diff --git a/.claude/context/styling.md b/.claude/context/styling.md index a13fe00..37116af 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -78,7 +78,7 @@ Catalog: | Folder | Components | |---|---| | `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` | -| `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooSummaryCard` | +| `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooSummaryCard`, `NooSelectionBar` | | `files/` | `NooFileKind` (spec §1.2 tiles; `NooFileKind.from(name:, mimeType:, isDirectory:)`), `NooFileTile`, `NooStatusIcon`/`NooSyncStatus`, `NooFileRow` (mobile 64px), `NooFileTableHeader`/`NooFileTableRow` (desktop), `NooSwipeAction` | | `media/` | `NooGridCard`, `NooPhotoTile` (video badge, selection), `NooPhotoGroupHeader`/`NooPhotoGrid` (sliver, or `.box`), `NooActivityItem`, `NooStatCard` | | `nav/` | `NooBottomBar`, `NooTopBar` (a `PreferredSizeWidget`) with `NooTopBarButton`/`NooTopBarBack`, `NooDrawer` with its `Account`/`Storage`/`Item`/`Link` parts, `NooSidebar` with `NooSidebarItem`/`Divider`/`Account`/`Storage`, `NooToolbar` | diff --git a/lib/views/shares_view.dart b/lib/views/shares_view.dart index f7d4e60..3d398a4 100644 --- a/lib/views/shares_view.dart +++ b/lib/views/shares_view.dart @@ -11,7 +11,6 @@ import '../widgets/noo/core/noo_segmented_control.dart'; import '../widgets/noo/files/noo_file_kind.dart'; import '../widgets/noo/files/noo_file_row.dart'; import '../widgets/noo/files/noo_file_table.dart'; -import '../widgets/noo/lists/noo_grouped_list.dart'; import '../widgets/noo/noo_layout.dart'; import '../widgets/tabs/tab_state_slivers.dart'; @@ -162,24 +161,47 @@ class _SharesViewState extends State { c.setSharedWithMe(scope == _ShareScope.withYou); } + // A lazily-built `SliverList`, not `NooGroupedList` (its own `Column` + // isn't lazy - see files_view.dart's `_buildMobileRow` doc comment, and + // trash_view.dart's `_buildMobileList`, which had the same bug: a heavy + // account's full share list built eagerly up front). Each row still + // reads as one continuous radius-20 card via per-row corner rounding + + // a 1px `line` divider. Widget _buildMobileList(BuildContext context, List shares) { + final colors = context.nooColors; return SliverPadding( padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), - sliver: SliverToBoxAdapter( - child: NooGroupedList( - children: [ - for (final share in shares) - NooFileRow( - kind: NooFileKind.from(name: share.name, isDirectory: share.isFolder), - name: share.name, - meta: '${share.ownerDisplayName} · ${_permissionLabel(share.permissions)}', - iosStyle: NooLayout.iosStyle(context), - onTap: share.isFolder ? () => _openFolder(context, share) : null, - trailing: Icon(_shareTypeIcon(share.shareType), size: 16), - onMore: () => _confirmUnshare(context, share), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final share = shares[index]; + final row = NooFileRow( + kind: NooFileKind.from(name: share.name, isDirectory: share.isFolder), + name: share.name, + meta: '${share.ownerDisplayName} · ${_permissionLabel(share.permissions)}', + iosStyle: NooLayout.iosStyle(context), + onTap: share.isFolder ? () => _openFolder(context, share) : null, + trailing: Icon(_shareTypeIcon(share.shareType), size: 16), + onMore: () => _confirmUnshare(context, share), + ); + final isFirst = index == 0; + final isLast = index == shares.length - 1; + return Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.vertical( + top: isFirst + ? const Radius.circular(NooRadii.card) + : Radius.zero, + bottom: isLast + ? const Radius.circular(NooRadii.card) + : Radius.zero, + ), + child: row, ), - ], - ), + if (!isLast) Container(height: 1, color: colors.line), + ], + ); + }, childCount: shares.length), ), ); } diff --git a/lib/views/trash_view.dart b/lib/views/trash_view.dart index 339f01a..666c8f9 100644 --- a/lib/views/trash_view.dart +++ b/lib/views/trash_view.dart @@ -11,7 +11,6 @@ import '../widgets/noo/files/noo_file_row.dart'; import '../widgets/noo/files/noo_file_table.dart'; import '../widgets/noo/files/noo_file_tile.dart'; import '../widgets/noo/lists/noo_banner.dart'; -import '../widgets/noo/lists/noo_grouped_list.dart'; import '../widgets/noo/noo_layout.dart'; import '../widgets/tabs/tab_location.dart'; import '../widgets/tabs/tab_state_slivers.dart'; @@ -103,38 +102,61 @@ class _TrashViewState extends State { ); } + // A lazily-built `SliverList`, not `NooGroupedList` (its own `Column` + // isn't lazy - see files_view.dart's `_buildMobileRow` doc comment for + // the same reasoning): Trash can hold hundreds of items, and building + // every row eagerly up front is what made this tab laggy. Each row still + // reads as one continuous radius-20 card via per-row corner rounding + + // a 1px `line` divider, matching `NooGroupedList`'s look. Widget _buildMobileList(BuildContext context, List trash) { + final colors = context.nooColors; return SliverPadding( padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), - sliver: SliverToBoxAdapter( - child: NooGroupedList( - children: [ - for (final item in trash) - NooFileRow( - kind: NooFileKind.from( - name: item.name, - mimeType: item.mimeType, - isDirectory: item.isFolder, + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final item = trash[index]; + final row = NooFileRow( + kind: NooFileKind.from( + name: item.name, + mimeType: item.mimeType, + isDirectory: item.isFolder, + ), + name: item.name, + meta: _meta(item), + iosStyle: NooLayout.iosStyle(context), + trailing: SizedBox.square( + dimension: 40, + child: Semantics( + button: true, + label: 'Restore', + child: InkResponse( + onTap: () => _restore(context, item), + radius: 20, + child: const Icon(LucideIcons.rotateCcw), ), - name: item.name, - meta: _meta(item), - iosStyle: NooLayout.iosStyle(context), - trailing: SizedBox.square( - dimension: 40, - child: Semantics( - button: true, - label: 'Restore', - child: InkResponse( - onTap: () => _restore(context, item), - radius: 20, - child: const Icon(LucideIcons.rotateCcw), - ), - ), - ), - onMore: () => _confirmDeleteForever(context, item), ), - ], - ), + ), + onMore: () => _confirmDeleteForever(context, item), + ); + final isFirst = index == 0; + final isLast = index == trash.length - 1; + return Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.vertical( + top: isFirst + ? const Radius.circular(NooRadii.card) + : Radius.zero, + bottom: isLast + ? const Radius.circular(NooRadii.card) + : Radius.zero, + ), + child: row, + ), + if (!isLast) Container(height: 1, color: colors.line), + ], + ); + }, childCount: trash.length), ), ); } diff --git a/lib/widgets/files_controls_row.dart b/lib/widgets/files_controls_row.dart index 553724c..3d90c22 100644 --- a/lib/widgets/files_controls_row.dart +++ b/lib/widgets/files_controls_row.dart @@ -93,33 +93,49 @@ class FilesControlsRow extends StatelessWidget { showNooSheet( context, children: [ - NooSegmentedControl( - fill: true, - value: files.sortAscendingFor(folderPath), - onChanged: (asc) { - if (asc != files.sortAscendingFor(folderPath)) { - files.toggleSortOrderFor(folderPath); - } - }, - options: const [ - NooSegmentOption(value: true, icon: LucideIcons.arrowUp, label: 'Ascending'), - NooSegmentOption(value: false, icon: LucideIcons.arrowDown, label: 'Descending'), - ], - ), - NooGroupedList( - children: [ - for (final field in FileSortField.values) - NooSettingsRow( - label: Text(sortFieldLabel(field)), - trailing: field == files.sortFieldFor(folderPath) - ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) - : null, - onTap: () { - files.setSortFieldFor(folderPath, field); - Navigator.pop(context); + // `showNooSheet`'s `children` are built once, up front - a bare + // checkmark/value here would freeze at whatever it was when the + // sheet opened, since tapping a row calls `files.set...` and + // notifies the controller, not this already-built widget tree. + // `ListenableBuilder` re-runs its `builder` on every + // `FilesController.notifyListeners()` instead, so the selection + // updates live - see the identical fix in `_showFilterSheet`. + ListenableBuilder( + listenable: files, + builder: (context, _) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + NooSegmentedControl( + fill: true, + value: files.sortAscendingFor(folderPath), + onChanged: (asc) { + if (asc != files.sortAscendingFor(folderPath)) { + files.toggleSortOrderFor(folderPath); + } }, + options: const [ + NooSegmentOption(value: true, icon: LucideIcons.arrowUp, label: 'Ascending'), + NooSegmentOption(value: false, icon: LucideIcons.arrowDown, label: 'Descending'), + ], ), - ], + const SizedBox(height: 22), + NooGroupedList( + children: [ + for (final field in FileSortField.values) + NooSettingsRow( + label: Text(sortFieldLabel(field)), + trailing: field == files.sortFieldFor(folderPath) + ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + : null, + onTap: () { + files.setSortFieldFor(folderPath, field); + Navigator.pop(context); + }, + ), + ], + ), + ], + ), ), ], ); @@ -129,54 +145,66 @@ class FilesControlsRow extends StatelessWidget { showNooSheet( context, children: [ - NooGroupedList( - label: 'Show', - children: [ - NooSettingsRow( - label: const Text('Files and folders'), - trailing: files.filesTypeFilter == FilesTypeFilter.all - ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) - : null, - onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all), - ), - NooSettingsRow( - label: const Text('Files only'), - trailing: files.filesTypeFilter == FilesTypeFilter.filesOnly - ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) - : null, - onTap: () => files.setFilesTypeFilter(FilesTypeFilter.filesOnly), - ), - NooSettingsRow( - label: const Text('Folders only'), - trailing: files.filesTypeFilter == FilesTypeFilter.foldersOnly - ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) - : null, - onTap: () => files.setFilesTypeFilter(FilesTypeFilter.foldersOnly), - ), - ], - ), - NooGroupedList( - children: [ - NooSettingsRow( - icon: LucideIcons.eye, - label: const Text('Show hidden files'), - trailing: NooToggle( - checked: files.showHiddenFiles, - onChanged: (_) => files.toggleShowHiddenFiles(), - ), - ), - if (showStorageScope) - NooSettingsRow( - icon: LucideIcons.hardDrive, - label: const Text('External storage'), - trailing: NooToggle( - checked: files.storageScope == StorageScope.external, - onChanged: (external) => files.setStorageScope( - external ? StorageScope.external : StorageScope.cloud, + // See `_showSortSheet`'s comment: without this, none of the + // checkmarks/toggles below would move until the sheet is closed + // and reopened. + ListenableBuilder( + listenable: files, + builder: (context, _) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + NooGroupedList( + label: 'Show', + children: [ + NooSettingsRow( + label: const Text('Files and folders'), + trailing: files.filesTypeFilter == FilesTypeFilter.all + ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + : null, + onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all), ), - ), + NooSettingsRow( + label: const Text('Files only'), + trailing: files.filesTypeFilter == FilesTypeFilter.filesOnly + ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + : null, + onTap: () => files.setFilesTypeFilter(FilesTypeFilter.filesOnly), + ), + NooSettingsRow( + label: const Text('Folders only'), + trailing: files.filesTypeFilter == FilesTypeFilter.foldersOnly + ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + : null, + onTap: () => files.setFilesTypeFilter(FilesTypeFilter.foldersOnly), + ), + ], ), - ], + const SizedBox(height: 22), + NooGroupedList( + children: [ + NooSettingsRow( + icon: LucideIcons.eye, + label: const Text('Show hidden files'), + trailing: NooToggle( + checked: files.showHiddenFiles, + onChanged: (_) => files.toggleShowHiddenFiles(), + ), + ), + if (showStorageScope) + NooSettingsRow( + icon: LucideIcons.hardDrive, + label: const Text('External storage'), + trailing: NooToggle( + checked: files.storageScope == StorageScope.external, + onChanged: (external) => files.setStorageScope( + external ? StorageScope.external : StorageScope.cloud, + ), + ), + ), + ], + ), + ], + ), ), ], );