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. - Shared: `users` in fg-3.
**Swipe action** **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. - 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. - 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 **Selection action bar** (Files, Photos, Favorites - anywhere with
multi-select) 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 | | 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` | | 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. | | 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 ### 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, priority order for the Selection action bar's bulk actions (favorite,
share, download, delete, copy, move, rename, sync, details) - the share, download, delete, copy, move, rename, sync, details) - the
first few (3 mobile / 4 wide tablet) land in the bar's fixed inline 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 9. Swipe on a file
Mobile is a two-level menu, the way native iOS/Android Settings apps 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 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 1.8x the block's width) before release - a full swipe-through does delete
in one gesture, it's not tap-only anymore. 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 - Always read colors through `context.nooColors`, never
`Theme.of(context).colorScheme` - the latter is Flutter's own Material 3 `Theme.of(context).colorScheme` - the latter is Flutter's own Material 3
scheme, reseeded by the user's accent color choice (`AppTheme.light`/ 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 /// What swiping a Files list-view item left/right does, user-configurable
/// in Settings. /// 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 /// How the mobile Upload button is sized: [auto] expands on Files/Photos and
/// shrinks to an icon elsewhere, [mini] is always icon-only, [expanded] /// shrinks to an icon elsewhere, [mini] is always icon-only, [expanded]
+17 -25
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 /// Reuse the single-item action handlers so swipe gestures and the action
/// that side is off or set to an action the design's two-slot /// bar agree on labels, sheets and mutations. Delete retains its swipe
/// `NooSwipeAction` has no room for (`SwipeAction.share` - still reachable /// confirmation before making any server changes.
/// via the row's overflow menu / the "Share" bulk action). NooSwipeActionSpec? _swipeSpec(SwipeAction action, NextcloudItem item) {
NooSwipeActionSpec? _swipeSpec( final kind = action.selectionKind;
SwipeAction action, if (kind == null) return null;
NextcloudItem item, final selectedAction = _buildSelectionActions(context, [
ItemOperations ops, item,
) { ]).firstWhere((candidate) => candidate.kind == kind);
switch (action) {
case SwipeAction.favorite:
return NooSwipeActionSpec( return NooSwipeActionSpec(
kind: NooSwipeActionKind.favorite, kind: kind,
onTriggered: () => ops.toggleItemFavorite(item), label: selectedAction.label,
icon: selectedAction.icon,
onTriggered: kind == SelectionActionKind.delete
? () => _confirmAndDeleteViaSwipe(item)
: selectedAction.onTap,
); );
case SwipeAction.delete:
return NooSwipeActionSpec(
kind: NooSwipeActionKind.delete,
onTriggered: () => _confirmAndDeleteViaSwipe(item),
);
case SwipeAction.share:
case SwipeAction.none:
return null;
}
} }
@override @override
@@ -1343,7 +1336,6 @@ class _FilesViewState extends State<FilesView> {
final sync = context.watch<SyncStatusController>(); final sync = context.watch<SyncStatusController>();
final session = context.watch<SessionController>(); final session = context.watch<SessionController>();
final settings = context.watch<SettingsController>(); final settings = context.watch<SettingsController>();
final ops = context.read<ItemOperations>();
final isSelected = _selectedIds.contains(item.id); final isSelected = _selectedIds.contains(item.id);
// Pick mode and selection are server-side features - never on Offline. // Pick mode and selection are server-side features - never on Offline.
final picking = !_offline && pick.isPicking; final picking = !_offline && pick.isPicking;
@@ -1381,8 +1373,8 @@ class _FilesViewState extends State<FilesView> {
final swipeable = _isSelecting || picking || _offline final swipeable = _isSelecting || picking || _offline
? row ? row
: NooSwipeAction( : NooSwipeAction(
startAction: _swipeSpec(settings.swipeRightAction, item, ops), startAction: _swipeSpec(settings.swipeRightAction, item),
endAction: _swipeSpec(settings.swipeLeftAction, item, ops), endAction: _swipeSpec(settings.swipeLeftAction, item),
child: row, child: row,
); );
+8 -11
View File
@@ -1,23 +1,21 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../models/selection_action.dart';
import '../../../theme/design_tokens.dart'; import '../../../theme/design_tokens.dart';
/// The two swipe actions the design system defines (`DESIGN_SYSTEM.md` 2, /// Swipe blocks share the action bar's identities, icons and default labels.
/// "Swipe action"); which one sits on which side is a user setting typedef NooSwipeActionKind = SelectionActionKind;
/// (Settings -> Swipe on a file).
enum NooSwipeActionKind { delete, favorite }
/// One side's action for [NooSwipeAction]. /// One side's action for [NooSwipeAction].
class NooSwipeActionSpec { class NooSwipeActionSpec {
final NooSwipeActionKind kind; final NooSwipeActionKind kind;
final VoidCallback onTriggered; 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. /// "Unfavorite" for an already-starred file.
final String? label; final String? label;
/// Overrides the default icon (`trash-2` / `star`). /// Overrides the action's default icon.
final IconData? icon; final IconData? icon;
const NooSwipeActionSpec({ const NooSwipeActionSpec({
@@ -30,7 +28,7 @@ class NooSwipeActionSpec {
/// Wraps a row (normally a `NooFileRow`) so dragging it horizontally slides /// Wraps a row (normally a `NooFileRow`) so dragging it horizontally slides
/// it aside to uncover a 96px action block: delete is white on /// 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 /// 12/600 label. Releasing past half the block snaps it open, otherwise it
/// springs shut - both on [NooMotion.base] / [NooMotion.ease], no bounce. /// springs shut - both on [NooMotion.base] / [NooMotion.ease], no bounce.
/// Tapping the block fires the action and closes it; tapping the row while /// 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) { Widget build(BuildContext context) {
final isDelete = spec.kind == NooSwipeActionKind.delete; final isDelete = spec.kind == NooSwipeActionKind.delete;
final bg = isDelete ? colors.dangerFill : colors.accent; final bg = isDelete ? colors.dangerFill : colors.accent;
final icon = final icon = spec.icon ?? spec.kind.icon;
spec.icon ?? (isDelete ? LucideIcons.trash2 : LucideIcons.star); final label = spec.label ?? spec.kind.label;
final label = spec.label ?? (isDelete ? 'Delete' : 'Favorite');
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
+57 -1
View File
@@ -4,6 +4,7 @@ import '../../models/selection_action.dart';
import '../../providers/settings_controller.dart'; import '../../providers/settings_controller.dart';
import '../../theme/design_tokens.dart'; import '../../theme/design_tokens.dart';
import '../noo/lists/noo_tab_order_row.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_settings_row.dart';
import '../noo/lists/noo_grouped_list.dart'; import '../noo/lists/noo_grouped_list.dart';
import '../noo/core/noo_segmented_control.dart'; import '../noo/core/noo_segmented_control.dart';
@@ -58,7 +59,11 @@ class SettingsActionBarSection extends StatelessWidget {
if (!desktop) { if (!desktop) {
return NooGroupedList( return NooGroupedList(
footer: const Text(_subtitle), 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), const SizedBox(height: 4),
Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)), Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)),
const SizedBox(height: NooSpace.md), const SizedBox(height: NooSpace.md),
_ActionBarPreview(order: settings.selectionActionOrder),
const SizedBox(height: NooSpace.md),
positionRow, positionRow,
const SizedBox(height: NooSpace.md), const SizedBox(height: NooSpace.md),
orderList, 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 /// The reorderable [NooTabOrderRow] stack for [SelectionActionKind]s - same
/// radius-20, line-divided card `settings_tabs.dart`'s `_TabOrderList` /// radius-20, line-divided card `settings_tabs.dart`'s `_TabOrderList`
/// draws, minus the pin toggle (`showPin: false`): there's nothing to /// 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 '../noo/lists/noo_settings_row.dart';
import 'settings_section.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 /// Settings section 3: three independent locks - opening the app, switching
/// accounts, and revealing hidden files - each behind the device's own PIN/ /// accounts, and revealing hidden files - each behind the device's own PIN/
/// biometric credential (see `AppLockService` - this app never stores or /// biometric credential (see `AppLockService` - this app never stores or
@@ -58,14 +38,13 @@ class SettingsSecuritySection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final session = context.watch<SessionController>(); final session = context.watch<SessionController>();
final platform = Theme.of(context).platform;
return SettingsSection( return SettingsSection(
title: 'Security', title: 'Security',
children: [ children: [
NooSettingsRow( NooSettingsRow(
icon: LucideIcons.lock, icon: LucideIcons.lock,
label: Text(biometricLabel(platform)), label: const Text('App lock'),
subtitle: const Text( subtitle: const Text(
"Require this device's PIN or biometric to open Noo", "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 '../noo/lists/noo_settings_row.dart';
import 'settings_section.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. /// Settings section 8: what swiping a file left/right does in list view.
class SettingsSwipeSection extends StatelessWidget { class SettingsSwipeSection extends StatelessWidget {
const SettingsSwipeSection({super.key}); const SettingsSwipeSection({super.key});
@@ -35,7 +22,8 @@ class SettingsSwipeSection extends StatelessWidget {
options: [ options: [
for (final action in SwipeAction.values) for (final action in SwipeAction.values)
NooSettingsRow( NooSettingsRow(
label: Text(_swipeActionLabel(action)), icon: action.selectionKind?.icon,
label: Text(action.label),
trailing: action == current trailing: action == current
? Icon( ? Icon(
LucideIcons.check, LucideIcons.check,
@@ -64,7 +52,7 @@ class SettingsSwipeSection extends StatelessWidget {
NooSettingsRow( NooSettingsRow(
icon: LucideIcons.chevronsRight, icon: LucideIcons.chevronsRight,
label: const Text('Swipe right'), label: const Text('Swipe right'),
value: _swipeActionLabel(settings.swipeRightAction), value: settings.swipeRightAction.label,
onTap: () => _openPicker( onTap: () => _openPicker(
context, context,
title: 'Swipe right', title: 'Swipe right',
@@ -75,7 +63,7 @@ class SettingsSwipeSection extends StatelessWidget {
NooSettingsRow( NooSettingsRow(
icon: LucideIcons.chevronsLeft, icon: LucideIcons.chevronsLeft,
label: const Text('Swipe left'), label: const Text('Swipe left'),
value: _swipeActionLabel(settings.swipeLeftAction), value: settings.swipeLeftAction.label,
onTap: () => _openPicker( onTap: () => _openPicker(
context, context,
title: 'Swipe left', title: 'Swipe left',
+48
View File
@@ -1,6 +1,8 @@
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:noo/widgets/noo/files/noo_swipe_action.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'; import 'noo_test_utils.dart';
@@ -8,6 +10,52 @@ void main() {
setUpNooTests(); setUpNooTests();
group('NooSwipeAction', () { 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', ( testNooWidgets('a moderate swipe opens the action without triggering it', (
tester, tester,
theme, 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);
});
}