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:
@@ -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.
|
||||
|
||||
+27
-134
@@ -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<FavoritesView> createState() => _FavoritesViewState();
|
||||
}
|
||||
|
||||
class _FavoritesViewState extends State<FavoritesView>
|
||||
with SingleTickerProviderStateMixin {
|
||||
class _FavoritesViewState extends State<FavoritesView> {
|
||||
bool _requested = false;
|
||||
final Set<String> _selectedIds = {};
|
||||
final ScrollController _selectionActionsScrollController = ScrollController();
|
||||
final List<AnimationController> _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<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
|
||||
/// 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<FavoritesView>
|
||||
List<SelectionAction> _buildSelectionActions(List<NextcloudItem> 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<FavoritesView>
|
||||
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<FavoritesView>
|
||||
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<FavoritesView>
|
||||
},
|
||||
);
|
||||
}
|
||||
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<FavoritesView>
|
||||
BuildContext context,
|
||||
List<NextcloudItem> 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+47
-86
@@ -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<FilesView>
|
||||
final Set<String> _selectedIds = {};
|
||||
int _lastPathDepth = 1;
|
||||
final ScrollController _controlsScrollController = ScrollController();
|
||||
final ScrollController _selectionActionsScrollController = ScrollController();
|
||||
final List<AnimationController> _scrollHintControllers = [];
|
||||
|
||||
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
|
||||
/// thumbnails come from server previews instead.
|
||||
File? _localFileFor(BuildContext context, NextcloudItem item) => _offline
|
||||
? context.read<OfflineController>().localFileFor(item)
|
||||
: null;
|
||||
File? _localFileFor(BuildContext context, NextcloudItem item) =>
|
||||
_offline ? context.read<OfflineController>().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<FilesView>
|
||||
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<FilesView>
|
||||
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<FilesView>
|
||||
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<FilesView>
|
||||
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<FilesView>
|
||||
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<FilesView>
|
||||
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<FilesView>
|
||||
);
|
||||
}
|
||||
|
||||
/// 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<NextcloudItem> 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<FilesView>
|
||||
// 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<FilesView>
|
||||
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<FilesView>
|
||||
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,
|
||||
|
||||
+107
-208
@@ -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<PhotosView> createState() => _PhotosViewState();
|
||||
}
|
||||
|
||||
class _PhotosViewState extends State<PhotosView>
|
||||
with SingleTickerProviderStateMixin {
|
||||
class _PhotosViewState extends State<PhotosView> {
|
||||
bool _requested = false;
|
||||
final Set<String> _selectedIds = {};
|
||||
final ScrollController _selectionActionsScrollController = ScrollController();
|
||||
final List<AnimationController> _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<PhotosView>
|
||||
|
||||
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<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
|
||||
/// just the matching/toggle/immediate-confirm branch.
|
||||
void _handlePickTap(
|
||||
@@ -181,52 +113,52 @@ class _PhotosViewState extends State<PhotosView>
|
||||
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<PhotosView>
|
||||
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<PhotosView>
|
||||
showNooSheet(
|
||||
context,
|
||||
children: [
|
||||
NooSegmentedControl<bool>(
|
||||
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<bool>(
|
||||
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<PhotosView>
|
||||
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<NextcloudItem> 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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<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() {
|
||||
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<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'));
|
||||
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<Text>(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<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;
|
||||
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 = <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', () {
|
||||
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<Icon>(find.byIcon(LucideIcons.logOut)).color, c.danger);
|
||||
expect(
|
||||
tester.widget<Icon>(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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user