refactor: update ChatsDrawer to use slivers for improved performance and layout

- Replaced ListView with CustomScrollView and SliverList for better lazy loading of conversation tiles.
- Removed legacy helper methods and optimized the layout structure using SliverPadding and SliverToBoxAdapter.
- Enhanced the refreshable scrollable functionality to support slivers, improving overall performance and responsiveness.
- Streamlined the handling of pinned, folder, and archived conversations within the drawer.
This commit is contained in:
cogwheel0
2025-10-10 13:19:11 +05:30
parent 9fff4d49ea
commit 4e64b6f32a

View File

@@ -78,42 +78,42 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
} catch (_) {} } catch (_) {}
} }
Widget _buildRefreshableScrollable({required List<Widget> children}) { // Build a lazily-constructed sliver list of conversation tiles.
// Common padding used in both scrollable variants Widget _conversationsSliver(List<dynamic> items, {bool inFolder = false}) {
const padding = EdgeInsets.fromLTRB(0, Spacing.sm, 0, Spacing.md); return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildTileFor(items[index], inFolder: inFolder),
childCount: items.length,
),
);
}
// Legacy helper removed: drawer now uses slivers with lazy delegates.
Widget _buildRefreshableScrollableSlivers({required List<Widget> slivers}) {
if (Platform.isIOS) { if (Platform.isIOS) {
// Use Cupertino-style pull-to-refresh on iOS
final scroll = CustomScrollView( final scroll = CustomScrollView(
key: const PageStorageKey<String>('chats_drawer_scroll'), key: const PageStorageKey<String>('chats_drawer_scroll'),
controller: _listController, controller: _listController,
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
slivers: [ slivers: [
CupertinoSliverRefreshControl(onRefresh: _refreshChats), CupertinoSliverRefreshControl(onRefresh: _refreshChats),
SliverPadding( ...slivers,
padding: padding,
sliver: SliverList(delegate: SliverChildListDelegate(children)),
),
], ],
); );
return CupertinoScrollbar(controller: _listController, child: scroll); return CupertinoScrollbar(controller: _listController, child: scroll);
} }
// Material pull-to-refresh elsewhere final scroll = CustomScrollView(
return RefreshIndicator(
onRefresh: _refreshChats,
child: Scrollbar(
controller: _listController,
child: ListView(
key: const PageStorageKey<String>('chats_drawer_scroll'), key: const PageStorageKey<String>('chats_drawer_scroll'),
controller: _listController, controller: _listController,
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
// Precache a bit ahead for perceived smoothness when scrolling.
cacheExtent: 800, cacheExtent: 800,
padding: padding, slivers: slivers,
children: children, );
), return RefreshIndicator(
), onRefresh: _refreshChats,
child: Scrollbar(controller: _listController, child: scroll),
); );
} }
@@ -285,35 +285,34 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
final archived = list.where((c) => c.archived == true).toList(); final archived = list.where((c) => c.archived == true).toList();
final children = <Widget>[ final slivers = <Widget>[
if (pinned.isNotEmpty) ...[ if (pinned.isNotEmpty) ...[
Padding( SliverPadding(
padding: const EdgeInsets.only( padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
left: Spacing.md, sliver: SliverToBoxAdapter(
right: Spacing.md,
),
child: _buildSectionHeader( child: _buildSectionHeader(
AppLocalizations.of(context)!.pinned, AppLocalizations.of(context)!.pinned,
pinned.length, pinned.length,
), ),
), ),
const SizedBox(height: Spacing.xs), ),
...pinned.map((conv) => _buildTileFor(conv)), const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
const SizedBox(height: Spacing.md), _conversationsSliver(pinned),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)),
], ],
// Folders section (shown even if empty) // Folders section (shown even if empty)
Padding( SliverPadding(
padding: const EdgeInsets.only( padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
left: Spacing.md, sliver: SliverToBoxAdapter(child: _buildFoldersSectionHeader()),
right: Spacing.md,
), ),
child: _buildFoldersSectionHeader(), const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
),
const SizedBox(height: Spacing.xs),
if (_isDragging && _draggingHasFolder) ...[ if (_isDragging && _draggingHasFolder) ...[
_buildUnfileDropTarget(), SliverPadding(
const SizedBox(height: Spacing.sm), padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter(child: _buildUnfileDropTarget()),
),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.sm)),
], ],
...ref ...ref
.watch(foldersProvider) .watch(foldersProvider)
@@ -327,8 +326,8 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
final expandedMap = ref.watch(_expandedFoldersProvider); final expandedMap = ref.watch(_expandedFoldersProvider);
// Show all folders (including empty) final out = <Widget>[];
final sections = folders.map((folder) { for (final folder in folders) {
final existing = grouped[folder.id] ?? const <dynamic>[]; final existing = grouped[folder.id] ?? const <dynamic>[];
final convs = _resolveFolderConversations( final convs = _resolveFolderConversations(
folder, folder,
@@ -337,59 +336,80 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
final isExpanded = final isExpanded =
expandedMap[folder.id] ?? folder.isExpanded; expandedMap[folder.id] ?? folder.isExpanded;
final hasItems = convs.isNotEmpty; final hasItems = convs.isNotEmpty;
return Column( out.add(
crossAxisAlignment: CrossAxisAlignment.stretch, SliverPadding(
children: [ padding: const EdgeInsets.symmetric(
_buildFolderHeader( horizontal: Spacing.md,
),
sliver: SliverToBoxAdapter(
child: _buildFolderHeader(
folder.id, folder.id,
folder.name, folder.name,
convs.length, convs.length,
defaultExpanded: folder.isExpanded, defaultExpanded: folder.isExpanded,
), ),
if (isExpanded && hasItems) ...[
const SizedBox(height: Spacing.xs),
...convs.map(
(c) => _buildTileFor(c, inFolder: true),
), ),
const SizedBox(height: Spacing.xs), ),
],
const SizedBox(height: Spacing.xs),
],
); );
}).toList(); if (isExpanded && hasItems) {
return sections.isEmpty out.add(
? [const SizedBox.shrink()] const SliverToBoxAdapter(
: sections; child: SizedBox(height: Spacing.xs),
},
loading: () => [const SizedBox.shrink()],
error: (e, st) => [const SizedBox.shrink()],
), ),
const SizedBox(height: Spacing.md), );
out.add(_conversationsSliver(convs, inFolder: true));
out.add(
const SliverToBoxAdapter(
child: SizedBox(height: Spacing.xs),
),
);
}
out.add(
const SliverToBoxAdapter(
child: SizedBox(height: Spacing.xs),
),
);
}
return out.isEmpty
? <Widget>[
const SliverToBoxAdapter(child: SizedBox.shrink()),
]
: out;
},
loading: () => [
const SliverToBoxAdapter(child: SizedBox.shrink()),
],
error: (e, st) => [
const SliverToBoxAdapter(child: SizedBox.shrink()),
],
),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)),
if (regular.isNotEmpty) ...[ if (regular.isNotEmpty) ...[
Padding( SliverPadding(
padding: const EdgeInsets.only( padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
left: Spacing.md, sliver: SliverToBoxAdapter(
right: Spacing.md,
),
child: _buildSectionHeader( child: _buildSectionHeader(
AppLocalizations.of(context)!.recent, AppLocalizations.of(context)!.recent,
regular.length, regular.length,
), ),
), ),
const SizedBox(height: Spacing.xs), ),
...regular.map(_buildTileFor), const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
_conversationsSliver(regular),
], ],
if (archived.isNotEmpty) ...[ if (archived.isNotEmpty) ...[
const SizedBox(height: Spacing.md), const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)),
Padding( SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md), padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter(
child: _buildArchivedSection(archived), child: _buildArchivedSection(archived),
), ),
),
], ],
]; ];
return _buildRefreshableScrollable(children: children); return _buildRefreshableScrollableSlivers(slivers: slivers);
}, },
loading: () => loading: () =>
const Center(child: CircularProgressIndicator(strokeWidth: 2.0)), const Center(child: CircularProgressIndicator(strokeWidth: 2.0)),
@@ -453,35 +473,54 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
final archived = list.where((c) => c.archived == true).toList(); final archived = list.where((c) => c.archived == true).toList();
final children = <Widget>[ final slivers = <Widget>[
Padding( SliverPadding(
padding: const EdgeInsets.only(left: Spacing.md, right: Spacing.md), padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter(
child: _buildSectionHeader('Results', list.length), child: _buildSectionHeader('Results', list.length),
), ),
const SizedBox(height: Spacing.xs), ),
if (pinned.isNotEmpty) ...[ const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
Padding( ];
if (pinned.isNotEmpty) {
slivers.addAll([
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md), padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter(
child: _buildSectionHeader( child: _buildSectionHeader(
AppLocalizations.of(context)!.pinned, AppLocalizations.of(context)!.pinned,
pinned.length, pinned.length,
), ),
), ),
const SizedBox(height: Spacing.xs),
...pinned.map((conv) => _buildTileFor(conv)),
const SizedBox(height: Spacing.md),
],
// Folders section (shown even if empty)
Padding(
padding: const EdgeInsets.only(left: Spacing.md, right: Spacing.md),
child: _buildFoldersSectionHeader(),
), ),
const SizedBox(height: Spacing.xs), const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
if (_isDragging && _draggingHasFolder) ...[ _conversationsSliver(pinned),
_buildUnfileDropTarget(), const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)),
const SizedBox(height: Spacing.sm), ]);
], }
...ref
slivers.addAll([
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter(child: _buildFoldersSectionHeader()),
),
const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
]);
if (_isDragging && _draggingHasFolder) {
slivers.add(
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter(child: _buildUnfileDropTarget()),
),
);
slivers.add(
const SliverToBoxAdapter(child: SizedBox(height: Spacing.sm)),
);
}
final folderSlivers = ref
.watch(foldersProvider) .watch(foldersProvider)
.when( .when(
data: (folders) { data: (folders) {
@@ -490,60 +529,89 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
final id = c.folderId!; final id = c.folderId!;
grouped.putIfAbsent(id, () => []).add(c); grouped.putIfAbsent(id, () => []).add(c);
} }
final expandedMap = ref.watch(_expandedFoldersProvider); final expandedMap = ref.watch(_expandedFoldersProvider);
final out = <Widget>[];
final sections = folders.map((folder) { for (final folder in folders) {
final existing = grouped[folder.id] ?? const <dynamic>[]; final existing = grouped[folder.id] ?? const <dynamic>[];
final convs = _resolveFolderConversations(folder, existing); final convs = _resolveFolderConversations(folder, existing);
final isExpanded = final isExpanded =
expandedMap[folder.id] ?? folder.isExpanded; expandedMap[folder.id] ?? folder.isExpanded;
final hasItems = convs.isNotEmpty; final hasItems = convs.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, out.add(
children: [ SliverPadding(
_buildFolderHeader( padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
),
sliver: SliverToBoxAdapter(
child: _buildFolderHeader(
folder.id, folder.id,
folder.name, folder.name,
convs.length, convs.length,
defaultExpanded: folder.isExpanded, defaultExpanded: folder.isExpanded,
), ),
if (isExpanded && hasItems) ...[ ),
const SizedBox(height: Spacing.xs), ),
...convs.map((c) => _buildTileFor(c, inFolder: true)), );
const SizedBox(height: Spacing.sm), if (isExpanded && hasItems) {
out.add(
const SliverToBoxAdapter(
child: SizedBox(height: Spacing.xs),
),
);
out.add(_conversationsSliver(convs, inFolder: true));
out.add(
const SliverToBoxAdapter(
child: SizedBox(height: Spacing.sm),
),
);
}
}
return out.isEmpty
? <Widget>[
const SliverToBoxAdapter(child: SizedBox.shrink()),
]
: out;
},
loading: () => <Widget>[
const SliverToBoxAdapter(child: SizedBox.shrink()),
], ],
error: (e, st) => <Widget>[
const SliverToBoxAdapter(child: SizedBox.shrink()),
], ],
); );
}).toList(); slivers.addAll(folderSlivers);
return sections.isEmpty
? [const SizedBox.shrink()] if (regular.isNotEmpty) {
: sections; slivers.addAll([
}, const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)),
loading: () => [const SizedBox.shrink()], SliverPadding(
error: (e, st) => [const SizedBox.shrink()],
),
const SizedBox(height: Spacing.md),
if (regular.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md), padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter(
child: _buildSectionHeader( child: _buildSectionHeader(
AppLocalizations.of(context)!.recent, AppLocalizations.of(context)!.recent,
regular.length, regular.length,
), ),
), ),
const SizedBox(height: Spacing.xs), ),
...regular.map(_buildTileFor), const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)),
], _conversationsSliver(regular),
if (archived.isNotEmpty) ...[ ]);
const SizedBox(height: Spacing.md), }
Padding(
if (archived.isNotEmpty) {
slivers.addAll([
const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md), padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
sliver: SliverToBoxAdapter(
child: _buildArchivedSection(archived), child: _buildArchivedSection(archived),
), ),
], ),
]; ]);
return _buildRefreshableScrollable(children: children); }
return _buildRefreshableScrollableSlivers(slivers: slivers);
}, },
loading: () => loading: () =>
const Center(child: CircularProgressIndicator(strokeWidth: 2.0)), const Center(child: CircularProgressIndicator(strokeWidth: 2.0)),