diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index 9e689ad..de3acd0 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -168,10 +168,10 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14 - Shared: `users` in fg-3. **Swipe action** -- The row slides and uncovers a 96px action block. Delete is white on danger-fill; Favorite is white on accent. +- The row slides and uncovers a 96px action block. Delete is white on danger-fill; all other actions are white on accent. - The block has a 20px icon above a 12/600 label. - Releasing past half the block snaps it open; dragging further still (past 1.8× the block's width, a haptic tick marks the crossing and the icon bumps up 15%) arms the action, and releasing while armed fires it immediately - one swipe-through gesture instead of open-then-tap. The block grows to fill however far it's dragged, so there's never a gap behind it. -- Swipe actions are set in Settings → Swipe on a file. +- Swipe actions are set in Settings → Swipe on a file. Both directions offer None and every action-bar action: Favorite, Share, Download, Delete, Copy, Move, Rename, Sync to device, Details. They use the same single-item handlers; Delete still asks for confirmation. **Selection action bar** (Files, Photos, Favorites - anywhere with multi-select) @@ -289,7 +289,7 @@ Mobile always has **5 pinned tabs in the bottom bar**. The rest go in the drawer | Upload | `plus` in the nav bar | Extended FAB - fully round (pill) when the bottom bar is floating, rounded square (`card` radius) when it's attached | | Overflow icon | `ellipsis` | `ellipsis-vertical` | | Settings | Pushed screen with a "‹ Files" back button and a large title. No bottom bar. | Pushed screen with a back arrow and a title. No bottom bar. | -| Biometric label | "Lock with Face ID" | "Lock with fingerprint" | +| App lock label | "App lock" | "App lock" | ### Desktop @@ -366,7 +366,9 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The priority order for the Selection action bar's bulk actions (favorite, share, download, delete, copy, move, rename, sync, details) - the first few (3 mobile / 4 desktop) land in the bar's fixed inline - slots, the rest sit behind "More". See §2 "Selection action bar". + slots, the rest sit behind "More". A live preview above the list uses + the actual bar and updates on reorder, including its More sheet. Preview + actions show feedback without touching files. See §2 "Selection action bar". 9. Swipe on a file Mobile is a two-level menu, the way native iOS/Android Settings apps diff --git a/.claude/context/styling.md b/.claude/context/styling.md index 1ce3ac2..083c4f9 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -111,6 +111,17 @@ Gotchas: in one gesture, it's not tap-only anymore. - Window chrome (macOS traffic lights, the Windows 40px title bar) isn't built yet. `NooSidebar.windowControls` is the slot for it. +- Swipe settings offer every `SelectionActionKind` plus None. `SwipeAction` + persists the existing enum names and maps to that shared action catalog; + `FilesView` uses the single-item action-bar handlers, with the existing + delete confirmation retained. Labels/icons update for favorited and synced + items. Swipes remain disabled during selection, picking, and offline browsing. +- Settings → Action bar shows the real `NooSelectionBar` above its reorder + list. It updates immediately and supports the same More sheet; preview + actions only show feedback, never operate on files. The bar scales down + inside Settings' narrower column while keeping the mobile/tablet split. +- The Security setting is labelled **App lock** on every platform; its + subtitle still explains device PIN or biometric authentication. - Always read colors through `context.nooColors`, never `Theme.of(context).colorScheme` - the latter is Flutter's own Material 3 scheme, reseeded by the user's accent color choice (`AppTheme.light`/ diff --git a/lib/providers/settings_controller.dart b/lib/providers/settings_controller.dart index feb0851..83211d3 100644 --- a/lib/providers/settings_controller.dart +++ b/lib/providers/settings_controller.dart @@ -7,7 +7,24 @@ import '../widgets/noo/nav/noo_nav_style.dart'; /// What swiping a Files list-view item left/right does, user-configurable /// in Settings. -enum SwipeAction { none, favorite, delete, share } +enum SwipeAction { + none(null), + favorite(SelectionActionKind.favorite), + delete(SelectionActionKind.delete), + share(SelectionActionKind.share), + download(SelectionActionKind.download), + copy(SelectionActionKind.copy), + move(SelectionActionKind.move), + rename(SelectionActionKind.rename), + sync(SelectionActionKind.sync), + details(SelectionActionKind.details); + + const SwipeAction(this.selectionKind); + + final SelectionActionKind? selectionKind; + + String get label => selectionKind?.label ?? 'None'; +} /// How the mobile Upload button is sized: [auto] expands on Files/Photos and /// shrinks to an icon elsewhere, [mini] is always icon-only, [expanded] diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index 10e9ed6..487b53f 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -629,30 +629,23 @@ class _FilesViewState extends State { ); } - /// The user's configured swipe action for one side of a row, or null if - /// that side is off or set to an action the design's two-slot - /// `NooSwipeAction` has no room for (`SwipeAction.share` - still reachable - /// via the row's overflow menu / the "Share" bulk action). - NooSwipeActionSpec? _swipeSpec( - SwipeAction action, - NextcloudItem item, - ItemOperations ops, - ) { - switch (action) { - case SwipeAction.favorite: - return NooSwipeActionSpec( - kind: NooSwipeActionKind.favorite, - onTriggered: () => ops.toggleItemFavorite(item), - ); - case SwipeAction.delete: - return NooSwipeActionSpec( - kind: NooSwipeActionKind.delete, - onTriggered: () => _confirmAndDeleteViaSwipe(item), - ); - case SwipeAction.share: - case SwipeAction.none: - return null; - } + /// Reuse the single-item action handlers so swipe gestures and the action + /// bar agree on labels, sheets and mutations. Delete retains its swipe + /// confirmation before making any server changes. + NooSwipeActionSpec? _swipeSpec(SwipeAction action, NextcloudItem item) { + final kind = action.selectionKind; + if (kind == null) return null; + final selectedAction = _buildSelectionActions(context, [ + item, + ]).firstWhere((candidate) => candidate.kind == kind); + return NooSwipeActionSpec( + kind: kind, + label: selectedAction.label, + icon: selectedAction.icon, + onTriggered: kind == SelectionActionKind.delete + ? () => _confirmAndDeleteViaSwipe(item) + : selectedAction.onTap, + ); } @override @@ -1343,7 +1336,6 @@ class _FilesViewState extends State { final sync = context.watch(); final session = context.watch(); final settings = context.watch(); - final ops = context.read(); final isSelected = _selectedIds.contains(item.id); // Pick mode and selection are server-side features - never on Offline. final picking = !_offline && pick.isPicking; @@ -1381,8 +1373,8 @@ class _FilesViewState extends State { final swipeable = _isSelecting || picking || _offline ? row : NooSwipeAction( - startAction: _swipeSpec(settings.swipeRightAction, item, ops), - endAction: _swipeSpec(settings.swipeLeftAction, item, ops), + startAction: _swipeSpec(settings.swipeRightAction, item), + endAction: _swipeSpec(settings.swipeLeftAction, item), child: row, ); diff --git a/lib/widgets/noo/files/noo_swipe_action.dart b/lib/widgets/noo/files/noo_swipe_action.dart index 7eb9ce9..e729ad7 100644 --- a/lib/widgets/noo/files/noo_swipe_action.dart +++ b/lib/widgets/noo/files/noo_swipe_action.dart @@ -1,23 +1,21 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../models/selection_action.dart'; import '../../../theme/design_tokens.dart'; -/// The two swipe actions the design system defines (`DESIGN_SYSTEM.md` 2, -/// "Swipe action"); which one sits on which side is a user setting -/// (Settings -> Swipe on a file). -enum NooSwipeActionKind { delete, favorite } +/// Swipe blocks share the action bar's identities, icons and default labels. +typedef NooSwipeActionKind = SelectionActionKind; /// One side's action for [NooSwipeAction]. class NooSwipeActionSpec { final NooSwipeActionKind kind; final VoidCallback onTriggered; - /// Overrides the default label ("Delete" / "Favorite"), e.g. + /// Overrides the action's default label, e.g. /// "Unfavorite" for an already-starred file. final String? label; - /// Overrides the default icon (`trash-2` / `star`). + /// Overrides the action's default icon. final IconData? icon; const NooSwipeActionSpec({ @@ -30,7 +28,7 @@ class NooSwipeActionSpec { /// Wraps a row (normally a `NooFileRow`) so dragging it horizontally slides /// it aside to uncover a 96px action block: delete is white on -/// `danger-fill`, favorite is white on `accent`, each a 20px icon over a +/// `danger-fill`, other actions are white on `accent`, each a 20px icon over a /// 12/600 label. Releasing past half the block snaps it open, otherwise it /// springs shut - both on [NooMotion.base] / [NooMotion.ease], no bounce. /// Tapping the block fires the action and closes it; tapping the row while @@ -256,9 +254,8 @@ class _ActionBlock extends StatelessWidget { Widget build(BuildContext context) { final isDelete = spec.kind == NooSwipeActionKind.delete; final bg = isDelete ? colors.dangerFill : colors.accent; - final icon = - spec.icon ?? (isDelete ? LucideIcons.trash2 : LucideIcons.star); - final label = spec.label ?? (isDelete ? 'Delete' : 'Favorite'); + final icon = spec.icon ?? spec.kind.icon; + final label = spec.label ?? spec.kind.label; return GestureDetector( behavior: HitTestBehavior.opaque, diff --git a/lib/widgets/settings/settings_action_bar.dart b/lib/widgets/settings/settings_action_bar.dart index d58e3a8..2c5197b 100644 --- a/lib/widgets/settings/settings_action_bar.dart +++ b/lib/widgets/settings/settings_action_bar.dart @@ -4,6 +4,7 @@ import '../../models/selection_action.dart'; import '../../providers/settings_controller.dart'; import '../../theme/design_tokens.dart'; import '../noo/lists/noo_tab_order_row.dart'; +import '../noo/lists/noo_selection_bar.dart'; import '../noo/lists/noo_settings_row.dart'; import '../noo/lists/noo_grouped_list.dart'; import '../noo/core/noo_segmented_control.dart'; @@ -58,7 +59,11 @@ class SettingsActionBarSection extends StatelessWidget { if (!desktop) { return NooGroupedList( footer: const Text(_subtitle), - children: [positionRow, orderList], + children: [ + _ActionBarPreview(order: settings.selectionActionOrder), + positionRow, + orderList, + ], ); } @@ -79,6 +84,8 @@ class SettingsActionBarSection extends StatelessWidget { const SizedBox(height: 4), Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)), const SizedBox(height: NooSpace.md), + _ActionBarPreview(order: settings.selectionActionOrder), + const SizedBox(height: NooSpace.md), positionRow, const SizedBox(height: NooSpace.md), orderList, @@ -88,6 +95,55 @@ class SettingsActionBarSection extends StatelessWidget { } } +class _ActionBarPreview extends StatelessWidget { + final List order; + + const _ActionBarPreview({required this.order}); + + @override + Widget build(BuildContext context) { + final colors = context.nooColors; + final desktop = NooLayout.isDesktop(context); + return Container( + padding: const EdgeInsets.all(NooSpace.sm), + color: colors.surface2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Preview', style: NooText.meta.copyWith(color: colors.fg3)), + const SizedBox(height: NooSpace.xs), + // Settings has a narrower column than the file browser. Scale the + // real bar to fit rather than changing its inline/overflow split. + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: SizedBox( + width: desktop ? 1000 : 400, + child: NooSelectionBar( + count: 1, + isDesktop: desktop, + iosStyle: NooLayout.iosStyle(context), + onClose: () {}, + actions: [ + for (final kind in order) + SelectionAction( + kind: kind, + icon: kind.icon, + label: kind.label, + onTap: () => ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${kind.label} preview')), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + /// The reorderable [NooTabOrderRow] stack for [SelectionActionKind]s - same /// radius-20, line-divided card `settings_tabs.dart`'s `_TabOrderList` /// draws, minus the pin toggle (`showPin: false`): there's nothing to diff --git a/lib/widgets/settings/settings_security.dart b/lib/widgets/settings/settings_security.dart index e9de0de..02865e8 100644 --- a/lib/widgets/settings/settings_security.dart +++ b/lib/widgets/settings/settings_security.dart @@ -6,26 +6,6 @@ import '../noo/core/noo_toggle.dart'; import '../noo/lists/noo_settings_row.dart'; import 'settings_section.dart'; -/// Per-platform copy for the login-lock row's label (DESIGN_SYSTEM.md 3's -/// "Biometric label" row, both the mobile table and the macOS/Windows -/// desktop table). Falls back to generic copy on platforms the spec -/// doesn't name (Linux, tests). -String biometricLabel(TargetPlatform platform) { - switch (platform) { - case TargetPlatform.iOS: - return 'Lock with Face ID'; - case TargetPlatform.android: - return 'Lock with fingerprint'; - case TargetPlatform.macOS: - return 'Touch ID'; - case TargetPlatform.windows: - return 'Windows Hello'; - case TargetPlatform.linux: - case TargetPlatform.fuchsia: - return 'Lock with device credential'; - } -} - /// Settings section 3: three independent locks - opening the app, switching /// accounts, and revealing hidden files - each behind the device's own PIN/ /// biometric credential (see `AppLockService` - this app never stores or @@ -58,14 +38,13 @@ class SettingsSecuritySection extends StatelessWidget { @override Widget build(BuildContext context) { final session = context.watch(); - final platform = Theme.of(context).platform; return SettingsSection( title: 'Security', children: [ NooSettingsRow( icon: LucideIcons.lock, - label: Text(biometricLabel(platform)), + label: const Text('App lock'), subtitle: const Text( "Require this device's PIN or biometric to open Noo", ), diff --git a/lib/widgets/settings/settings_swipe.dart b/lib/widgets/settings/settings_swipe.dart index 0165994..eb01b8c 100644 --- a/lib/widgets/settings/settings_swipe.dart +++ b/lib/widgets/settings/settings_swipe.dart @@ -6,19 +6,6 @@ import '../../theme/design_tokens.dart'; import '../noo/lists/noo_settings_row.dart'; import 'settings_section.dart'; -String _swipeActionLabel(SwipeAction action) { - switch (action) { - case SwipeAction.none: - return 'None'; - case SwipeAction.favorite: - return 'Favorite'; - case SwipeAction.delete: - return 'Delete'; - case SwipeAction.share: - return 'Share'; - } -} - /// Settings section 8: what swiping a file left/right does in list view. class SettingsSwipeSection extends StatelessWidget { const SettingsSwipeSection({super.key}); @@ -35,7 +22,8 @@ class SettingsSwipeSection extends StatelessWidget { options: [ for (final action in SwipeAction.values) NooSettingsRow( - label: Text(_swipeActionLabel(action)), + icon: action.selectionKind?.icon, + label: Text(action.label), trailing: action == current ? Icon( LucideIcons.check, @@ -64,7 +52,7 @@ class SettingsSwipeSection extends StatelessWidget { NooSettingsRow( icon: LucideIcons.chevronsRight, label: const Text('Swipe right'), - value: _swipeActionLabel(settings.swipeRightAction), + value: settings.swipeRightAction.label, onTap: () => _openPicker( context, title: 'Swipe right', @@ -75,7 +63,7 @@ class SettingsSwipeSection extends StatelessWidget { NooSettingsRow( icon: LucideIcons.chevronsLeft, label: const Text('Swipe left'), - value: _swipeActionLabel(settings.swipeLeftAction), + value: settings.swipeLeftAction.label, onTap: () => _openPicker( context, title: 'Swipe left', diff --git a/test/widgets/noo/files_swipe_test.dart b/test/widgets/noo/files_swipe_test.dart index 1a86f7f..14c4159 100644 --- a/test/widgets/noo/files_swipe_test.dart +++ b/test/widgets/noo/files_swipe_test.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:noo/widgets/noo/files/noo_swipe_action.dart'; +import 'package:noo/models/selection_action.dart'; +import 'package:noo/providers/settings_controller.dart'; import 'noo_test_utils.dart'; @@ -8,6 +10,52 @@ void main() { setUpNooTests(); group('NooSwipeAction', () { + test('every action-bar action has a swipe setting', () { + expect( + SwipeAction.values + .map((action) => action.selectionKind) + .whereType() + .toSet(), + SelectionActionKind.values.toSet(), + ); + }); + + for (final kind in SelectionActionKind.values) { + testNooWidgets('a full swipe runs ${kind.label}', ( + tester, + theme, + c, + ) async { + var triggered = 0; + await pumpNoo( + tester, + SizedBox( + width: 360, + height: 64, + child: NooSwipeAction( + startAction: NooSwipeActionSpec( + kind: kind, + onTriggered: () => triggered++, + ), + child: const ColoredBox( + color: Colors.white, + child: SizedBox(width: 360, height: 64), + ), + ), + ), + theme: theme, + ); + await tester.drag(find.byType(NooSwipeAction), const Offset(60, 0)); + await tester.pumpAndSettle(); + expect(triggered, 0); + expect(find.text(kind.label), findsOneWidget); + expect(find.byIcon(kind.icon), findsOneWidget); + await tester.drag(find.byType(NooSwipeAction), const Offset(300, 0)); + await tester.pump(); + expect(triggered, 1); + }); + } + testNooWidgets('a moderate swipe opens the action without triggering it', ( tester, theme, diff --git a/test/widgets/settings_action_bar_test.dart b/test/widgets/settings_action_bar_test.dart new file mode 100644 index 0000000..e5d1aab --- /dev/null +++ b/test/widgets/settings_action_bar_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:noo/models/selection_action.dart'; +import 'package:noo/providers/settings_controller.dart'; +import 'package:noo/widgets/noo/lists/noo_selection_bar.dart'; +import 'package:noo/widgets/settings/settings_action_bar.dart'; +import 'package:noo/widgets/settings/settings_swipe.dart'; + +import 'noo/noo_test_utils.dart'; + +void main() { + setUpNooTests(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + for (final desktop in [false, true]) { + testNooWidgets( + 'action preview follows reorder and exposes overflow (${desktop ? 'tablet' : 'phone'})', + (tester, theme, colors) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = desktop + ? const Size(1200, 900) + : const Size(380, 900); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final settings = SettingsController(); + addTearDown(settings.dispose); + await pumpNoo( + tester, + ChangeNotifierProvider.value( + value: settings, + child: SizedBox( + width: desktop ? 640 : 340, + child: const SingleChildScrollView( + child: SettingsActionBarSection(), + ), + ), + ), + theme: theme, + surfaceSize: desktop ? const Size(1200, 900) : const Size(380, 900), + ); + await tester.pumpAndSettle(); + final bar = find.byType(NooSelectionBar); + expect(bar, findsOneWidget); + expect(tester.widget(bar).isDesktop, desktop); + expect(find.text('Preview'), findsOneWidget); + settings.setSelectionActionOrder([ + SelectionActionKind.details, + ...SelectionActionKind.values.where( + (kind) => kind != SelectionActionKind.details, + ), + ]); + await tester.pumpAndSettle(); + await tester.tap( + find.descendant(of: bar, matching: find.byIcon(LucideIcons.info)), + ); + await tester.pump(); + expect(find.text('Details preview'), findsOneWidget); + await tester.tap( + find.descendant( + of: bar, + matching: desktop + ? find.text('More') + : find.byIcon(LucideIcons.ellipsisVertical), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Sync to device'), findsNWidgets(2)); + expect(tester.takeException(), isNull); + }, + ); + } + + testNooWidgets('swipe picker offers every action and saves the choice', ( + tester, + theme, + colors, + ) async { + final settings = SettingsController(); + addTearDown(settings.dispose); + await pumpNoo( + tester, + ChangeNotifierProvider.value( + value: settings, + child: const SettingsSwipeSection(), + ), + theme: theme, + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Swipe right')); + await tester.pumpAndSettle(); + for (final kind in SelectionActionKind.values) { + expect(find.text(kind.label), findsAtLeastNWidgets(1)); + } + await tester.ensureVisible(find.text('Details')); + await tester.tap(find.text('Details')); + await tester.pumpAndSettle(); + expect(settings.swipeRightAction, SwipeAction.details); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('ui_swipe_right_action'), 'details'); + expect(tester.takeException(), isNull); + }); +}