Add action bar preview and all swipe actions; rename App lock
Check PR / check (pull_request) Successful in 56s

This commit is contained in:
Ayushya Amitabh
2026-10-07 23:14:21 -04:00
parent 91ab8b4a07
commit 2d244ce335
10 changed files with 277 additions and 82 deletions
@@ -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" |
### Wide tablet
@@ -359,7 +359,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 wide tablet) 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
+11
View File
@@ -109,6 +109,17 @@ Gotchas:
trigger it) but also fires it directly if the drag goes far enough (past
1.8x the block's width) before release - a full swipe-through does delete
in one gesture, it's not tap-only anymore.
- 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`/
+18 -1
View File
@@ -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]
+19 -27
View File
@@ -629,30 +629,23 @@ class _FilesViewState extends State<FilesView> {
);
}
/// 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<FilesView> {
final sync = context.watch<SyncStatusController>();
final session = context.watch<SessionController>();
final settings = context.watch<SettingsController>();
final ops = context.read<ItemOperations>();
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<FilesView> {
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,
);
+8 -11
View File
@@ -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,
+57 -1
View File
@@ -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<SelectionActionKind> 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
+1 -22
View File
@@ -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<SessionController>();
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",
),
+4 -16
View File
@@ -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',
+48
View File
@@ -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<SelectionActionKind>()
.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,
+105
View File
@@ -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<NooSelectionBar>(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);
});
}