Redesign the selection action bar to remove horizontal scrolling

The multi-select action bar (Files/Photos/Favorites) hid bulk actions
behind a horizontally-scrolling row with no visible cue that there was
more to find, once there were more than a handful (up to 9: favorite,
share, download, delete, copy, move, rename, sync, details).

Designed the replacement on the linked design canvas first (Selection
action bar boards), then implemented it: NooSelectionBar now shows a
fixed number of actions inline (3 on mobile, 4 on desktop) with the rest
behind a trailing "More" button that opens the same overflow sheet a
file row's own "..." menu already uses. Removed the now-dead
scroll-hint plumbing from the three screens that render it.
This commit is contained in:
2026-09-28 11:27:03 -04:00
parent c2540ec2ce
commit d2c34b000f
6 changed files with 716 additions and 445 deletions
+26 -1
View File
@@ -10,7 +10,7 @@ Reference files in this project:
- `Noo Design System.dc.html` is the visual component sheet. - `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. - `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. - `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. - The block has a 20px icon above a 12/600 label.
- Swipe actions are set in Settings → Swipe on a file. - 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** **Grid card**
- Surface fill, radius 18. Mobile uses 2 columns with a 10px gap; desktop uses 5 columns with a 16px gap. - 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. - 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.
+27 -134
View File
@@ -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_table.dart';
import '../widgets/noo/files/noo_file_tile.dart'; import '../widgets/noo/files/noo_file_tile.dart';
import '../widgets/noo/lists/noo_grouped_list.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_settings_row.dart';
import '../widgets/noo/media/noo_grid_card.dart'; import '../widgets/noo/media/noo_grid_card.dart';
import '../widgets/noo/noo_layout.dart'; import '../widgets/noo/noo_layout.dart';
@@ -55,92 +56,23 @@ class FavoritesView extends StatefulWidget {
State<FavoritesView> createState() => _FavoritesViewState(); State<FavoritesView> createState() => _FavoritesViewState();
} }
class _FavoritesViewState extends State<FavoritesView> class _FavoritesViewState extends State<FavoritesView> {
with SingleTickerProviderStateMixin {
bool _requested = false; bool _requested = false;
final Set<String> _selectedIds = {}; final Set<String> _selectedIds = {};
final ScrollController _selectionActionsScrollController = ScrollController();
final List<AnimationController> _scrollHintControllers = [];
bool get _isSelecting => _selectedIds.isNotEmpty; bool get _isSelecting => _selectedIds.isNotEmpty;
void _toggleSelection(NextcloudItem item) { void _toggleSelection(NextcloudItem item) {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
final enteringSelection = _selectedIds.isEmpty;
setState(() { setState(() {
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
}); });
if (enteringSelection && _isSelecting) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _playScrollHint(_selectionActionsScrollController),
);
}
} }
void _clearSelection() { void _clearSelection() {
setState(_selectedIds.clear); 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<void> _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<double>([
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 /// A favorited folder switches to the Files tab, navigated there; a
/// favorited file just opens directly from here, like Files/Photos do - /// favorited file just opens directly from here, like Files/Photos do -
/// no need to reposition Files first since dismissing the viewer lands /// no need to reposition Files first since dismissing the viewer lands
@@ -166,40 +98,40 @@ class _FavoritesViewState extends State<FavoritesView>
List<SelectionAction> _buildSelectionActions(List<NextcloudItem> selected) { List<SelectionAction> _buildSelectionActions(List<NextcloudItem> selected) {
return [ return [
SelectionAction( SelectionAction(
icon: Icons.favorite_border_rounded, icon: LucideIcons.starOff,
label: 'Remove from favorites', label: 'Remove from favorites',
onTap: () => _unfavoriteSelected(selected), onTap: () => _unfavoriteSelected(selected),
), ),
SelectionAction( SelectionAction(
icon: Icons.share_rounded, icon: LucideIcons.share2,
label: 'Share', label: 'Share',
onTap: () => selected.length == 1 onTap: () => selected.length == 1
? ShareSheet.show(context, selected.single) ? ShareSheet.show(context, selected.single)
: _shareSelected(context, selected), : _shareSelected(context, selected),
), ),
SelectionAction( SelectionAction(
icon: Icons.download_rounded, icon: LucideIcons.download,
label: 'Download', label: 'Download',
onTap: () => _downloadSelected(context, selected), onTap: () => _downloadSelected(context, selected),
), ),
SelectionAction( SelectionAction(
icon: Icons.delete_outline_rounded, icon: LucideIcons.trash2,
label: 'Delete', label: 'Delete',
onTap: () => _confirmDeleteSelected(context, selected), onTap: () => _confirmDeleteSelected(context, selected),
), ),
SelectionAction( SelectionAction(
icon: Icons.copy_rounded, icon: LucideIcons.copy,
label: 'Copy', label: 'Copy',
onTap: () => _moveOrCopySelected(selected, copy: true), onTap: () => _moveOrCopySelected(selected, copy: true),
), ),
SelectionAction( SelectionAction(
icon: Icons.drive_file_move_rounded, icon: LucideIcons.folderInput,
label: 'Move', label: 'Move',
onTap: () => _moveOrCopySelected(selected, copy: false), onTap: () => _moveOrCopySelected(selected, copy: false),
), ),
if (selected.length == 1) if (selected.length == 1)
SelectionAction( SelectionAction(
icon: Icons.info_outline_rounded, icon: LucideIcons.info,
label: 'Details', label: 'Details',
onTap: () => DetailsSheet.show(context, selected.single), onTap: () => DetailsSheet.show(context, selected.single),
), ),
@@ -477,7 +409,11 @@ class _FavoritesViewState extends State<FavoritesView>
col3: item.isFolder ? null : formatBytes(item.size), col3: item.isFolder ? null : formatBytes(item.size),
favorite: item.isFavorite, favorite: item.isFavorite,
selected: isSelected, selected: isSelected,
thumbnail: _thumbnailFor(context, item, extent: NooFileTileSize.desktop.extent), thumbnail: _thumbnailFor(
context,
item,
extent: NooFileTileSize.desktop.extent,
),
onTap: () { onTap: () {
if (_isSelecting) { if (_isSelecting) {
_toggleSelection(item); _toggleSelection(item);
@@ -639,7 +575,12 @@ class _FavoritesViewState extends State<FavoritesView>
childAspectRatio: isDesktop ? 0.92 : 0.85, childAspectRatio: isDesktop ? 0.92 : 0.85,
), ),
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate((context, index) {
return _buildGridCard(context, favorites[index], siblings, isDesktop); return _buildGridCard(
context,
favorites[index],
siblings,
isDesktop,
);
}, childCount: favorites.length), }, childCount: favorites.length),
), ),
) )
@@ -673,11 +614,7 @@ class _FavoritesViewState extends State<FavoritesView>
}, },
); );
} }
return _buildTableRow( return _buildTableRow(context, favorites[index - 1], siblings);
context,
favorites[index - 1],
siblings,
);
}, childCount: favorites.length + 1), }, childCount: favorites.length + 1),
), ),
) )
@@ -719,56 +656,12 @@ class _FavoritesViewState extends State<FavoritesView>
BuildContext context, BuildContext context,
List<NextcloudItem> selectedItems, List<NextcloudItem> selectedItems,
) { ) {
final colors = context.nooColors; return NooSelectionBar(
return Padding( count: selectedItems.length,
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), actions: _buildSelectionActions(selectedItems),
child: Row( onClose: _clearSelection,
children: [ isDesktop: NooLayout.isDesktop(context),
SizedBox( iosStyle: NooLayout.iosStyle(context),
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,
),
],
),
),
),
],
),
); );
} }
} }
+47 -86
View File
@@ -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_status_icon.dart';
import '../widgets/noo/files/noo_swipe_action.dart'; import '../widgets/noo/files/noo_swipe_action.dart';
import '../widgets/noo/lists/noo_grouped_list.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_settings_row.dart';
import '../widgets/noo/lists/noo_summary_card.dart'; import '../widgets/noo/lists/noo_summary_card.dart';
import '../widgets/noo/media/noo_grid_card.dart'; import '../widgets/noo/media/noo_grid_card.dart';
@@ -231,7 +232,6 @@ class _FilesViewState extends State<FilesView>
final Set<String> _selectedIds = {}; final Set<String> _selectedIds = {};
int _lastPathDepth = 1; int _lastPathDepth = 1;
final ScrollController _controlsScrollController = ScrollController(); final ScrollController _controlsScrollController = ScrollController();
final ScrollController _selectionActionsScrollController = ScrollController();
final List<AnimationController> _scrollHintControllers = []; final List<AnimationController> _scrollHintControllers = [];
bool get _isSelecting => _selectedIds.isNotEmpty; bool get _isSelecting => _selectedIds.isNotEmpty;
@@ -247,9 +247,8 @@ class _FilesViewState extends State<FilesView>
/// The on-device copy of [item] for the Offline tab; null online, where /// The on-device copy of [item] for the Offline tab; null online, where
/// thumbnails come from server previews instead. /// thumbnails come from server previews instead.
File? _localFileFor(BuildContext context, NextcloudItem item) => _offline File? _localFileFor(BuildContext context, NextcloudItem item) =>
? context.read<OfflineController>().localFileFor(item) _offline ? context.read<OfflineController>().localFileFor(item) : null;
: null;
// (Online only - the Offline tab always reloads on first build, since a // (Online only - the Offline tab always reloads on first build, since a
// local listing has no cheap "already loaded" signal.) // local listing has no cheap "already loaded" signal.)
@@ -340,21 +339,14 @@ class _FilesViewState extends State<FilesView>
controller.dispose(); controller.dispose();
} }
_controlsScrollController.dispose(); _controlsScrollController.dispose();
_selectionActionsScrollController.dispose();
super.dispose(); super.dispose();
} }
void _toggleSelection(NextcloudItem item) { void _toggleSelection(NextcloudItem item) {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
final enteringSelection = _selectedIds.isEmpty;
setState(() { setState(() {
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
}); });
if (enteringSelection && _isSelecting) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _playScrollHint(_selectionActionsScrollController),
);
}
} }
void _clearSelection() { void _clearSelection() {
@@ -766,7 +758,9 @@ class _FilesViewState extends State<FilesView>
Icon(LucideIcons.circleAlert, size: 56, color: colors.danger), Icon(LucideIcons.circleAlert, size: 56, color: colors.danger),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( 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), style: NooText.cardTitle.copyWith(color: colors.danger),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -814,7 +808,12 @@ class _FilesViewState extends State<FilesView>
else if (files.isGridView) else if (files.isGridView)
SliverPadding( SliverPadding(
key: const ValueKey('files-grid'), 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( sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isDesktop ? 5 : 2, crossAxisCount: isDesktop ? 5 : 2,
@@ -838,7 +837,11 @@ class _FilesViewState extends State<FilesView>
key: const ValueKey('files-table-header'), key: const ValueKey('files-table-header'),
padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, 0), padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, 0),
sliver: SliverToBoxAdapter( sliver: SliverToBoxAdapter(
child: _buildDesktopHeader(context, files, browser.currentFolderPath), child: _buildDesktopHeader(
context,
files,
browser.currentFolderPath,
),
), ),
), ),
SliverPadding( SliverPadding(
@@ -872,7 +875,12 @@ class _FilesViewState extends State<FilesView>
key: ValueKey('${browser.currentFolderPath}::${item.id}'), key: ValueKey('${browser.currentFolderPath}::${item.id}'),
index: index, index: index,
fromRight: navigatingDeeper, fromRight: navigatingDeeper,
child: _buildMobileRow(context, item, index, browser.items.length), child: _buildMobileRow(
context,
item,
index,
browser.items.length,
),
); );
}, childCount: browser.items.length), }, childCount: browser.items.length),
), ),
@@ -955,7 +963,9 @@ class _FilesViewState extends State<FilesView>
stat: stat, stat: stat,
caption: caption, caption: caption,
meta: meta, meta: meta,
tone: hasConflicts ? NooSummaryCardTone.danger : NooSummaryCardTone.normal, tone: hasConflicts
? NooSummaryCardTone.danger
: NooSummaryCardTone.normal,
action: NooButton( action: NooButton(
variant: NooButtonVariant.tonal, variant: NooButtonVariant.tonal,
size: NooButtonSize.compact, size: NooButtonSize.compact,
@@ -968,75 +978,20 @@ class _FilesViewState extends State<FilesView>
); );
} }
/// Takes over the controls row's own sticky slot while selecting - a /// Takes over the controls row's own sticky slot while selecting - see
/// close button, the "N selected" count, and the horizontally-scrollable /// `NooSelectionBar` (design canvas
/// bulk actions (in accent-text, per `DESIGN_SYSTEM.md`'s pinned /// https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4, "Selection action
/// selection bar). /// bar" - DESIGN_SYSTEM.md has no §4 recipe of its own for this).
Widget _buildSelectionBar( Widget _buildSelectionBar(
BuildContext context, BuildContext context,
List<NextcloudItem> selectedItems, List<NextcloudItem> selectedItems,
) { ) {
final colors = context.nooColors; return NooSelectionBar(
return Padding( count: selectedItems.length,
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), actions: _buildSelectionActions(context, selectedItems),
child: Row( onClose: _clearSelection,
children: [ isDesktop: NooLayout.isDesktop(context),
SizedBox( iosStyle: NooLayout.iosStyle(context),
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,
),
],
),
),
),
],
),
); );
} }
@@ -1144,9 +1099,7 @@ class _FilesViewState extends State<FilesView>
// just some)? Save straight from the local copy instead of a fresh // just some)? Save straight from the local copy instead of a fresh
// network fetch through DownloadService - see // network fetch through DownloadService - see
// SyncStatusController.localSyncedFilePath. // SyncStatusController.localSyncedFilePath.
final localPaths = await Future.wait( final localPaths = await Future.wait(files.map(sync.localSyncedFilePath));
files.map(sync.localSyncedFilePath),
);
if (localPaths.every((path) => path != null)) { if (localPaths.every((path) => path != null)) {
try { try {
for (var i = 0; i < files.length; i++) { for (var i = 0; i < files.length; i++) {
@@ -1355,7 +1308,11 @@ class _FilesViewState extends State<FilesView>
FilesController files, FilesController files,
String folderPath, String folderPath,
) { ) {
const columns = [FileSortField.name, FileSortField.size, FileSortField.dateModified]; const columns = [
FileSortField.name,
FileSortField.size,
FileSortField.dateModified,
];
final field = files.sortFieldFor(folderPath); final field = files.sortFieldFor(folderPath);
final sortColumn = columns.indexOf(field); final sortColumn = columns.indexOf(field);
return NooFileTableHeader( return NooFileTableHeader(
@@ -1486,7 +1443,11 @@ class _FilesViewState extends State<FilesView>
item: item, item: item,
service: session.service, service: session.service,
localFile: _localFileFor(context, item), 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, : null,
thumbnailHeight: NooLayout.isDesktop(context) ? 118 : 104, thumbnailHeight: NooLayout.isDesktop(context) ? 118 : 104,
+46 -147
View File
@@ -20,6 +20,7 @@ import '../widgets/noo/core/noo_segmented_control.dart';
import '../widgets/noo/core/noo_toggle.dart'; import '../widgets/noo/core/noo_toggle.dart';
import '../widgets/noo/files/noo_file_kind.dart'; import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/lists/noo_grouped_list.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_settings_row.dart';
import '../widgets/noo/media/noo_photo_group.dart'; import '../widgets/noo/media/noo_photo_group.dart';
import '../widgets/noo/media/noo_photo_tile.dart'; import '../widgets/noo/media/noo_photo_tile.dart';
@@ -57,12 +58,9 @@ class PhotosView extends StatefulWidget {
State<PhotosView> createState() => _PhotosViewState(); State<PhotosView> createState() => _PhotosViewState();
} }
class _PhotosViewState extends State<PhotosView> class _PhotosViewState extends State<PhotosView> {
with SingleTickerProviderStateMixin {
bool _requested = false; bool _requested = false;
final Set<String> _selectedIds = {}; final Set<String> _selectedIds = {};
final ScrollController _selectionActionsScrollController = ScrollController();
final List<AnimationController> _scrollHintControllers = [];
// Session-local only (not one of PhotosController's persisted display // Session-local only (not one of PhotosController's persisted display
// prefs) - a pure narrowing of the already-fetched/filtered/sorted list, // prefs) - a pure narrowing of the already-fetched/filtered/sorted list,
@@ -74,81 +72,15 @@ class _PhotosViewState extends State<PhotosView>
void _toggleSelection(NextcloudItem item) { void _toggleSelection(NextcloudItem item) {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
final enteringSelection = _selectedIds.isEmpty;
setState(() { setState(() {
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
}); });
if (enteringSelection && _isSelecting) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _playScrollHint(_selectionActionsScrollController),
);
}
} }
void _clearSelection() { void _clearSelection() {
setState(_selectedIds.clear); 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<void> _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<double>([
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 /// Mirrors `FilesView._handlePickTap` - Photos has no folders, so this is
/// just the matching/toggle/immediate-confirm branch. /// just the matching/toggle/immediate-confirm branch.
void _handlePickTap( void _handlePickTap(
@@ -181,52 +113,52 @@ class _PhotosViewState extends State<PhotosView>
if (pick.isPicking) { if (pick.isPicking) {
return [ return [
SelectionAction( SelectionAction(
icon: Icons.check_rounded, icon: LucideIcons.check,
label: 'Use ${selected.length} item(s)', label: 'Use ${selected.length} item(s)',
onTap: () => pick.confirmPick(selected), 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 [ return [
SelectionAction( SelectionAction(
icon: selected.every((i) => i.isFavorite) icon: allFavorited ? LucideIcons.starOff : LucideIcons.star,
? Icons.favorite_rounded label: allFavorited ? 'Remove from favorites' : 'Favorite',
: Icons.favorite_border_rounded,
label: selected.every((i) => i.isFavorite)
? 'Remove from favorites'
: 'Favorite',
onTap: () => _favoriteSelected(selected), onTap: () => _favoriteSelected(selected),
), ),
SelectionAction( SelectionAction(
icon: Icons.share_rounded, icon: LucideIcons.share2,
label: 'Share', label: 'Share',
onTap: () => selected.length == 1 onTap: () => selected.length == 1
? ShareSheet.show(context, selected.single) ? ShareSheet.show(context, selected.single)
: _shareSelected(context, selected), : _shareSelected(context, selected),
), ),
SelectionAction( SelectionAction(
icon: Icons.download_rounded, icon: LucideIcons.download,
label: 'Download', label: 'Download',
onTap: () => _downloadSelected(context, selected), onTap: () => _downloadSelected(context, selected),
), ),
SelectionAction( SelectionAction(
icon: Icons.delete_outline_rounded, icon: LucideIcons.trash2,
label: 'Delete', label: 'Delete',
onTap: () => _confirmDeleteSelected(context, selected), onTap: () => _confirmDeleteSelected(context, selected),
), ),
SelectionAction( SelectionAction(
icon: Icons.copy_rounded, icon: LucideIcons.copy,
label: 'Copy', label: 'Copy',
onTap: () => _moveOrCopySelected(selected, copy: true), onTap: () => _moveOrCopySelected(selected, copy: true),
), ),
SelectionAction( SelectionAction(
icon: Icons.drive_file_move_rounded, icon: LucideIcons.folderInput,
label: 'Move', label: 'Move',
onTap: () => _moveOrCopySelected(selected, copy: false), onTap: () => _moveOrCopySelected(selected, copy: false),
), ),
if (selected.length == 1) if (selected.length == 1)
SelectionAction( SelectionAction(
icon: Icons.info_outline_rounded, icon: LucideIcons.info,
label: 'Details', label: 'Details',
onTap: () => DetailsSheet.show(context, selected.single), onTap: () => DetailsSheet.show(context, selected.single),
), ),
@@ -462,15 +394,13 @@ class _PhotosViewState extends State<PhotosView>
const SizedBox(width: 8), const SizedBox(width: 8),
NooChip( NooChip(
selected: _typeFilter == _PhotoTypeFilter.image, selected: _typeFilter == _PhotoTypeFilter.image,
onTap: () => onTap: () => setState(() => _typeFilter = _PhotoTypeFilter.image),
setState(() => _typeFilter = _PhotoTypeFilter.image),
child: const Text('Photos'), child: const Text('Photos'),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
NooChip( NooChip(
selected: _typeFilter == _PhotoTypeFilter.video, selected: _typeFilter == _PhotoTypeFilter.video,
onTap: () => onTap: () => setState(() => _typeFilter = _PhotoTypeFilter.video),
setState(() => _typeFilter = _PhotoTypeFilter.video),
child: const Text('Videos'), child: const Text('Videos'),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -490,6 +420,17 @@ class _PhotosViewState extends State<PhotosView>
void _showSortSheet(BuildContext context, PhotosController photos) { void _showSortSheet(BuildContext context, PhotosController photos) {
showNooSheet( showNooSheet(
context, context,
children: [
// `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: [ children: [
NooSegmentedControl<bool>( NooSegmentedControl<bool>(
fill: true, fill: true,
@@ -510,6 +451,7 @@ class _PhotosViewState extends State<PhotosView>
), ),
], ],
), ),
const SizedBox(height: 22),
NooGroupedList( NooGroupedList(
children: [ children: [
for (final field in FileSortField.values) for (final field in FileSortField.values)
@@ -530,6 +472,9 @@ class _PhotosViewState extends State<PhotosView>
], ],
), ),
], ],
),
),
],
); );
} }
@@ -541,7 +486,11 @@ class _PhotosViewState extends State<PhotosView>
showNooSheet( showNooSheet(
context, context,
children: [ children: [
NooGroupedList( // See `_showSortSheet`'s comment - `Listenable.merge` since these
// toggles span two controllers.
ListenableBuilder(
listenable: Listenable.merge([photos, files]),
builder: (context, _) => NooGroupedList(
children: [ children: [
NooSettingsRow( NooSettingsRow(
icon: LucideIcons.heart, icon: LucideIcons.heart,
@@ -571,75 +520,25 @@ class _PhotosViewState extends State<PhotosView>
), ),
], ],
), ),
),
], ],
); );
} }
/// Replaces the controls row's own sticky slot while selecting - a close /// Replaces the controls row's own sticky slot while selecting - a close
/// button, the "N selected" count, and the horizontally-scrollable bulk /// button, the "N selected" count, and the bulk actions. Mirrors
/// actions. Mirrors `FilesView`'s identical selection bar. /// `FilesView`'s identical selection bar.
Widget _buildSelectionBar( Widget _buildSelectionBar(
BuildContext context, BuildContext context,
PickController pick, PickController pick,
List<NextcloudItem> selectedItems, List<NextcloudItem> selectedItems,
) { ) {
final colors = context.nooColors; return NooSelectionBar(
return Padding( count: selectedItems.length,
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), actions: _buildSelectionActions(pick, selectedItems),
child: Row( onClose: _clearSelection,
children: [ isDesktop: NooLayout.isDesktop(context),
SizedBox( iosStyle: NooLayout.iosStyle(context),
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,
),
],
),
),
),
],
),
); );
} }
@@ -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<SelectionAction> 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 <SelectionAction>[];
// 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<SelectionAction> inline,
List<SelectionAction> 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<SelectionAction> inline,
List<SelectionAction> 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<SelectionAction> 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),
],
),
),
),
),
),
);
}
}
+201 -16
View File
@@ -1,16 +1,31 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons_flutter/lucide_icons.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_progress_bar.dart';
import 'package:noo/widgets/noo/core/noo_toggle.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_banner.dart';
import 'package:noo/widgets/noo/lists/noo_grouped_list.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_settings_row.dart';
import 'package:noo/widgets/noo/lists/noo_summary_card.dart'; import 'package:noo/widgets/noo/lists/noo_summary_card.dart';
import 'package:noo/widgets/noo/lists/noo_tab_order_row.dart'; import 'package:noo/widgets/noo/lists/noo_tab_order_row.dart';
import 'noo_test_utils.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<SelectionAction> _actions(int n, Map<String, int> 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() { void main() {
setUpNooTests(); setUpNooTests();
@@ -28,15 +43,26 @@ void main() {
); );
expect(find.byIcon(LucideIcons.info), findsOneWidget); expect(find.byIcon(LucideIcons.info), findsOneWidget);
expect(decorationOf(tester, find.text('Empty trash')).color, c.surface); expect(decorationOf(tester, find.text('Empty trash')).color, c.surface);
expect(tester.widget<Text>(find.text('Empty trash')).style!.color, c.danger); expect(
tester.widget<Text>(find.text('Empty trash')).style!.color,
c.danger,
);
await tester.tap(find.text('Empty trash')); await tester.tap(find.text('Empty trash'));
expect(taps, 1); 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( await pumpNoo(
tester, tester,
const NooBanner(actionLabel: 'Undo', actionIsDanger: false, child: Text('x')), const NooBanner(
actionLabel: 'Undo',
actionIsDanger: false,
child: Text('x'),
),
theme: theme, theme: theme,
); );
expect(tester.widget<Text>(find.text('Undo')).style!.color, c.accentText); expect(tester.widget<Text>(find.text('Undo')).style!.color, c.accentText);
@@ -44,7 +70,11 @@ void main() {
}); });
group('NooGroupedList', () { 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( await pumpNoo(
tester, tester,
SizedBox( SizedBox(
@@ -65,7 +95,8 @@ void main() {
expect(find.text('2'), findsOneWidget); expect(find.text('2'), findsOneWidget);
expect(find.text('Footer note'), findsOneWidget); expect(find.text('Footer note'), findsOneWidget);
expect(tester.widget<Text>(find.text('Accounts')).style!.color, c.fg2); expect(tester.widget<Text>(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; tester.getBottomLeft(find.byKey(const Key('r1'))).dy;
expect(gap, 1); expect(gap, 1);
// Rows paint their own surface; the gaps show the list's line fill. // 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 = <String, int>{};
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 = <String, int>{};
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 = <String, int>{};
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 = <String, int>{};
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', () { 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; var taps = 0;
await pumpNoo( await pumpNoo(
tester, tester,
@@ -101,7 +255,9 @@ void main() {
expect(taps, 1); 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( await pumpNoo(
tester, tester,
const SizedBox( const SizedBox(
@@ -123,7 +279,9 @@ void main() {
expect(tester.getSize(find.byType(NooSettingsRow)).height, 60); 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( await pumpNoo(
tester, tester,
const SizedBox( const SizedBox(
@@ -143,18 +301,31 @@ void main() {
tester, tester,
const SizedBox( const SizedBox(
width: 360, 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, 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(style.color, c.danger);
expect(tester.widget<Icon>(find.byIcon(LucideIcons.logOut)).color, c.danger); expect(
tester.widget<Icon>(find.byIcon(LucideIcons.logOut)).color,
c.danger,
);
}); });
}); });
group('NooSummaryCard', () { group('NooSummaryCard', () {
testNooWidgets('stat, caption, progress, meta, action', (tester, theme, c) async { testNooWidgets('stat, caption, progress, meta, action', (
tester,
theme,
c,
) async {
await pumpNoo( await pumpNoo(
tester, tester,
const SizedBox( const SizedBox(
@@ -182,7 +353,11 @@ void main() {
tester, tester,
const SizedBox( const SizedBox(
width: 360, width: 360,
child: NooSummaryCard(stat: '3', caption: 'Errors', tone: NooSummaryCardTone.danger), child: NooSummaryCard(
stat: '3',
caption: 'Errors',
tone: NooSummaryCardTone.danger,
),
), ),
theme: theme, theme: theme,
); );
@@ -194,7 +369,11 @@ void main() {
}); });
group('NooTabOrderRow', () { 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; var pinned = true;
await pumpNoo( await pumpNoo(
tester, tester,
@@ -212,13 +391,19 @@ void main() {
theme: theme, theme: theme,
); );
expect(find.text('Files'), findsOneWidget); 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.tap(find.byIcon(LucideIcons.pin));
await tester.pump(); await tester.pump();
expect(pinned, isFalse); expect(pinned, isFalse);
expect(find.byIcon(LucideIcons.pin), findsNothing); 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,
);
}); });
}); });
} }