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..99e9570d8 --- /dev/null +++ b/lib/page/dashboard/providers/dashboard_edit_mode_provider.dart @@ -0,0 +1,111 @@ +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 { + // 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); + + state = DashboardEditState( + isEditing: true, + layoutSnapshot: layoutSnapshot, + prefsSnapshot: prefsSnapshot, + ); + + controller.setEditMode(true); + } + + /// 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); + + 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(); + } + } +} diff --git a/lib/page/dashboard/views/usp_sliver_dashboard_view.dart b/lib/page/dashboard/views/usp_sliver_dashboard_view.dart index f405fd51d..930df8912 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,15 @@ 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!); - } - } + void _commitEditMode() async { + await ref.read(dashboardEditModeProvider.notifier).commitEditMode(); + } - setState(() { - _isEditMode = false; - _initialLayoutSnapshot = null; - _initialPrefsSnapshot = null; - }); - controller.setEditMode(false); + void _cancelEditMode() async { + await ref.read(dashboardEditModeProvider.notifier).cancelEditMode(); } @override @@ -205,6 +172,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 +180,7 @@ class _UspSliverDashboardViewState AppText.headlineSmall(loc(context).uspDashboard), Row( children: [ - if (_isEditMode) ...[ + if (isEditMode) ...[ AppIconButton( icon: AppIcon.font(Icons.auto_fix_high), onTap: () { @@ -240,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( @@ -339,6 +307,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 +329,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 +357,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( @@ -542,15 +511,11 @@ class _UspSliverDashboardViewState ), ); - if ((result == 'reset' || - result == 'toggle_off' || - result == 'preset_changed') && - mounted) { - setState(() { - _isEditMode = false; - _initialLayoutSnapshot = null; - _initialPrefsSnapshot = null; - }); + 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 365932305..3faf4da6b 100644 --- a/lib/route/route_usp_dashboard.dart +++ b/lib/route/route_usp_dashboard.dart @@ -19,6 +19,32 @@ final uspDashboardRoute = ShellRoute( }); return const UspDashboardView(); }, + onExit: (context, state) async { + // 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) { + 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; + }, ), 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..149202c52 --- /dev/null +++ b/test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart @@ -0,0 +1,230 @@ +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('commitEditMode 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).commitEditMode(); + + final state = container.read(dashboardEditModeProvider); + expect(state.isEditing, isFalse); + expect(state.layoutSnapshot, isNull); + expect(state.prefsSnapshot, isNull); + }); + + 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); + + 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).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( + 're-entrant enterEditMode does not overwrite the original snapshot ' + '(W-1)', () async { + final container = await createContainer(); + addTearDown(container.dispose); + + final notifier = container.read(dashboardEditModeProvider.notifier); + await notifier.enterEditMode(); + + final controller = container.read(uspSliverDashboardControllerProvider); + final originalCount = controller.exportLayout().length; + + // Modify the grid, then re-enter (simulating a double-tap / gesture race). + controller.removeItems(['stats_panel']); + expect(controller.exportLayout().length, lessThan(originalCount)); + + // 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() + .length; + expect(restoredCount, equals(originalCount)); + }); + + 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); + + for (int i = 0; i < 3; i++) { + await container + .read(dashboardEditModeProvider.notifier) + .enterEditMode(); + expect(container.read(dashboardEditModeProvider).isEditing, isTrue); + + await container + .read(dashboardEditModeProvider.notifier) + .commitEditMode(); + expect(container.read(dashboardEditModeProvider).isEditing, isFalse); + } + }); + }); +}