diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index 6e49ad2..e5a2383 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -10,7 +10,7 @@ Reference files in this project: - `Noo Design System.dc.html` is the visual component sheet. - `noo-kit.js` holds the tokens (`TH`), the icon set (`SVG`/`ic`), file-type mapping (`KIND`) and mock data. - `Mobile Screen.dc.html` and `Desktop Screen.dc.html` are the reference builds for each screen. -- [Noo — Missing Screens](https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4) is a live design canvas (Claude Design, not a static file in this repo) covering the screens this doc originally had no recipe for: Media viewer, Search, the Upload/Move/Copy destination picker, and the Details sheet's tab switch. It has both an iOS row (built first, ready for later) and an Android row (built to match right now) - same content, chrome adapted per the platform rule above. §4 below is the written-up version of what's approved there; go back to the canvas for pixel-level layout, not just the summary. +- [Noo — Missing Screens](https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4) is a live design canvas (Claude Design, not a static file in this repo) covering pieces this doc originally had no recipe for: Media viewer, Search, the Upload/Move/Copy destination picker, the Details sheet's tab switch, and the Selection action bar (mobile + desktop). It has both an iOS row (built first, ready for later) and an Android row (built to match right now) - same content, chrome adapted per the platform rule above. §4 below is the written-up version of what's approved there; go back to the canvas for pixel-level layout, not just the summary. --- @@ -160,6 +160,31 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14 - The block has a 20px icon above a 12/600 label. - Swipe actions are set in Settings → Swipe on a file. +**Selection action bar** (Files, Photos, Favorites - anywhere with +multi-select) +- Replaces that screen's own sort/filter row in place while selecting - + never a second bar stacked underneath it, and never the shell's own top + bar/toolbar, which don't change for selection. +- A `surface` card, radius 20, in the screen's usual gutter (12 mobile, 24 + desktop) - selection reads as a distinct mode, not a bare row of buttons + on `bg`. +- Left to right: a 36px `surface-2` close circle (`x`), then "N selected" + (17/500), then the bulk actions, trailing-anchored. Fixed width, never + scrolls: only the first few actions show inline (3 on mobile, 4 on + desktop - there can be up to 9: favorite, share, download, delete, copy, + move, rename, sync, details); the rest sit behind a trailing "More" + button that opens the same grouped-list sheet a file row's own overflow + menu (`ellipsis`/`ellipsis-vertical`) already uses. +- Mobile: inline actions are plain 20px accent-text icons, no fill; "More" + is the same 40px `NooOverflowButton` treatment (`ellipsis`/ + `ellipsis-vertical`, fg-3) file rows use for their own overflow menu. +- Desktop: inline actions are labelled tonal pills (accent-soft/ + accent-text), danger-soft/danger for the one labelled "Delete" - desktop + has the room for labels, and the app's other toolbar actions are already + labelled buttons rather than bare icons. "More" is a secondary pill + (surface-2/fg-1) with a trailing `chevron-down`, so it reads as "opens a + menu" rather than another bulk action. + **Grid card** - Surface fill, radius 18. Mobile uses 2 columns with a 10px gap; desktop uses 5 columns with a 16px gap. - The thumbnail area is 104–118px tall, filled with the file-type soft color and a 32px icon. Real thumbnails replace it when they exist. diff --git a/lib/views/favorites_view.dart b/lib/views/favorites_view.dart index 9883f47..56404a5 100644 --- a/lib/views/favorites_view.dart +++ b/lib/views/favorites_view.dart @@ -21,6 +21,7 @@ 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_grouped_list.dart'; +import '../widgets/noo/lists/noo_selection_bar.dart'; import '../widgets/noo/lists/noo_settings_row.dart'; import '../widgets/noo/media/noo_grid_card.dart'; import '../widgets/noo/noo_layout.dart'; @@ -55,92 +56,23 @@ class FavoritesView extends StatefulWidget { State createState() => _FavoritesViewState(); } -class _FavoritesViewState extends State - with SingleTickerProviderStateMixin { +class _FavoritesViewState extends State { bool _requested = false; final Set _selectedIds = {}; - final ScrollController _selectionActionsScrollController = ScrollController(); - final List _scrollHintControllers = []; bool get _isSelecting => _selectedIds.isNotEmpty; void _toggleSelection(NextcloudItem item) { HapticFeedback.selectionClick(); - final enteringSelection = _selectedIds.isEmpty; setState(() { if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); }); - if (enteringSelection && _isSelecting) { - WidgetsBinding.instance.addPostFrameCallback( - (_) => _playScrollHint(_selectionActionsScrollController), - ); - } } void _clearSelection() { setState(_selectedIds.clear); } - /// Mirrors `FilesView`/`PhotosView`'s identical scroll-hint - see their - /// doc comment: nudges the selection actions row right and back, once, - /// the first time a selection starts. No-ops if there's nothing to - /// scroll (row already fits). - Future _playScrollHint(ScrollController scrollController) async { - await Future.delayed(const Duration(milliseconds: 500)); - // `hasClients` only means a position is attached, not that it's - // finished its first layout - reading maxScrollExtent before that - // throws (min/maxScrollExtent are still null internally). - if (!mounted || - !scrollController.hasClients || - !scrollController.position.hasContentDimensions) { - return; - } - final maxExtent = scrollController.position.maxScrollExtent; - if (maxExtent <= 0) return; - final double peak = maxExtent < 36 ? maxExtent : 36; - final controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 900), - ); - _scrollHintControllers.add(controller); - final hint = TweenSequence([ - TweenSequenceItem( - tween: Tween( - begin: 0.0, - end: peak, - ).chain(CurveTween(curve: Curves.easeInOutSine)), - weight: 50, - ), - TweenSequenceItem( - tween: Tween( - begin: peak, - end: 0.0, - ).chain(CurveTween(curve: Curves.easeInOutSine)), - weight: 50, - ), - ]).animate(controller); - void onTick() { - if (scrollController.hasClients) { - scrollController.jumpTo(hint.value); - } - } - - hint.addListener(onTick); - await controller.forward(); - hint.removeListener(onTick); - _scrollHintControllers.remove(controller); - controller.dispose(); - } - - @override - void dispose() { - for (final controller in _scrollHintControllers) { - controller.dispose(); - } - _selectionActionsScrollController.dispose(); - super.dispose(); - } - /// A favorited folder switches to the Files tab, navigated there; a /// favorited file just opens directly from here, like Files/Photos do - /// no need to reposition Files first since dismissing the viewer lands @@ -166,40 +98,40 @@ class _FavoritesViewState extends State List _buildSelectionActions(List selected) { return [ SelectionAction( - icon: Icons.favorite_border_rounded, + icon: LucideIcons.starOff, label: 'Remove from favorites', onTap: () => _unfavoriteSelected(selected), ), SelectionAction( - icon: Icons.share_rounded, + icon: LucideIcons.share2, label: 'Share', onTap: () => selected.length == 1 ? ShareSheet.show(context, selected.single) : _shareSelected(context, selected), ), SelectionAction( - icon: Icons.download_rounded, + icon: LucideIcons.download, label: 'Download', onTap: () => _downloadSelected(context, selected), ), SelectionAction( - icon: Icons.delete_outline_rounded, + icon: LucideIcons.trash2, label: 'Delete', onTap: () => _confirmDeleteSelected(context, selected), ), SelectionAction( - icon: Icons.copy_rounded, + icon: LucideIcons.copy, label: 'Copy', onTap: () => _moveOrCopySelected(selected, copy: true), ), SelectionAction( - icon: Icons.drive_file_move_rounded, + icon: LucideIcons.folderInput, label: 'Move', onTap: () => _moveOrCopySelected(selected, copy: false), ), if (selected.length == 1) SelectionAction( - icon: Icons.info_outline_rounded, + icon: LucideIcons.info, label: 'Details', onTap: () => DetailsSheet.show(context, selected.single), ), @@ -477,7 +409,11 @@ class _FavoritesViewState extends State col3: item.isFolder ? null : formatBytes(item.size), favorite: item.isFavorite, selected: isSelected, - thumbnail: _thumbnailFor(context, item, extent: NooFileTileSize.desktop.extent), + thumbnail: _thumbnailFor( + context, + item, + extent: NooFileTileSize.desktop.extent, + ), onTap: () { if (_isSelecting) { _toggleSelection(item); @@ -639,7 +575,12 @@ class _FavoritesViewState extends State childAspectRatio: isDesktop ? 0.92 : 0.85, ), delegate: SliverChildBuilderDelegate((context, index) { - return _buildGridCard(context, favorites[index], siblings, isDesktop); + return _buildGridCard( + context, + favorites[index], + siblings, + isDesktop, + ); }, childCount: favorites.length), ), ) @@ -673,11 +614,7 @@ class _FavoritesViewState extends State }, ); } - return _buildTableRow( - context, - favorites[index - 1], - siblings, - ); + return _buildTableRow(context, favorites[index - 1], siblings); }, childCount: favorites.length + 1), ), ) @@ -719,56 +656,12 @@ class _FavoritesViewState extends State BuildContext context, List selectedItems, ) { - final colors = context.nooColors; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), - child: Row( - children: [ - SizedBox( - width: MediaQuery.of(context).size.width * 0.5, - child: Align( - alignment: Alignment.centerLeft, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: Icon(LucideIcons.x, size: 20, color: colors.fg1), - tooltip: 'Cancel selection', - onPressed: _clearSelection, - visualDensity: VisualDensity.compact, - ), - const SizedBox(width: 8), - Text( - '${selectedItems.length} selected', - style: NooText.cardTitle.copyWith( - fontSize: 17, - color: colors.fg1, - ), - ), - ], - ), - ), - ), - Expanded( - child: SingleChildScrollView( - controller: _selectionActionsScrollController, - scrollDirection: Axis.horizontal, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (final action in _buildSelectionActions(selectedItems)) - IconButton( - icon: Icon(action.icon, size: 20, color: colors.fg1), - tooltip: action.label, - onPressed: action.onTap, - visualDensity: VisualDensity.compact, - ), - ], - ), - ), - ), - ], - ), + return NooSelectionBar( + count: selectedItems.length, + actions: _buildSelectionActions(selectedItems), + onClose: _clearSelection, + isDesktop: NooLayout.isDesktop(context), + iosStyle: NooLayout.iosStyle(context), ); } } diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index 1037369..568032f 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -35,6 +35,7 @@ import '../widgets/noo/files/noo_file_tile.dart'; import '../widgets/noo/files/noo_status_icon.dart'; import '../widgets/noo/files/noo_swipe_action.dart'; import '../widgets/noo/lists/noo_grouped_list.dart'; +import '../widgets/noo/lists/noo_selection_bar.dart'; import '../widgets/noo/lists/noo_settings_row.dart'; import '../widgets/noo/lists/noo_summary_card.dart'; import '../widgets/noo/media/noo_grid_card.dart'; @@ -231,7 +232,6 @@ class _FilesViewState extends State final Set _selectedIds = {}; int _lastPathDepth = 1; final ScrollController _controlsScrollController = ScrollController(); - final ScrollController _selectionActionsScrollController = ScrollController(); final List _scrollHintControllers = []; bool get _isSelecting => _selectedIds.isNotEmpty; @@ -247,9 +247,8 @@ class _FilesViewState extends State /// The on-device copy of [item] for the Offline tab; null online, where /// thumbnails come from server previews instead. - File? _localFileFor(BuildContext context, NextcloudItem item) => _offline - ? context.read().localFileFor(item) - : null; + File? _localFileFor(BuildContext context, NextcloudItem item) => + _offline ? context.read().localFileFor(item) : null; // (Online only - the Offline tab always reloads on first build, since a // local listing has no cheap "already loaded" signal.) @@ -340,21 +339,14 @@ class _FilesViewState extends State controller.dispose(); } _controlsScrollController.dispose(); - _selectionActionsScrollController.dispose(); super.dispose(); } void _toggleSelection(NextcloudItem item) { HapticFeedback.selectionClick(); - final enteringSelection = _selectedIds.isEmpty; setState(() { if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); }); - if (enteringSelection && _isSelecting) { - WidgetsBinding.instance.addPostFrameCallback( - (_) => _playScrollHint(_selectionActionsScrollController), - ); - } } void _clearSelection() { @@ -766,7 +758,9 @@ class _FilesViewState extends State Icon(LucideIcons.circleAlert, size: 56, color: colors.danger), const SizedBox(height: 16), Text( - _offline ? 'Could not read local files' : 'WebDAV Sync Error', + _offline + ? 'Could not read local files' + : 'WebDAV Sync Error', style: NooText.cardTitle.copyWith(color: colors.danger), ), const SizedBox(height: 8), @@ -814,7 +808,12 @@ class _FilesViewState extends State else if (files.isGridView) SliverPadding( key: const ValueKey('files-grid'), - padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, NooSpace.lg), + padding: EdgeInsets.fromLTRB( + gutter, + NooSpace.xs, + gutter, + NooSpace.lg, + ), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: isDesktop ? 5 : 2, @@ -838,7 +837,11 @@ class _FilesViewState extends State key: const ValueKey('files-table-header'), padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, 0), sliver: SliverToBoxAdapter( - child: _buildDesktopHeader(context, files, browser.currentFolderPath), + child: _buildDesktopHeader( + context, + files, + browser.currentFolderPath, + ), ), ), SliverPadding( @@ -872,7 +875,12 @@ class _FilesViewState extends State key: ValueKey('${browser.currentFolderPath}::${item.id}'), index: index, fromRight: navigatingDeeper, - child: _buildMobileRow(context, item, index, browser.items.length), + child: _buildMobileRow( + context, + item, + index, + browser.items.length, + ), ); }, childCount: browser.items.length), ), @@ -955,7 +963,9 @@ class _FilesViewState extends State stat: stat, caption: caption, meta: meta, - tone: hasConflicts ? NooSummaryCardTone.danger : NooSummaryCardTone.normal, + tone: hasConflicts + ? NooSummaryCardTone.danger + : NooSummaryCardTone.normal, action: NooButton( variant: NooButtonVariant.tonal, size: NooButtonSize.compact, @@ -968,75 +978,20 @@ class _FilesViewState extends State ); } - /// Takes over the controls row's own sticky slot while selecting - a - /// close button, the "N selected" count, and the horizontally-scrollable - /// bulk actions (in accent-text, per `DESIGN_SYSTEM.md`'s pinned - /// selection bar). + /// Takes over the controls row's own sticky slot while selecting - see + /// `NooSelectionBar` (design canvas + /// https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4, "Selection action + /// bar" - DESIGN_SYSTEM.md has no §4 recipe of its own for this). Widget _buildSelectionBar( BuildContext context, List selectedItems, ) { - final colors = context.nooColors; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), - child: Row( - children: [ - SizedBox( - width: MediaQuery.of(context).size.width * 0.5, - child: Align( - alignment: Alignment.centerLeft, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: Icon(LucideIcons.x, size: 20, color: colors.fg1), - tooltip: 'Cancel selection', - onPressed: _clearSelection, - visualDensity: VisualDensity.compact, - ), - const SizedBox(width: 8), - Text( - '${selectedItems.length} selected', - style: NooText.cardTitle.copyWith( - fontSize: 17, - color: colors.fg1, - ), - ), - ], - ), - ), - ), - Expanded( - child: SingleChildScrollView( - controller: _selectionActionsScrollController, - scrollDirection: Axis.horizontal, - // Left-aligned (not anchored to the trailing edge) so the - // first action's left edge sits at a fixed spot - lining up - // with the controls row's own first icon directly below it - - // regardless of how many actions there are. - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (final action in _buildSelectionActions( - context, - selectedItems, - )) - IconButton( - icon: Icon( - action.icon, - size: 20, - color: colors.accentText, - ), - tooltip: action.label, - onPressed: action.onTap, - visualDensity: VisualDensity.compact, - ), - ], - ), - ), - ), - ], - ), + return NooSelectionBar( + count: selectedItems.length, + actions: _buildSelectionActions(context, selectedItems), + onClose: _clearSelection, + isDesktop: NooLayout.isDesktop(context), + iosStyle: NooLayout.iosStyle(context), ); } @@ -1144,9 +1099,7 @@ class _FilesViewState extends State // just some)? Save straight from the local copy instead of a fresh // network fetch through DownloadService - see // SyncStatusController.localSyncedFilePath. - final localPaths = await Future.wait( - files.map(sync.localSyncedFilePath), - ); + final localPaths = await Future.wait(files.map(sync.localSyncedFilePath)); if (localPaths.every((path) => path != null)) { try { for (var i = 0; i < files.length; i++) { @@ -1355,7 +1308,11 @@ class _FilesViewState extends State FilesController files, String folderPath, ) { - const columns = [FileSortField.name, FileSortField.size, FileSortField.dateModified]; + const columns = [ + FileSortField.name, + FileSortField.size, + FileSortField.dateModified, + ]; final field = files.sortFieldFor(folderPath); final sortColumn = columns.indexOf(field); return NooFileTableHeader( @@ -1486,7 +1443,11 @@ class _FilesViewState extends State item: item, service: session.service, localFile: _localFileFor(context, item), - fallback: Icon(kind.icon, color: kind.foreground(colors), size: 32), + fallback: Icon( + kind.icon, + color: kind.foreground(colors), + size: 32, + ), ) : null, thumbnailHeight: NooLayout.isDesktop(context) ? 118 : 104, diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index 794923c..cf5e9b3 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -20,6 +20,7 @@ import '../widgets/noo/core/noo_segmented_control.dart'; import '../widgets/noo/core/noo_toggle.dart'; import '../widgets/noo/files/noo_file_kind.dart'; import '../widgets/noo/lists/noo_grouped_list.dart'; +import '../widgets/noo/lists/noo_selection_bar.dart'; import '../widgets/noo/lists/noo_settings_row.dart'; import '../widgets/noo/media/noo_photo_group.dart'; import '../widgets/noo/media/noo_photo_tile.dart'; @@ -57,12 +58,9 @@ class PhotosView extends StatefulWidget { State createState() => _PhotosViewState(); } -class _PhotosViewState extends State - with SingleTickerProviderStateMixin { +class _PhotosViewState extends State { bool _requested = false; final Set _selectedIds = {}; - final ScrollController _selectionActionsScrollController = ScrollController(); - final List _scrollHintControllers = []; // Session-local only (not one of PhotosController's persisted display // prefs) - a pure narrowing of the already-fetched/filtered/sorted list, @@ -74,81 +72,15 @@ class _PhotosViewState extends State void _toggleSelection(NextcloudItem item) { HapticFeedback.selectionClick(); - final enteringSelection = _selectedIds.isEmpty; setState(() { if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); }); - if (enteringSelection && _isSelecting) { - WidgetsBinding.instance.addPostFrameCallback( - (_) => _playScrollHint(_selectionActionsScrollController), - ); - } } void _clearSelection() { setState(_selectedIds.clear); } - /// A one-shot hint that the selection actions row actually scrolls - - /// mirrors `FilesView`'s identical controls-row hint (see its doc - /// comment): nudges it right and back, once, the first time a selection - /// starts. No-ops if there's nothing to scroll (row already fits). - Future _playScrollHint(ScrollController scrollController) async { - await Future.delayed(const Duration(milliseconds: 500)); - // `hasClients` only means a position is attached, not that it's - // finished its first layout - reading maxScrollExtent before that - // throws (min/maxScrollExtent are still null internally). - if (!mounted || - !scrollController.hasClients || - !scrollController.position.hasContentDimensions) { - return; - } - final maxExtent = scrollController.position.maxScrollExtent; - if (maxExtent <= 0) return; - final double peak = maxExtent < 36 ? maxExtent : 36; - final controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 900), - ); - _scrollHintControllers.add(controller); - final hint = TweenSequence([ - TweenSequenceItem( - tween: Tween( - begin: 0.0, - end: peak, - ).chain(CurveTween(curve: Curves.easeInOutSine)), - weight: 50, - ), - TweenSequenceItem( - tween: Tween( - begin: peak, - end: 0.0, - ).chain(CurveTween(curve: Curves.easeInOutSine)), - weight: 50, - ), - ]).animate(controller); - void onTick() { - if (scrollController.hasClients) { - scrollController.jumpTo(hint.value); - } - } - - hint.addListener(onTick); - await controller.forward(); - hint.removeListener(onTick); - _scrollHintControllers.remove(controller); - controller.dispose(); - } - - @override - void dispose() { - for (final controller in _scrollHintControllers) { - controller.dispose(); - } - _selectionActionsScrollController.dispose(); - super.dispose(); - } - /// Mirrors `FilesView._handlePickTap` - Photos has no folders, so this is /// just the matching/toggle/immediate-confirm branch. void _handlePickTap( @@ -181,52 +113,52 @@ class _PhotosViewState extends State if (pick.isPicking) { return [ SelectionAction( - icon: Icons.check_rounded, + icon: LucideIcons.check, label: 'Use ${selected.length} item(s)', onTap: () => pick.confirmPick(selected), ), ]; } + // Matches the star/star-off convention Files' own selection toolbar + // uses for favorite/unfavorite (see `files_view.dart`) rather than a + // filled heart. + final allFavorited = selected.every((i) => i.isFavorite); return [ SelectionAction( - icon: selected.every((i) => i.isFavorite) - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - label: selected.every((i) => i.isFavorite) - ? 'Remove from favorites' - : 'Favorite', + icon: allFavorited ? LucideIcons.starOff : LucideIcons.star, + label: allFavorited ? 'Remove from favorites' : 'Favorite', onTap: () => _favoriteSelected(selected), ), SelectionAction( - icon: Icons.share_rounded, + icon: LucideIcons.share2, label: 'Share', onTap: () => selected.length == 1 ? ShareSheet.show(context, selected.single) : _shareSelected(context, selected), ), SelectionAction( - icon: Icons.download_rounded, + icon: LucideIcons.download, label: 'Download', onTap: () => _downloadSelected(context, selected), ), SelectionAction( - icon: Icons.delete_outline_rounded, + icon: LucideIcons.trash2, label: 'Delete', onTap: () => _confirmDeleteSelected(context, selected), ), SelectionAction( - icon: Icons.copy_rounded, + icon: LucideIcons.copy, label: 'Copy', onTap: () => _moveOrCopySelected(selected, copy: true), ), SelectionAction( - icon: Icons.drive_file_move_rounded, + icon: LucideIcons.folderInput, label: 'Move', onTap: () => _moveOrCopySelected(selected, copy: false), ), if (selected.length == 1) SelectionAction( - icon: Icons.info_outline_rounded, + icon: LucideIcons.info, label: 'Details', onTap: () => DetailsSheet.show(context, selected.single), ), @@ -462,15 +394,13 @@ class _PhotosViewState extends State const SizedBox(width: 8), NooChip( selected: _typeFilter == _PhotoTypeFilter.image, - onTap: () => - setState(() => _typeFilter = _PhotoTypeFilter.image), + onTap: () => setState(() => _typeFilter = _PhotoTypeFilter.image), child: const Text('Photos'), ), const SizedBox(width: 8), NooChip( selected: _typeFilter == _PhotoTypeFilter.video, - onTap: () => - setState(() => _typeFilter = _PhotoTypeFilter.video), + onTap: () => setState(() => _typeFilter = _PhotoTypeFilter.video), child: const Text('Videos'), ), const SizedBox(width: 8), @@ -491,43 +421,58 @@ class _PhotosViewState extends State showNooSheet( context, children: [ - NooSegmentedControl( - fill: true, - value: photos.sortAscending, - onChanged: (asc) { - if (asc != photos.sortAscending) photos.toggleSortOrder(); - }, - 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 == photos.sortField - ? Icon( - LucideIcons.check, - size: 18, - color: context.nooColors.accentText, - ) - : null, - onTap: () { - photos.setSortField(field); - Navigator.pop(context); + // `showNooSheet`'s `children` are built once, up front - a bare + // checkmark here would freeze at whatever it was when the sheet + // opened, since tapping a row calls `photos.set...`/notifies the + // controller, not this already-built widget tree. `ListenableBuilder` + // re-runs its `builder` on every notification instead, so the + // selection updates live. + ListenableBuilder( + listenable: photos, + builder: (context, _) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + NooSegmentedControl( + fill: true, + value: photos.sortAscending, + onChanged: (asc) { + if (asc != photos.sortAscending) photos.toggleSortOrder(); }, + 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 == photos.sortField + ? Icon( + LucideIcons.check, + size: 18, + color: context.nooColors.accentText, + ) + : null, + onTap: () { + photos.setSortField(field); + Navigator.pop(context); + }, + ), + ], + ), + ], + ), ), ], ); @@ -541,105 +486,59 @@ class _PhotosViewState extends State showNooSheet( context, children: [ - NooGroupedList( - children: [ - NooSettingsRow( - icon: LucideIcons.heart, - label: const Text('Favorites only'), - trailing: NooToggle( - checked: photos.showFavoritesOnly, - onChanged: (_) => photos.toggleFavoritesFilter(), - ), - ), - NooSettingsRow( - icon: LucideIcons.eye, - label: const Text('Show hidden files'), - trailing: NooToggle( - checked: photos.showHidden, - onChanged: (_) => photos.toggleShowHidden(), - ), - ), - 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 - `Listenable.merge` since these + // toggles span two controllers. + ListenableBuilder( + listenable: Listenable.merge([photos, files]), + builder: (context, _) => NooGroupedList( + children: [ + NooSettingsRow( + icon: LucideIcons.heart, + label: const Text('Favorites only'), + trailing: NooToggle( + checked: photos.showFavoritesOnly, + onChanged: (_) => photos.toggleFavoritesFilter(), ), ), - ), - ], + NooSettingsRow( + icon: LucideIcons.eye, + label: const Text('Show hidden files'), + trailing: NooToggle( + checked: photos.showHidden, + onChanged: (_) => photos.toggleShowHidden(), + ), + ), + 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, + ), + ), + ), + ], + ), ), ], ); } /// Replaces the controls row's own sticky slot while selecting - a close - /// button, the "N selected" count, and the horizontally-scrollable bulk - /// actions. Mirrors `FilesView`'s identical selection bar. + /// button, the "N selected" count, and the bulk actions. Mirrors + /// `FilesView`'s identical selection bar. Widget _buildSelectionBar( BuildContext context, PickController pick, List selectedItems, ) { - final colors = context.nooColors; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), - child: Row( - children: [ - SizedBox( - width: MediaQuery.of(context).size.width * 0.5, - child: Align( - alignment: Alignment.centerLeft, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: Icon(LucideIcons.x, size: 20, color: colors.fg1), - tooltip: 'Cancel selection', - onPressed: _clearSelection, - visualDensity: VisualDensity.compact, - ), - const SizedBox(width: 8), - Text( - '${selectedItems.length} selected', - style: NooText.cardTitle.copyWith( - fontSize: 17, - color: colors.fg1, - ), - ), - ], - ), - ), - ), - Expanded( - child: SingleChildScrollView( - controller: _selectionActionsScrollController, - scrollDirection: Axis.horizontal, - // Left-aligned (not anchored to the trailing edge) so the - // first action's left edge sits at a fixed spot - lining up - // with the controls row's own first chip directly below it - - // regardless of how many actions there are. - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (final action in _buildSelectionActions( - pick, - selectedItems, - )) - IconButton( - icon: Icon(action.icon, size: 20, color: colors.fg1), - tooltip: action.label, - onPressed: action.onTap, - visualDensity: VisualDensity.compact, - ), - ], - ), - ), - ), - ], - ), + return NooSelectionBar( + count: selectedItems.length, + actions: _buildSelectionActions(pick, selectedItems), + onClose: _clearSelection, + isDesktop: NooLayout.isDesktop(context), + iosStyle: NooLayout.iosStyle(context), ); } diff --git a/lib/widgets/noo/lists/noo_selection_bar.dart b/lib/widgets/noo/lists/noo_selection_bar.dart new file mode 100644 index 0000000..c782849 --- /dev/null +++ b/lib/widgets/noo/lists/noo_selection_bar.dart @@ -0,0 +1,308 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../models/selection_action.dart'; +import '../../../theme/design_tokens.dart'; +import '../files/noo_file_row.dart' show NooOverflowButton; +import '../overlays/noo_sheet.dart'; +import 'noo_grouped_list.dart'; +import 'noo_settings_row.dart'; + +/// Bulk actions shown inline before the rest collapse behind "More" - a +/// fixed, non-scrolling width on every platform (design canvas +/// https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4, "Selection action +/// bar"). Mobile is tighter (icons share the bar with the close circle and +/// count on a ~360dp phone); desktop's labelled pills have a wider toolbar +/// to work with. +const int _kMobileInlineActions = 3; +const int _kDesktopInlineActions = 4; + +/// The multi-select action bar Files/Photos/Favorites swap in for their own +/// sort/filter row while selecting - design canvas +/// https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4 ("Selection action +/// bar"), since DESIGN_SYSTEM.md has no §4 recipe of its own for this. +/// Replaces the bare row of Material `IconButton`s each screen used to +/// build ad hoc. +/// +/// A `surface` card (radius 20) - selection reads as a distinct mode, not +/// just a row of buttons floating on `bg` - with a close circle, the +/// count, and the bulk actions. This sits in the *content* pane's own +/// pinned header slot on every platform (the shell's desktop toolbar, +/// built separately in `main.dart`, never changes for selection), so +/// mobile and desktop share the same fixed 56px height; only the gutter +/// ([isDesktop] ? 24 : 12, matching `NooLayout.gutter`) and the actions' +/// presentation differ: +/// - Mobile: the first [_kMobileInlineActions] actions as plain 20px +/// accent-text icons, no fill. +/// - Desktop: the first [_kDesktopInlineActions] as labelled tonal pills - +/// danger-soft/danger for the one labelled "Delete", accent-soft/ +/// accent-text otherwise - since desktop has the room for labels. +/// +/// Any actions beyond that inline count sit behind a trailing "More" +/// button that opens the same grouped-list sheet a file row's own overflow +/// menu already uses (see `FilesView._showItemActions`) - never a second +/// scrolling row, which is what this replaced: with up to 9 bulk actions +/// (favorite, share, download, delete, copy, move, rename, sync, +/// details), a horizontally-scrolling row hid actions behind a swipe +/// gesture with no visible cue that there was more to find. +class NooSelectionBar extends StatelessWidget { + final int count; + final List actions; + final VoidCallback onClose; + final bool isDesktop; + final bool iosStyle; + + const NooSelectionBar({ + super.key, + required this.count, + required this.actions, + required this.onClose, + required this.isDesktop, + this.iosStyle = false, + }); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final gutter = isDesktop ? NooSpace.xl : NooSpace.sm; + final inlineCount = isDesktop + ? _kDesktopInlineActions + : _kMobileInlineActions; + final inlineActions = actions.length > inlineCount + ? actions.sublist(0, inlineCount) + : actions; + final overflowActions = actions.length > inlineCount + ? actions.sublist(inlineCount) + : const []; + + // Every screen pins this into a fixed 56px sliver-header slot (the same + // one the sort/filter row otherwise occupies) - the 4px top/bottom + // margin plus the card's own 48px must add up to exactly that, or the + // header overflows (see files_view.dart's `topRow` height comment for + // the same warning about this exact number). + return Padding( + padding: EdgeInsets.symmetric(horizontal: gutter, vertical: NooSpace.xxs), + child: Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(NooRadii.card), + ), + child: Row( + children: [ + Semantics( + button: true, + label: 'Cancel selection', + child: GestureDetector( + onTap: onClose, + child: Container( + width: 36, + height: 36, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colors.surface2, + shape: BoxShape.circle, + ), + child: Icon(LucideIcons.x, size: 18, color: colors.fg1), + ), + ), + ), + const SizedBox(width: NooSpace.xs), + Text( + '$count selected', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.cardTitle.copyWith( + fontSize: 17, + color: colors.fg1, + ), + ), + const SizedBox(width: NooSpace.xs), + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: isDesktop + ? _desktopActions( + context, + colors, + inlineActions, + overflowActions, + ) + : _mobileActions( + context, + colors, + inlineActions, + overflowActions, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _mobileActions( + BuildContext context, + NooColors colors, + List inline, + List overflow, + ) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final action in inline) + Semantics( + button: true, + label: action.label, + child: InkResponse( + onTap: action.onTap, + radius: 20, + child: Padding( + padding: const EdgeInsets.all(10), + child: Icon(action.icon, size: 20, color: colors.accentText), + ), + ), + ), + if (overflow.isNotEmpty) + NooOverflowButton( + iosStyle: iosStyle, + onTap: () => _showOverflowSheet(context, overflow), + ), + ], + ); + } + + Widget _desktopActions( + BuildContext context, + NooColors colors, + List inline, + List overflow, + ) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final action in inline) ...[ + _DesktopActionPill(action: action, colors: colors), + const SizedBox(width: 6), + ], + if (overflow.isNotEmpty) + _DesktopMorePill( + colors: colors, + onTap: () => _showOverflowSheet(context, overflow), + ), + ], + ); + } + + void _showOverflowSheet( + BuildContext context, + List overflow, + ) { + showNooSheet( + context, + children: [ + NooGroupedList( + children: [ + for (final action in overflow) + NooSettingsRow( + icon: action.icon, + label: Text(action.label), + onTap: () { + Navigator.pop(context); + action.onTap(); + }, + ), + ], + ), + ], + ); + } +} + +class _DesktopActionPill extends StatelessWidget { + final SelectionAction action; + final NooColors colors; + + const _DesktopActionPill({required this.action, required this.colors}); + + @override + Widget build(BuildContext context) { + final danger = action.label == 'Delete'; + final bg = danger ? colors.dangerSoft : colors.accentSoft; + final fg = danger ? colors.danger : colors.accentText; + return Material( + color: bg, + borderRadius: BorderRadius.circular(NooRadii.pill), + child: InkWell( + onTap: action.onTap, + borderRadius: BorderRadius.circular(NooRadii.pill), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: SizedBox( + height: 32, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(action.icon, size: 15, color: fg), + const SizedBox(width: 6), + Text( + action.label, + style: NooText.buttonSm.copyWith(color: fg, fontSize: 13), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// The desktop overflow trigger - a secondary pill (matching the Button +/// component's "Secondary" variant) rather than a tonal one, so it reads +/// as "opens a menu" and not as another bulk action next to it. The +/// trailing `chevron-down` mirrors the same "opens a menu" cue `NooChip` +/// uses. +class _DesktopMorePill extends StatelessWidget { + final NooColors colors; + final VoidCallback onTap; + + const _DesktopMorePill({required this.colors, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: 'More actions', + child: Material( + color: colors.surface2, + borderRadius: BorderRadius.circular(NooRadii.pill), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(NooRadii.pill), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: SizedBox( + height: 32, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'More', + style: NooText.buttonSm.copyWith( + color: colors.fg1, + fontSize: 13, + ), + ), + const SizedBox(width: 4), + Icon(LucideIcons.chevronDown, size: 14, color: colors.fg1), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/test/widgets/noo/lists_test.dart b/test/widgets/noo/lists_test.dart index b3fba59..233b93f 100644 --- a/test/widgets/noo/lists_test.dart +++ b/test/widgets/noo/lists_test.dart @@ -1,16 +1,31 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:noo/models/selection_action.dart'; import 'package:noo/widgets/noo/core/noo_progress_bar.dart'; import 'package:noo/widgets/noo/core/noo_toggle.dart'; import 'package:noo/widgets/noo/lists/noo_banner.dart'; import 'package:noo/widgets/noo/lists/noo_grouped_list.dart'; +import 'package:noo/widgets/noo/lists/noo_selection_bar.dart'; import 'package:noo/widgets/noo/lists/noo_settings_row.dart'; import 'package:noo/widgets/noo/lists/noo_summary_card.dart'; import 'package:noo/widgets/noo/lists/noo_tab_order_row.dart'; import 'noo_test_utils.dart'; +/// [n] actions labelled A, B, C, ... each incrementing an entry in [taps] +/// keyed by its own label when tapped. +List _actions(int n, Map taps) => + List.generate(n, (i) { + final label = String.fromCharCode(65 + i); + taps[label] = 0; + return SelectionAction( + icon: LucideIcons.star, + label: label, + onTap: () => taps[label] = taps[label]! + 1, + ); + }); + void main() { setUpNooTests(); @@ -28,15 +43,26 @@ void main() { ); expect(find.byIcon(LucideIcons.info), findsOneWidget); expect(decorationOf(tester, find.text('Empty trash')).color, c.surface); - expect(tester.widget(find.text('Empty trash')).style!.color, c.danger); + expect( + tester.widget(find.text('Empty trash')).style!.color, + c.danger, + ); await tester.tap(find.text('Empty trash')); expect(taps, 1); }); - testNooWidgets('non-danger action uses accent text', (tester, theme, c) async { + testNooWidgets('non-danger action uses accent text', ( + tester, + theme, + c, + ) async { await pumpNoo( tester, - const NooBanner(actionLabel: 'Undo', actionIsDanger: false, child: Text('x')), + const NooBanner( + actionLabel: 'Undo', + actionIsDanger: false, + child: Text('x'), + ), theme: theme, ); expect(tester.widget(find.text('Undo')).style!.color, c.accentText); @@ -44,7 +70,11 @@ void main() { }); group('NooGroupedList', () { - testNooWidgets('label, aside, 1px line gaps and footer', (tester, theme, c) async { + testNooWidgets('label, aside, 1px line gaps and footer', ( + tester, + theme, + c, + ) async { await pumpNoo( tester, SizedBox( @@ -65,7 +95,8 @@ void main() { expect(find.text('2'), findsOneWidget); expect(find.text('Footer note'), findsOneWidget); expect(tester.widget(find.text('Accounts')).style!.color, c.fg2); - final gap = tester.getTopLeft(find.byKey(const Key('r2'))).dy - + final gap = + tester.getTopLeft(find.byKey(const Key('r2'))).dy - tester.getBottomLeft(find.byKey(const Key('r1'))).dy; expect(gap, 1); // Rows paint their own surface; the gaps show the list's line fill. @@ -73,8 +104,131 @@ void main() { }); }); + group('NooSelectionBar', () { + testNooWidgets('mobile: all actions show inline when they fit', ( + tester, + theme, + c, + ) async { + final taps = {}; + await pumpNoo( + tester, + SizedBox( + width: 360, + child: NooSelectionBar( + count: 2, + actions: _actions(2, taps), + onClose: () {}, + isDesktop: false, + ), + ), + theme: theme, + ); + expect(find.text('2 selected'), findsOneWidget); + expect(find.byIcon(LucideIcons.star), findsNWidgets(2)); + expect(find.byIcon(LucideIcons.ellipsisVertical), findsNothing); + await tester.tap(find.byIcon(LucideIcons.star).first); + expect(taps['A'], 1); + }); + + testNooWidgets( + 'mobile: actions beyond the inline count collapse behind More', + (tester, theme, c) async { + final taps = {}; + await pumpNoo( + tester, + // Wide enough that the missing-webfont fallback used in tests + // (Google Fonts network fetch is off, see noo_test_utils.dart) + // doesn't itself force an overflow unrelated to what's tested here. + SizedBox( + width: 500, + child: NooSelectionBar( + count: 5, + actions: _actions(5, taps), + onClose: () {}, + isDesktop: false, + ), + ), + theme: theme, + ); + // Only the first 3 actions render inline - no horizontally- + // scrolling row that could hide the rest with no visible cue. + expect(find.byIcon(LucideIcons.star), findsNWidgets(3)); + expect(find.byIcon(LucideIcons.ellipsisVertical), findsOneWidget); + + await tester.tap(find.byIcon(LucideIcons.ellipsisVertical)); + await tester.pumpAndSettle(); + // The sheet lists exactly the overflowed actions (D, E) - the + // already-inline ones (A, B, C) aren't duplicated in it. + expect(find.text('D'), findsOneWidget); + expect(find.text('E'), findsOneWidget); + expect(find.text('A'), findsNothing); + + await tester.tap(find.text('D')); + await tester.pumpAndSettle(); + expect(taps['D'], 1); + }, + ); + + testNooWidgets( + 'desktop: actions beyond the inline count collapse behind More', + (tester, theme, c) async { + final taps = {}; + await pumpNoo( + tester, + SizedBox( + width: 900, + child: NooSelectionBar( + count: 6, + actions: _actions(6, taps), + onClose: () {}, + isDesktop: true, + ), + ), + theme: theme, + ); + // The first 4 actions render as labelled pills; the rest are behind + // "More". + for (final label in ['A', 'B', 'C', 'D']) { + expect(find.text(label), findsOneWidget); + } + expect(find.text('E'), findsNothing); + expect(find.text('More'), findsOneWidget); + + await tester.tap(find.text('More')); + await tester.pumpAndSettle(); + expect(find.text('E'), findsOneWidget); + expect(find.text('F'), findsOneWidget); + }, + ); + + testNooWidgets('close button fires onClose', (tester, theme, c) async { + var closed = false; + final taps = {}; + await pumpNoo( + tester, + SizedBox( + width: 360, + child: NooSelectionBar( + count: 1, + actions: _actions(1, taps), + onClose: () => closed = true, + isDesktop: false, + ), + ), + theme: theme, + ); + await tester.tap(find.byIcon(LucideIcons.x)); + expect(closed, isTrue); + }); + }); + group('NooSettingsRow', () { - testNooWidgets('value row shows chevron and fires onTap', (tester, theme, c) async { + testNooWidgets('value row shows chevron and fires onTap', ( + tester, + theme, + c, + ) async { var taps = 0; await pumpNoo( tester, @@ -101,7 +255,9 @@ void main() { expect(taps, 1); }); - testWidgets('subtitle row is 60px, trailing wins over value', (tester) async { + testWidgets('subtitle row is 60px, trailing wins over value', ( + tester, + ) async { await pumpNoo( tester, const SizedBox( @@ -123,7 +279,9 @@ void main() { expect(tester.getSize(find.byType(NooSettingsRow)).height, 60); }); - testWidgets('keeps its 52px height inside a tall bounded parent', (tester) async { + testWidgets('keeps its 52px height inside a tall bounded parent', ( + tester, + ) async { await pumpNoo( tester, const SizedBox( @@ -143,18 +301,31 @@ void main() { tester, const SizedBox( width: 360, - child: NooSettingsRow(icon: LucideIcons.logOut, label: Text('Log out'), danger: true), + child: NooSettingsRow( + icon: LucideIcons.logOut, + label: Text('Log out'), + danger: true, + ), ), theme: theme, ); - final style = DefaultTextStyle.of(tester.element(find.text('Log out'))).style; + final style = DefaultTextStyle.of( + tester.element(find.text('Log out')), + ).style; expect(style.color, c.danger); - expect(tester.widget(find.byIcon(LucideIcons.logOut)).color, c.danger); + expect( + tester.widget(find.byIcon(LucideIcons.logOut)).color, + c.danger, + ); }); }); group('NooSummaryCard', () { - testNooWidgets('stat, caption, progress, meta, action', (tester, theme, c) async { + testNooWidgets('stat, caption, progress, meta, action', ( + tester, + theme, + c, + ) async { await pumpNoo( tester, const SizedBox( @@ -182,7 +353,11 @@ void main() { tester, const SizedBox( width: 360, - child: NooSummaryCard(stat: '3', caption: 'Errors', tone: NooSummaryCardTone.danger), + child: NooSummaryCard( + stat: '3', + caption: 'Errors', + tone: NooSummaryCardTone.danger, + ), ), theme: theme, ); @@ -194,7 +369,11 @@ void main() { }); group('NooTabOrderRow', () { - testNooWidgets('pin state drives icon/fill, toggle fires', (tester, theme, c) async { + testNooWidgets('pin state drives icon/fill, toggle fires', ( + tester, + theme, + c, + ) async { var pinned = true; await pumpNoo( tester, @@ -212,13 +391,19 @@ void main() { theme: theme, ); expect(find.text('Files'), findsOneWidget); - expect(decorationOf(tester, find.byIcon(LucideIcons.pin)).color, c.accentSoft); + expect( + decorationOf(tester, find.byIcon(LucideIcons.pin)).color, + c.accentSoft, + ); await tester.tap(find.byIcon(LucideIcons.pin)); await tester.pump(); expect(pinned, isFalse); expect(find.byIcon(LucideIcons.pin), findsNothing); - expect(decorationOf(tester, find.byIcon(LucideIcons.pinOff)).color, c.surface2); + expect( + decorationOf(tester, find.byIcon(LucideIcons.pinOff)).color, + c.surface2, + ); }); }); }