From c5133817ded7e12fce5bd00bd1279c085eda56af Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Tue, 7 Jul 2026 12:49:46 +0800 Subject: [PATCH 1/2] fix(dashboard): exit edit mode when navigating away (#1037) Extract edit mode state from view-local to a centralized provider so it can be accessed by route guards. When user navigates away from dashboard (e.g., tab switch) during edit mode, onExit cancels edit mode and reverts any unsaved layout changes. Co-Authored-By: Claude Opus 4.5 --- .../dashboard_edit_mode_provider.dart | 88 +++++++++ .../views/usp_sliver_dashboard_view.dart | 67 ++----- lib/route/route_usp_dashboard.dart | 12 ++ lib/route/router_provider.dart | 1 + .../dashboard_edit_mode_provider_test.dart | 172 ++++++++++++++++++ 5 files changed, 289 insertions(+), 51 deletions(-) create mode 100644 lib/page/dashboard/providers/dashboard_edit_mode_provider.dart create mode 100644 test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart diff --git a/lib/page/dashboard/providers/dashboard_edit_mode_provider.dart b/lib/page/dashboard/providers/dashboard_edit_mode_provider.dart new file mode 100644 index 000000000..1c07dfbd8 --- /dev/null +++ b/lib/page/dashboard/providers/dashboard_edit_mode_provider.dart @@ -0,0 +1,88 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/page/dashboard/models/usp_layout_preferences.dart'; +import 'package:privacy_gui/page/dashboard/providers/usp_layout_controller.dart'; +import 'package:privacy_gui/page/dashboard/providers/usp_layout_preferences_provider.dart'; + +/// Manages dashboard edit mode state and layout snapshots for revert on cancel. +/// +/// This provider centralizes edit mode state so it can be accessed from route +/// guards (onExit) to handle navigation away during edit mode. +final dashboardEditModeProvider = + NotifierProvider( + DashboardEditModeNotifier.new, +); + +class DashboardEditState { + final bool isEditing; + final List? layoutSnapshot; + final UspLayoutPreferences? prefsSnapshot; + + const DashboardEditState({ + this.isEditing = false, + this.layoutSnapshot, + this.prefsSnapshot, + }); + + DashboardEditState copyWith({ + bool? isEditing, + List? layoutSnapshot, + UspLayoutPreferences? prefsSnapshot, + bool clearSnapshots = false, + }) { + return DashboardEditState( + isEditing: isEditing ?? this.isEditing, + layoutSnapshot: + clearSnapshots ? null : (layoutSnapshot ?? this.layoutSnapshot), + prefsSnapshot: + clearSnapshots ? null : (prefsSnapshot ?? this.prefsSnapshot), + ); + } +} + +class DashboardEditModeNotifier extends Notifier { + @override + DashboardEditState build() => const DashboardEditState(); + + /// Enter edit mode and capture snapshots for potential revert. + Future enterEditMode() async { + await ref.read(uspLayoutPreferencesProvider.notifier).initialized; + + final controller = ref.read(uspSliverDashboardControllerProvider); + final layoutSnapshot = controller.exportLayout(); + final prefsSnapshot = ref.read(uspLayoutPreferencesProvider); + + state = DashboardEditState( + isEditing: true, + layoutSnapshot: layoutSnapshot, + prefsSnapshot: prefsSnapshot, + ); + + controller.setEditMode(true); + } + + /// Exit edit mode and optionally save or revert changes. + Future exitEditMode({bool save = true}) async { + final controller = ref.read(uspSliverDashboardControllerProvider); + + if (!save) { + // Revert to snapshots + if (state.layoutSnapshot != null) { + controller.importLayout(state.layoutSnapshot!); + await ref + .read(uspSliverDashboardControllerProvider.notifier) + .saveLayout(); + } + if (state.prefsSnapshot != null) { + await ref + .read(uspLayoutPreferencesProvider.notifier) + .restoreSnapshot(state.prefsSnapshot!); + } + } + + controller.setEditMode(false); + state = const DashboardEditState(); + } + + /// Cancel edit mode (revert changes) - convenience method. + Future cancelEditMode() => exitEditMode(save: false); +} diff --git a/lib/page/dashboard/views/usp_sliver_dashboard_view.dart b/lib/page/dashboard/views/usp_sliver_dashboard_view.dart index f405fd51d..a83607db0 100644 --- a/lib/page/dashboard/views/usp_sliver_dashboard_view.dart +++ b/lib/page/dashboard/views/usp_sliver_dashboard_view.dart @@ -6,7 +6,7 @@ import 'package:privacy_gui/page/dashboard/views/components/effects/jiggle_shake import 'package:privacy_gui/page/dashboard/factories/usp_widget_factory.dart'; import 'package:privacy_gui/constants/pref_key.dart'; import 'package:privacy_gui/page/dashboard/models/usp_dashboard_preset.dart'; -import 'package:privacy_gui/page/dashboard/models/usp_layout_preferences.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_edit_mode_provider.dart'; import 'package:privacy_gui/page/dashboard/models/package_widget_template.dart'; import 'package:privacy_gui/page/dashboard/providers/package_widget_loader.dart'; import 'package:privacy_gui/page/dashboard/widgets/package_widget_renderer.dart'; @@ -42,9 +42,6 @@ class UspSliverDashboardView extends ConsumerStatefulWidget { class _UspSliverDashboardViewState extends ConsumerState { - bool _isEditMode = false; - List? _initialLayoutSnapshot; - UspLayoutPreferences? _initialPrefsSnapshot; bool _presetDialogShown = false; @override @@ -112,45 +109,11 @@ class _UspSliverDashboardViewState } void _enterEditMode() async { - // Ensure preferences have been loaded from SharedPreferences before - // capturing the snapshot. Without this, the snapshot may capture the - // default state (preset = null) if _loadFromPrefs hasn't completed yet. - await ref.read(uspLayoutPreferencesProvider.notifier).initialized; - - final controller = ref.read(uspSliverDashboardControllerProvider); - _initialLayoutSnapshot = controller.exportLayout(); - _initialPrefsSnapshot = ref.read(uspLayoutPreferencesProvider); - - if (!mounted) return; - setState(() { - _isEditMode = true; - }); - controller.setEditMode(true); + await ref.read(dashboardEditModeProvider.notifier).enterEditMode(); } void _exitEditMode({bool save = true}) async { - final controller = ref.read(uspSliverDashboardControllerProvider); - - if (!save) { - if (_initialLayoutSnapshot != null) { - controller.importLayout(_initialLayoutSnapshot!); - await ref - .read(uspSliverDashboardControllerProvider.notifier) - .saveLayout(); - } - if (_initialPrefsSnapshot != null) { - await ref - .read(uspLayoutPreferencesProvider.notifier) - .restoreSnapshot(_initialPrefsSnapshot!); - } - } - - setState(() { - _isEditMode = false; - _initialLayoutSnapshot = null; - _initialPrefsSnapshot = null; - }); - controller.setEditMode(false); + await ref.read(dashboardEditModeProvider.notifier).exitEditMode(save: save); } @override @@ -205,6 +168,7 @@ class _UspSliverDashboardViewState Widget _buildHeader(BuildContext context) { final isRemoteMode = GlobalConfig.remote.isActive; + final isEditMode = ref.watch(dashboardEditModeProvider).isEditing; return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -212,7 +176,7 @@ class _UspSliverDashboardViewState AppText.headlineSmall(loc(context).uspDashboard), Row( children: [ - if (_isEditMode) ...[ + if (isEditMode) ...[ AppIconButton( icon: AppIcon.font(Icons.auto_fix_high), onTap: () { @@ -339,6 +303,7 @@ class _UspSliverDashboardViewState Widget _buildSliverDashboard(BuildContext context) { final controller = ref.watch(uspSliverDashboardControllerProvider); final factory = ref.watch(uspWidgetFactoryProvider); + final isEditMode = ref.watch(dashboardEditModeProvider).isEditing; final uiKitColumns = context.currentMaxColumns; final scrollController = ScrollController(); @@ -360,18 +325,18 @@ class _UspSliverDashboardViewState controller: controller, scrollController: scrollController, itemBuilder: (context, item) { - return _buildItemWidget(context, item, _isEditMode, factory); + return _buildItemWidget(context, item, isEditMode, factory); }, slotAspectRatio: ratio, mainAxisSpacing: AppSpacing.lg, crossAxisSpacing: AppSpacing.lg, padding: EdgeInsets.symmetric(horizontal: pageMargin), - gridStyle: _isEditMode ? editModeGridStyle : null, + gridStyle: isEditMode ? editModeGridStyle : null, onItemResizeEnd: (item) { _handleResizeEnd(context, item); }, // Drag-to-trash for widget removal in edit mode. - trashBuilder: !_isEditMode + trashBuilder: !isEditMode ? null : (context, isHovered, isActive, activeItemId) { return _buildTrashZone(context, isHovered, isActive); @@ -388,13 +353,13 @@ class _UspSliverDashboardViewState padding: EdgeInsets.symmetric(horizontal: pageMargin), sliver: SliverDashboard( itemBuilder: (context, item) { - return _buildItemWidget(context, item, _isEditMode, factory); + return _buildItemWidget(context, item, isEditMode, factory); }, slotAspectRatio: ratio, mainAxisSpacing: AppSpacing.lg, crossAxisSpacing: AppSpacing.lg, breakpoints: {0: uiKitColumns}, - gridStyle: _isEditMode ? editModeGridStyle : null, + gridStyle: isEditMode ? editModeGridStyle : null, ), ), const SliverToBoxAdapter( @@ -546,11 +511,11 @@ class _UspSliverDashboardViewState result == 'toggle_off' || result == 'preset_changed') && mounted) { - setState(() { - _isEditMode = false; - _initialLayoutSnapshot = null; - _initialPrefsSnapshot = null; - }); + // Exit edit mode without saving — the settings panel already applied + // the reset/preset change directly. + await ref + .read(dashboardEditModeProvider.notifier) + .exitEditMode(save: false); } } diff --git a/lib/route/route_usp_dashboard.dart b/lib/route/route_usp_dashboard.dart index 365932305..00203bd31 100644 --- a/lib/route/route_usp_dashboard.dart +++ b/lib/route/route_usp_dashboard.dart @@ -19,6 +19,18 @@ final uspDashboardRoute = ShellRoute( }); return const UspDashboardView(); }, + onExit: (context, state) async { + // Cancel edit mode when navigating away from dashboard (e.g., tab switch). + // This reverts any unsaved layout changes. + final container = ProviderScope.containerOf(context); + final editState = container.read(dashboardEditModeProvider); + if (editState.isEditing) { + await container + .read(dashboardEditModeProvider.notifier) + .cancelEditMode(); + } + return true; + }, ), LinksysRoute( name: RouteNamed.uspMenu, diff --git a/lib/route/router_provider.dart b/lib/route/router_provider.dart index 667407dd4..28f83e849 100644 --- a/lib/route/router_provider.dart +++ b/lib/route/router_provider.dart @@ -24,6 +24,7 @@ import 'navigation_extra.dart'; // USP dashboard imports import 'package:privacy_gui/page/_shared/providers/usp_bars_visible_provider.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_edit_mode_provider.dart'; import 'package:privacy_gui/page/dashboard/views/usp_dashboard_view.dart'; import 'package:privacy_gui/page/menu/views/usp_menu_view.dart'; import 'package:privacy_gui/page/support/views/usp_support_view.dart'; diff --git a/test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart b/test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart new file mode 100644 index 000000000..49fd63efa --- /dev/null +++ b/test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart @@ -0,0 +1,172 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/page/dashboard/models/usp_layout_preferences.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_edit_mode_provider.dart'; +import 'package:privacy_gui/page/dashboard/providers/usp_layout_controller.dart'; +import 'package:privacy_gui/page/dashboard/providers/usp_layout_preferences_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Future pumpAsync() async { + await Future.delayed(const Duration(milliseconds: 100)); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future createContainer({ + Map initialValues = const {}, + }) async { + SharedPreferences.setMockInitialValues(initialValues); + final container = ProviderContainer(); + container.read(uspSliverDashboardControllerProvider); + await container.read(uspLayoutPreferencesProvider.notifier).initialized; + await pumpAsync(); + return container; + } + + group('DashboardEditState', () { + test('default state has isEditing=false and null snapshots', () { + const state = DashboardEditState(); + expect(state.isEditing, isFalse); + expect(state.layoutSnapshot, isNull); + expect(state.prefsSnapshot, isNull); + }); + + test('copyWith updates fields correctly', () { + const state = DashboardEditState(); + final updated = state.copyWith( + isEditing: true, + layoutSnapshot: [ + {'id': 'test'} + ], + prefsSnapshot: const UspLayoutPreferences(useCustomLayout: false), + ); + + expect(updated.isEditing, isTrue); + expect(updated.layoutSnapshot, hasLength(1)); + expect(updated.prefsSnapshot?.useCustomLayout, isFalse); + }); + + test('copyWith with clearSnapshots clears snapshots', () { + final state = DashboardEditState( + isEditing: true, + layoutSnapshot: [ + {'id': 'test'} + ], + prefsSnapshot: const UspLayoutPreferences(), + ); + final cleared = state.copyWith(clearSnapshots: true); + + expect(cleared.layoutSnapshot, isNull); + expect(cleared.prefsSnapshot, isNull); + }); + }); + + group('DashboardEditModeNotifier', () { + test('initial state is not editing', () async { + final container = await createContainer(); + addTearDown(container.dispose); + + final state = container.read(dashboardEditModeProvider); + expect(state.isEditing, isFalse); + expect(state.layoutSnapshot, isNull); + expect(state.prefsSnapshot, isNull); + }); + + test('enterEditMode sets isEditing=true and captures snapshots', () async { + final container = await createContainer(); + addTearDown(container.dispose); + + await container.read(dashboardEditModeProvider.notifier).enterEditMode(); + + final state = container.read(dashboardEditModeProvider); + expect(state.isEditing, isTrue); + expect(state.layoutSnapshot, isNotNull); + expect(state.layoutSnapshot, isNotEmpty); + expect(state.prefsSnapshot, isNotNull); + }); + + test('exitEditMode(save: true) clears state without reverting', () async { + final container = await createContainer(); + addTearDown(container.dispose); + + await container.read(dashboardEditModeProvider.notifier).enterEditMode(); + final snapshotBeforeExit = + container.read(dashboardEditModeProvider).layoutSnapshot; + expect(snapshotBeforeExit, isNotNull); + + await container + .read(dashboardEditModeProvider.notifier) + .exitEditMode(save: true); + + final state = container.read(dashboardEditModeProvider); + expect(state.isEditing, isFalse); + expect(state.layoutSnapshot, isNull); + expect(state.prefsSnapshot, isNull); + }); + + test('exitEditMode(save: false) reverts layout to snapshot', () async { + final container = await createContainer(); + addTearDown(container.dispose); + + await container.read(dashboardEditModeProvider.notifier).enterEditMode(); + + final originalLayout = container + .read(uspSliverDashboardControllerProvider) + .exportLayout() + .length; + + final controller = container.read(uspSliverDashboardControllerProvider); + controller.removeItems(['stats_panel']); + final afterRemove = controller.exportLayout().length; + expect(afterRemove, lessThan(originalLayout)); + + await container + .read(dashboardEditModeProvider.notifier) + .exitEditMode(save: false); + + final restoredLayout = + container.read(uspSliverDashboardControllerProvider).exportLayout(); + expect(restoredLayout.length, equals(originalLayout)); + }); + + test('cancelEditMode is equivalent to exitEditMode(save: false)', () async { + final container = await createContainer(); + addTearDown(container.dispose); + + await container.read(dashboardEditModeProvider.notifier).enterEditMode(); + + final controller = container.read(uspSliverDashboardControllerProvider); + final originalCount = controller.exportLayout().length; + controller.removeItems(['stats_panel']); + + await container.read(dashboardEditModeProvider.notifier).cancelEditMode(); + + final state = container.read(dashboardEditModeProvider); + expect(state.isEditing, isFalse); + + final restoredCount = container + .read(uspSliverDashboardControllerProvider) + .exportLayout() + .length; + expect(restoredCount, equals(originalCount)); + }); + + test('multiple enter/exit cycles work correctly', () async { + final container = await createContainer(); + addTearDown(container.dispose); + + for (int i = 0; i < 3; i++) { + await container + .read(dashboardEditModeProvider.notifier) + .enterEditMode(); + expect(container.read(dashboardEditModeProvider).isEditing, isTrue); + + await container + .read(dashboardEditModeProvider.notifier) + .exitEditMode(save: true); + expect(container.read(dashboardEditModeProvider).isEditing, isFalse); + } + }); + }); +} From 963db6d513d2926620797169b7b3812a5a0acaf7 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Fri, 10 Jul 2026 16:49:52 +0800 Subject: [PATCH 2/2] fix(dashboard): preserve applied layout changes on edit-mode exit (#1089) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #1089 review. Root cause: exitEditMode({bool save}) was an inverted-boolean trap — the !save branch persisted the pre-edit snapshot, so the settings-panel reset/preset path (which had already applied its change) called save:false and silently reverted the user's change. - Replace exitEditMode({bool save}) with explicit commitEditMode() (keep changes) and cancelEditMode() (revert to snapshot), sharing a private _exitEditMode({required bool revert}) that always resets state in a finally block (no stranded isEditing=true on save/restore failure) - Route _openLayoutSettings reset/preset path to commitEditMode() so the just-applied change is preserved; drop dead 'toggle_off' branch - enterEditMode: add re-entrant guard and claim isEditing before the await so an onExit firing in the async gap can never strand edit mode - route_usp_dashboard onExit: wrap cancelEditMode in try-catch and document the intentional silent-discard policy - Tighten DashboardEditState.layoutSnapshot to List>? - Delete unused lib/route/linksys_route.dart (dead code; active LinksysRoute lives in route_model.dart) - Add regression tests: commit-preserves-changes, re-entrant guard (W-1), cancel-during-async-gap (W-2) Co-Authored-By: Claude Opus 4.8 --- .../dashboard_edit_mode_provider.dart | 67 +++++++++----- .../views/usp_sliver_dashboard_view.dart | 26 +++--- lib/route/linksys_route.dart | 51 ----------- lib/route/route_usp_dashboard.dart | 24 +++-- .../dashboard_edit_mode_provider_test.dart | 90 +++++++++++++++---- 5 files changed, 151 insertions(+), 107 deletions(-) delete mode 100644 lib/route/linksys_route.dart diff --git a/lib/page/dashboard/providers/dashboard_edit_mode_provider.dart b/lib/page/dashboard/providers/dashboard_edit_mode_provider.dart index 1c07dfbd8..99e9570d8 100644 --- a/lib/page/dashboard/providers/dashboard_edit_mode_provider.dart +++ b/lib/page/dashboard/providers/dashboard_edit_mode_provider.dart @@ -14,7 +14,7 @@ final dashboardEditModeProvider = class DashboardEditState { final bool isEditing; - final List? layoutSnapshot; + final List>? layoutSnapshot; final UspLayoutPreferences? prefsSnapshot; const DashboardEditState({ @@ -25,7 +25,7 @@ class DashboardEditState { DashboardEditState copyWith({ bool? isEditing, - List? layoutSnapshot, + List>? layoutSnapshot, UspLayoutPreferences? prefsSnapshot, bool clearSnapshots = false, }) { @@ -45,8 +45,20 @@ class DashboardEditModeNotifier extends Notifier { /// Enter edit mode and capture snapshots for potential revert. Future enterEditMode() async { + // Re-entrant guard: a double-tap or gesture race must not re-capture the + // already-modified grid as the "original" snapshot. + if (state.isEditing) return; + + // Claim the edit slot BEFORE the await so a route guard (onExit) firing in + // the async gap always observes isEditing=true and reverts correctly. + state = const DashboardEditState(isEditing: true); + await ref.read(uspLayoutPreferencesProvider.notifier).initialized; + // If we were cancelled during the await (e.g. navigation away), bail out + // instead of resuming and stranding the controller in edit mode. + if (!state.isEditing) return; + final controller = ref.read(uspSliverDashboardControllerProvider); final layoutSnapshot = controller.exportLayout(); final prefsSnapshot = ref.read(uspLayoutPreferencesProvider); @@ -60,29 +72,40 @@ class DashboardEditModeNotifier extends Notifier { controller.setEditMode(true); } - /// Exit edit mode and optionally save or revert changes. - Future exitEditMode({bool save = true}) async { + /// Exit edit mode, keeping the current layout (changes are already persisted + /// on each drag/resize, so committing is just clearing the edit flag). + Future commitEditMode() => _exitEditMode(revert: false); + + /// Exit edit mode and revert the layout/prefs to the pre-edit snapshots + /// captured in [enterEditMode]. + Future cancelEditMode() => _exitEditMode(revert: true); + + /// Shared exit path for [commitEditMode] / [cancelEditMode]. + /// + /// The edit flag and snapshots are always cleared in the `finally` block so + /// that a failure in [DashboardController.saveLayout] / + /// [UspLayoutPreferencesNotifier.restoreSnapshot] can never leave the + /// dashboard stuck in edit mode with stale state. + Future _exitEditMode({required bool revert}) async { final controller = ref.read(uspSliverDashboardControllerProvider); - if (!save) { - // Revert to snapshots - if (state.layoutSnapshot != null) { - controller.importLayout(state.layoutSnapshot!); - await ref - .read(uspSliverDashboardControllerProvider.notifier) - .saveLayout(); - } - if (state.prefsSnapshot != null) { - await ref - .read(uspLayoutPreferencesProvider.notifier) - .restoreSnapshot(state.prefsSnapshot!); + try { + if (revert) { + if (state.layoutSnapshot != null) { + controller.importLayout(state.layoutSnapshot!); + await ref + .read(uspSliverDashboardControllerProvider.notifier) + .saveLayout(); + } + if (state.prefsSnapshot != null) { + await ref + .read(uspLayoutPreferencesProvider.notifier) + .restoreSnapshot(state.prefsSnapshot!); + } } + } finally { + controller.setEditMode(false); + state = const DashboardEditState(); } - - controller.setEditMode(false); - state = const DashboardEditState(); } - - /// Cancel edit mode (revert changes) - convenience method. - Future cancelEditMode() => exitEditMode(save: false); } diff --git a/lib/page/dashboard/views/usp_sliver_dashboard_view.dart b/lib/page/dashboard/views/usp_sliver_dashboard_view.dart index a83607db0..930df8912 100644 --- a/lib/page/dashboard/views/usp_sliver_dashboard_view.dart +++ b/lib/page/dashboard/views/usp_sliver_dashboard_view.dart @@ -112,8 +112,12 @@ class _UspSliverDashboardViewState await ref.read(dashboardEditModeProvider.notifier).enterEditMode(); } - void _exitEditMode({bool save = true}) async { - await ref.read(dashboardEditModeProvider.notifier).exitEditMode(save: save); + void _commitEditMode() async { + await ref.read(dashboardEditModeProvider.notifier).commitEditMode(); + } + + void _cancelEditMode() async { + await ref.read(dashboardEditModeProvider.notifier).cancelEditMode(); } @override @@ -204,12 +208,12 @@ class _UspSliverDashboardViewState AppGap.sm(), AppIconButton( icon: AppIcon.font(Icons.close), - onTap: () => _exitEditMode(save: false), + onTap: _cancelEditMode, ), AppGap.sm(), AppIconButton( icon: AppIcon.font(Icons.check), - onTap: () => _exitEditMode(save: true), + onTap: _commitEditMode, ), ] else ...[ AppIconButton( @@ -507,15 +511,11 @@ class _UspSliverDashboardViewState ), ); - if ((result == 'reset' || - result == 'toggle_off' || - result == 'preset_changed') && - mounted) { - // Exit edit mode without saving — the settings panel already applied - // the reset/preset change directly. - await ref - .read(dashboardEditModeProvider.notifier) - .exitEditMode(save: false); + if ((result == 'reset' || result == 'preset_changed') && mounted) { + // Commit (keep) the change — the settings panel already applied the + // reset/preset directly to the controller and prefs, so exiting must + // preserve it, not revert to the pre-edit snapshot. + await ref.read(dashboardEditModeProvider.notifier).commitEditMode(); } } diff --git a/lib/route/linksys_route.dart b/lib/route/linksys_route.dart deleted file mode 100644 index 1de202d4c..000000000 --- a/lib/route/linksys_route.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/widgets.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:privacy_gui/components/shortcuts/dialogs.dart'; -import 'package:privacy_gui/framework/feature_state.dart'; -import 'package:privacy_gui/route/route_model.dart'; - -class LinksysRoute extends GoRoute { - final LinksysRouteConfig? config; - - LinksysRoute({ - required super.path, - super.name, - super.builder, - super.routes, - this.config, - // New optional parameters for dirty checking - ProviderListenable? provider, - bool enableDirtyCheck = false, - FutureOr Function(BuildContext, GoRouterState)? onExit, - }) : super( - onExit: (context, state) async { - // First, run any custom onExit logic provided by the developer. - if (onExit != null) { - if (!await onExit(context, state)) { - return false; // Custom logic blocked navigation. - } - if (!context.mounted) return true; - } - - // If dirty checking is enabled and a provider is given... - if (enableDirtyCheck && provider != null) { - final container = ProviderScope.containerOf(context); - final currentState = container.read(provider); - - if (currentState.isDirty) { - final bool? confirmed = await showUnsavedAlert(context); - if (!context.mounted) return true; - if (confirmed != true) { - return false; // User cancelled, block navigation. - } - } - } - - // Allow navigation to proceed. - return true; - }, - ); -} diff --git a/lib/route/route_usp_dashboard.dart b/lib/route/route_usp_dashboard.dart index 00203bd31..3faf4da6b 100644 --- a/lib/route/route_usp_dashboard.dart +++ b/lib/route/route_usp_dashboard.dart @@ -20,14 +20,28 @@ final uspDashboardRoute = ShellRoute( return const UspDashboardView(); }, onExit: (context, state) async { - // Cancel edit mode when navigating away from dashboard (e.g., tab switch). - // This reverts any unsaved layout changes. + // Cancel edit mode when navigating away from dashboard (e.g., tab + // switch), reverting to the pre-edit snapshot. + // + // Intentional silent-discard policy: unlike the enableDirtyCheck routes + // below, the dashboard does NOT prompt with showUnsavedAlert. Layout + // edits are persisted on every drag/resize, so "cancel" means restoring + // the snapshot captured on edit-mode entry — there is no unsaved buffer + // to warn about, and a confirmation dialog on every tab switch would be + // noise. See #1037. final container = ProviderScope.containerOf(context); final editState = container.read(dashboardEditModeProvider); if (editState.isEditing) { - await container - .read(dashboardEditModeProvider.notifier) - .cancelEditMode(); + try { + await container + .read(dashboardEditModeProvider.notifier) + .cancelEditMode(); + } catch (e, s) { + // Never block navigation on a revert failure; cancelEditMode resets + // its own state in a finally block, so edit mode won't be stranded. + logger.e('[Route]: dashboard cancelEditMode failed on exit', + error: e, stackTrace: s); + } } return true; }, diff --git a/test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart b/test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart index 49fd63efa..149202c52 100644 --- a/test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart +++ b/test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart @@ -86,7 +86,7 @@ void main() { expect(state.prefsSnapshot, isNotNull); }); - test('exitEditMode(save: true) clears state without reverting', () async { + test('commitEditMode clears state without reverting', () async { final container = await createContainer(); addTearDown(container.dispose); @@ -95,9 +95,7 @@ void main() { container.read(dashboardEditModeProvider).layoutSnapshot; expect(snapshotBeforeExit, isNotNull); - await container - .read(dashboardEditModeProvider.notifier) - .exitEditMode(save: true); + await container.read(dashboardEditModeProvider.notifier).commitEditMode(); final state = container.read(dashboardEditModeProvider); expect(state.isEditing, isFalse); @@ -105,7 +103,34 @@ void main() { expect(state.prefsSnapshot, isNull); }); - test('exitEditMode(save: false) reverts layout to snapshot', () async { + test('commitEditMode preserves layout changes applied during edit', + () async { + // Regression for #1089 (PeterJhong): committing must NOT revert changes + // that were applied during edit mode (e.g. reset / preset change from the + // settings panel). Previously the settings path called the revert branch + // and undid the just-applied change. + final container = await createContainer(); + addTearDown(container.dispose); + + await container.read(dashboardEditModeProvider.notifier).enterEditMode(); + + final controller = container.read(uspSliverDashboardControllerProvider); + final originalCount = controller.exportLayout().length; + controller.removeItems(['stats_panel']); + final afterRemove = controller.exportLayout().length; + expect(afterRemove, lessThan(originalCount)); + + await container.read(dashboardEditModeProvider.notifier).commitEditMode(); + + // Change is kept, not reverted back to originalCount. + final finalCount = container + .read(uspSliverDashboardControllerProvider) + .exportLayout() + .length; + expect(finalCount, equals(afterRemove)); + }); + + test('cancelEditMode reverts layout to snapshot', () async { final container = await createContainer(); addTearDown(container.dispose); @@ -121,30 +146,40 @@ void main() { final afterRemove = controller.exportLayout().length; expect(afterRemove, lessThan(originalLayout)); - await container - .read(dashboardEditModeProvider.notifier) - .exitEditMode(save: false); + await container.read(dashboardEditModeProvider.notifier).cancelEditMode(); final restoredLayout = container.read(uspSliverDashboardControllerProvider).exportLayout(); expect(restoredLayout.length, equals(originalLayout)); + + final state = container.read(dashboardEditModeProvider); + expect(state.isEditing, isFalse); + expect(state.layoutSnapshot, isNull); + expect(state.prefsSnapshot, isNull); }); - test('cancelEditMode is equivalent to exitEditMode(save: false)', () async { + test( + 're-entrant enterEditMode does not overwrite the original snapshot ' + '(W-1)', () async { final container = await createContainer(); addTearDown(container.dispose); - await container.read(dashboardEditModeProvider.notifier).enterEditMode(); + final notifier = container.read(dashboardEditModeProvider.notifier); + await notifier.enterEditMode(); final controller = container.read(uspSliverDashboardControllerProvider); final originalCount = controller.exportLayout().length; - controller.removeItems(['stats_panel']); - await container.read(dashboardEditModeProvider.notifier).cancelEditMode(); + // Modify the grid, then re-enter (simulating a double-tap / gesture race). + controller.removeItems(['stats_panel']); + expect(controller.exportLayout().length, lessThan(originalCount)); - final state = container.read(dashboardEditModeProvider); - expect(state.isEditing, isFalse); + // Re-entrant call must be a no-op — it must NOT re-capture the modified + // grid as the new baseline. + await notifier.enterEditMode(); + // Cancel should restore the TRUE pre-edit baseline, not the mid-edit state. + await notifier.cancelEditMode(); final restoredCount = container .read(uspSliverDashboardControllerProvider) .exportLayout() @@ -152,7 +187,30 @@ void main() { expect(restoredCount, equals(originalCount)); }); - test('multiple enter/exit cycles work correctly', () async { + test( + 'cancel during enterEditMode async gap leaves controller not stuck ' + '(W-2)', () async { + final container = await createContainer(); + addTearDown(container.dispose); + + final notifier = container.read(dashboardEditModeProvider.notifier); + + // Start entering edit mode but do NOT await — isEditing is claimed + // synchronously before the internal await. + final enterFuture = notifier.enterEditMode(); + expect(container.read(dashboardEditModeProvider).isEditing, isTrue); + + // A navigation-away in the async gap cancels edit mode. + await notifier.cancelEditMode(); + expect(container.read(dashboardEditModeProvider).isEditing, isFalse); + + // enterEditMode resumes after its await; it must observe the cancel and + // bail out instead of stranding the controller in edit mode. + await enterFuture; + expect(container.read(dashboardEditModeProvider).isEditing, isFalse); + }); + + test('multiple enter/commit cycles work correctly', () async { final container = await createContainer(); addTearDown(container.dispose); @@ -164,7 +222,7 @@ void main() { await container .read(dashboardEditModeProvider.notifier) - .exitEditMode(save: true); + .commitEditMode(); expect(container.read(dashboardEditModeProvider).isEditing, isFalse); } });