feat: pull to refresh on chat drawer

This commit is contained in:
cogwheel0
2025-08-28 23:15:55 +05:30
parent f65cf33c59
commit 6dabd108f9

View File

@@ -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)),