Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions lib/page/dashboard/providers/dashboard_edit_mode_provider.dart
Original file line number Diff line number Diff line change
@@ -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, DashboardEditState>(
DashboardEditModeNotifier.new,
);

class DashboardEditState {
final bool isEditing;
final List<Map<String, dynamic>>? layoutSnapshot;
final UspLayoutPreferences? prefsSnapshot;

const DashboardEditState({
this.isEditing = false,
this.layoutSnapshot,
this.prefsSnapshot,
});

DashboardEditState copyWith({
bool? isEditing,
List<Map<String, dynamic>>? 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<DashboardEditState> {
@override
DashboardEditState build() => const DashboardEditState();

/// Enter edit mode and capture snapshots for potential revert.
Future<void> 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<void> commitEditMode() => _exitEditMode(revert: false);

/// Exit edit mode and revert the layout/prefs to the pre-edit snapshots
/// captured in [enterEditMode].
Future<void> 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<void> _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();
}
}
}
79 changes: 22 additions & 57 deletions lib/page/dashboard/views/usp_sliver_dashboard_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -42,9 +42,6 @@ class UspSliverDashboardView extends ConsumerStatefulWidget {

class _UspSliverDashboardViewState
extends ConsumerState<UspSliverDashboardView> {
bool _isEditMode = false;
List<dynamic>? _initialLayoutSnapshot;
UspLayoutPreferences? _initialPrefsSnapshot;
bool _presetDialogShown = false;

@override
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -205,14 +172,15 @@ class _UspSliverDashboardViewState

Widget _buildHeader(BuildContext context) {
final isRemoteMode = GlobalConfig.remote.isActive;
final isEditMode = ref.watch(dashboardEditModeProvider).isEditing;

return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
AppText.headlineSmall(loc(context).uspDashboard),
Row(
children: [
if (_isEditMode) ...[
if (isEditMode) ...[
AppIconButton(
icon: AppIcon.font(Icons.auto_fix_high),
onTap: () {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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();

Expand All @@ -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);
Expand All @@ -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(
Expand Down Expand Up @@ -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();
}
}

Expand Down
51 changes: 0 additions & 51 deletions lib/route/linksys_route.dart

This file was deleted.

26 changes: 26 additions & 0 deletions lib/route/route_usp_dashboard.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions lib/route/router_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading