Noo design system rebuild #1
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user