feat: pull to refresh on chat drawer
This commit is contained in:
@@ -41,6 +41,74 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
|||||||
(ref) => {},
|
(ref) => {},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Future<void> _refreshChats() async {
|
||||||
|
try {
|
||||||
|
// Always refresh folders
|
||||||
|
ref.invalidate(foldersProvider);
|
||||||
|
|
||||||
|
if (_query.trim().isEmpty) {
|
||||||
|
// Refresh main conversations list
|
||||||
|
ref.invalidate(conversationsProvider);
|
||||||
|
try {
|
||||||
|
await ref.read(conversationsProvider.future);
|
||||||
|
} catch (_) {}
|
||||||
|
} else {
|
||||||
|
// Refresh server-side search results
|
||||||
|
ref.invalidate(serverSearchProvider(_query));
|
||||||
|
try {
|
||||||
|
await ref.read(serverSearchProvider(_query).future);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Await folders as well so the list stabilizes
|
||||||
|
try {
|
||||||
|
await ref.read(foldersProvider.future);
|
||||||
|
} catch (_) {}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRefreshableScrollable({required List<Widget> children}) {
|
||||||
|
// Common padding used in both scrollable variants
|
||||||
|
const padding = EdgeInsets.fromLTRB(
|
||||||
|
Spacing.md,
|
||||||
|
Spacing.sm,
|
||||||
|
Spacing.md,
|
||||||
|
Spacing.md,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
// Use Cupertino-style pull-to-refresh on iOS
|
||||||
|
final scroll = CustomScrollView(
|
||||||
|
controller: _listController,
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
slivers: [
|
||||||
|
const CupertinoSliverRefreshControl(),
|
||||||
|
SliverPadding(
|
||||||
|
padding: padding,
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildListDelegate(children),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return CupertinoScrollbar(controller: _listController, child: scroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Material pull-to-refresh elsewhere
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _refreshChats,
|
||||||
|
child: Scrollbar(
|
||||||
|
controller: _listController,
|
||||||
|
child: ListView(
|
||||||
|
controller: _listController,
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: padding,
|
||||||
|
children: children,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
@@ -226,95 +294,84 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
|||||||
|
|
||||||
final archived = list.where((c) => c.archived == true).toList();
|
final archived = list.where((c) => c.archived == true).toList();
|
||||||
|
|
||||||
return Scrollbar(
|
final children = <Widget>[
|
||||||
controller: _listController,
|
if (pinned.isNotEmpty) ...[
|
||||||
child: ListView(
|
_buildSectionHeader(
|
||||||
controller: _listController,
|
AppLocalizations.of(context)!.pinned,
|
||||||
padding: const EdgeInsets.fromLTRB(
|
pinned.length,
|
||||||
Spacing.md,
|
|
||||||
Spacing.sm,
|
|
||||||
Spacing.md,
|
|
||||||
Spacing.md,
|
|
||||||
),
|
),
|
||||||
children: [
|
const SizedBox(height: Spacing.xs),
|
||||||
if (pinned.isNotEmpty) ...[
|
...pinned.map((conv) => _buildTileFor(conv)),
|
||||||
_buildSectionHeader(
|
const SizedBox(height: Spacing.md),
|
||||||
AppLocalizations.of(context)!.pinned,
|
],
|
||||||
pinned.length,
|
|
||||||
),
|
|
||||||
const SizedBox(height: Spacing.xs),
|
|
||||||
...pinned.map((conv) => _buildTileFor(conv)),
|
|
||||||
const SizedBox(height: Spacing.md),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Folders section (shown even if empty)
|
// Folders section (shown even if empty)
|
||||||
_buildFoldersSectionHeader(),
|
_buildFoldersSectionHeader(),
|
||||||
const SizedBox(height: Spacing.xs),
|
const SizedBox(height: Spacing.xs),
|
||||||
if (_isDragging && _draggingHasFolder) ...[
|
if (_isDragging && _draggingHasFolder) ...[
|
||||||
_buildUnfileDropTarget(),
|
_buildUnfileDropTarget(),
|
||||||
const SizedBox(height: Spacing.sm),
|
const SizedBox(height: Spacing.sm),
|
||||||
],
|
],
|
||||||
...ref
|
...ref
|
||||||
.watch(foldersProvider)
|
.watch(foldersProvider)
|
||||||
.when(
|
.when(
|
||||||
data: (folders) {
|
data: (folders) {
|
||||||
final grouped = <String, List<dynamic>>{};
|
final grouped = <String, List<dynamic>>{};
|
||||||
for (final c in foldered) {
|
for (final c in foldered) {
|
||||||
final id = c.folderId!;
|
final id = c.folderId!;
|
||||||
grouped.putIfAbsent(id, () => []).add(c);
|
grouped.putIfAbsent(id, () => []).add(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show all folders (including empty)
|
// Show all folders (including empty)
|
||||||
final sections = folders.map((folder) {
|
final sections = folders.map((folder) {
|
||||||
final expandedMap = ref.watch(
|
final expandedMap = ref.watch(
|
||||||
_expandedFoldersProvider,
|
_expandedFoldersProvider,
|
||||||
);
|
);
|
||||||
final isExpanded = expandedMap[folder.id] ?? false;
|
final isExpanded = expandedMap[folder.id] ?? false;
|
||||||
final convs = grouped[folder.id] ?? const <dynamic>[];
|
final convs = grouped[folder.id] ?? const <dynamic>[];
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_buildFolderHeader(
|
_buildFolderHeader(
|
||||||
folder.id,
|
folder.id,
|
||||||
folder.name,
|
folder.name,
|
||||||
convs.length,
|
convs.length,
|
||||||
),
|
),
|
||||||
if (isExpanded && convs.isNotEmpty) ...[
|
if (isExpanded && convs.isNotEmpty) ...[
|
||||||
const SizedBox(height: Spacing.xs),
|
const SizedBox(height: Spacing.xs),
|
||||||
...convs.map(
|
...convs.map(
|
||||||
(c) => _buildTileFor(c, inFolder: true),
|
(c) => _buildTileFor(c, inFolder: true),
|
||||||
),
|
),
|
||||||
const SizedBox(height: Spacing.xs),
|
const SizedBox(height: Spacing.xs),
|
||||||
],
|
],
|
||||||
const SizedBox(height: Spacing.xs),
|
const SizedBox(height: Spacing.xs),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
return sections.isEmpty
|
return sections.isEmpty
|
||||||
? [const SizedBox.shrink()]
|
? [const SizedBox.shrink()]
|
||||||
: sections;
|
: sections;
|
||||||
},
|
},
|
||||||
loading: () => [const SizedBox.shrink()],
|
loading: () => [const SizedBox.shrink()],
|
||||||
error: (e, st) => [const SizedBox.shrink()],
|
error: (e, st) => [const SizedBox.shrink()],
|
||||||
),
|
),
|
||||||
const SizedBox(height: Spacing.md),
|
const SizedBox(height: Spacing.md),
|
||||||
|
|
||||||
if (regular.isNotEmpty) ...[
|
if (regular.isNotEmpty) ...[
|
||||||
_buildSectionHeader(
|
_buildSectionHeader(
|
||||||
AppLocalizations.of(context)!.recent,
|
AppLocalizations.of(context)!.recent,
|
||||||
regular.length,
|
regular.length,
|
||||||
),
|
),
|
||||||
const SizedBox(height: Spacing.xs),
|
const SizedBox(height: Spacing.xs),
|
||||||
...regular.map(_buildTileFor),
|
...regular.map(_buildTileFor),
|
||||||
],
|
],
|
||||||
|
|
||||||
if (archived.isNotEmpty) ...[
|
if (archived.isNotEmpty) ...[
|
||||||
const SizedBox(height: Spacing.md),
|
const SizedBox(height: Spacing.md),
|
||||||
_buildArchivedSection(archived),
|
_buildArchivedSection(archived),
|
||||||
],
|
],
|
||||||
],
|
];
|
||||||
),
|
return _buildRefreshableScrollable(children: children);
|
||||||
);
|
|
||||||
},
|
},
|
||||||
loading: () =>
|
loading: () =>
|
||||||
const Center(child: CircularProgressIndicator(strokeWidth: 2.0)),
|
const Center(child: CircularProgressIndicator(strokeWidth: 2.0)),
|
||||||
@@ -377,90 +434,79 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
|||||||
|
|
||||||
final archived = list.where((c) => c.archived == true).toList();
|
final archived = list.where((c) => c.archived == true).toList();
|
||||||
|
|
||||||
return Scrollbar(
|
final children = <Widget>[
|
||||||
controller: _listController,
|
_buildSectionHeader('Results', list.length),
|
||||||
child: ListView(
|
const SizedBox(height: Spacing.xs),
|
||||||
controller: _listController,
|
if (pinned.isNotEmpty) ...[
|
||||||
padding: const EdgeInsets.fromLTRB(
|
_buildSectionHeader(
|
||||||
Spacing.md,
|
AppLocalizations.of(context)!.pinned,
|
||||||
Spacing.sm,
|
pinned.length,
|
||||||
Spacing.md,
|
|
||||||
Spacing.md,
|
|
||||||
),
|
),
|
||||||
children: [
|
const SizedBox(height: Spacing.xs),
|
||||||
_buildSectionHeader('Results', list.length),
|
...pinned.map((conv) => _buildTileFor(conv)),
|
||||||
const SizedBox(height: Spacing.xs),
|
const SizedBox(height: Spacing.md),
|
||||||
if (pinned.isNotEmpty) ...[
|
],
|
||||||
_buildSectionHeader(
|
// Folders section (shown even if empty)
|
||||||
AppLocalizations.of(context)!.pinned,
|
_buildFoldersSectionHeader(),
|
||||||
pinned.length,
|
const SizedBox(height: Spacing.xs),
|
||||||
),
|
if (_isDragging && _draggingHasFolder) ...[
|
||||||
const SizedBox(height: Spacing.xs),
|
_buildUnfileDropTarget(),
|
||||||
...pinned.map((conv) => _buildTileFor(conv)),
|
const SizedBox(height: Spacing.sm),
|
||||||
const SizedBox(height: Spacing.md),
|
],
|
||||||
],
|
...ref
|
||||||
// Folders section (shown even if empty)
|
.watch(foldersProvider)
|
||||||
_buildFoldersSectionHeader(),
|
.when(
|
||||||
const SizedBox(height: Spacing.xs),
|
data: (folders) {
|
||||||
if (_isDragging && _draggingHasFolder) ...[
|
final grouped = <String, List<dynamic>>{};
|
||||||
_buildUnfileDropTarget(),
|
for (final c in foldered) {
|
||||||
const SizedBox(height: Spacing.sm),
|
final id = c.folderId!;
|
||||||
],
|
grouped.putIfAbsent(id, () => []).add(c);
|
||||||
...ref
|
}
|
||||||
.watch(foldersProvider)
|
|
||||||
.when(
|
|
||||||
data: (folders) {
|
|
||||||
final grouped = <String, List<dynamic>>{};
|
|
||||||
for (final c in foldered) {
|
|
||||||
final id = c.folderId!;
|
|
||||||
grouped.putIfAbsent(id, () => []).add(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
final sections = folders.map((folder) {
|
final sections = folders.map((folder) {
|
||||||
final expandedMap = ref.watch(_expandedFoldersProvider);
|
final expandedMap = ref.watch(_expandedFoldersProvider);
|
||||||
final isExpanded = expandedMap[folder.id] ?? false;
|
final isExpanded = expandedMap[folder.id] ?? false;
|
||||||
final convs = grouped[folder.id] ?? const <dynamic>[];
|
final convs = grouped[folder.id] ?? const <dynamic>[];
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_buildFolderHeader(
|
_buildFolderHeader(
|
||||||
folder.id,
|
folder.id,
|
||||||
folder.name,
|
folder.name,
|
||||||
convs.length,
|
convs.length,
|
||||||
),
|
),
|
||||||
if (isExpanded && convs.isNotEmpty) ...[
|
if (isExpanded && convs.isNotEmpty) ...[
|
||||||
const SizedBox(height: Spacing.xs),
|
const SizedBox(height: Spacing.xs),
|
||||||
...convs.map(
|
...convs.map(
|
||||||
(c) => _buildTileFor(c, inFolder: true),
|
(c) => _buildTileFor(c, inFolder: true),
|
||||||
),
|
),
|
||||||
const SizedBox(height: Spacing.sm),
|
const SizedBox(height: Spacing.sm),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
return sections.isEmpty
|
return sections.isEmpty
|
||||||
? [const SizedBox.shrink()]
|
? [const SizedBox.shrink()]
|
||||||
: sections;
|
: sections;
|
||||||
},
|
},
|
||||||
loading: () => [const SizedBox.shrink()],
|
loading: () => [const SizedBox.shrink()],
|
||||||
error: (e, st) => [const SizedBox.shrink()],
|
error: (e, st) => [const SizedBox.shrink()],
|
||||||
),
|
),
|
||||||
const SizedBox(height: Spacing.md),
|
const SizedBox(height: Spacing.md),
|
||||||
if (regular.isNotEmpty) ...[
|
if (regular.isNotEmpty) ...[
|
||||||
_buildSectionHeader(
|
_buildSectionHeader(
|
||||||
AppLocalizations.of(context)!.recent,
|
AppLocalizations.of(context)!.recent,
|
||||||
regular.length,
|
regular.length,
|
||||||
),
|
),
|
||||||
const SizedBox(height: Spacing.xs),
|
const SizedBox(height: Spacing.xs),
|
||||||
...regular.map(_buildTileFor),
|
...regular.map(_buildTileFor),
|
||||||
],
|
],
|
||||||
if (archived.isNotEmpty) ...[
|
if (archived.isNotEmpty) ...[
|
||||||
const SizedBox(height: Spacing.md),
|
const SizedBox(height: Spacing.md),
|
||||||
_buildArchivedSection(archived),
|
_buildArchivedSection(archived),
|
||||||
],
|
],
|
||||||
],
|
];
|
||||||
),
|
return _buildRefreshableScrollable(children: children);
|
||||||
);
|
|
||||||
},
|
},
|
||||||
loading: () =>
|
loading: () =>
|
||||||
const Center(child: CircularProgressIndicator(strokeWidth: 2.0)),
|
const Center(child: CircularProgressIndicator(strokeWidth: 2.0)),
|
||||||
|
|||||||
Reference in New Issue
Block a user