container unsaved changes warning

This commit is contained in:
Fabian Freund
2026-04-20 14:23:23 +02:00
parent 14489cb375
commit a68c301f74
2 changed files with 283 additions and 189 deletions
@@ -0,0 +1,58 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
enum DiscardChangesChoice { discard, save }
Future<DiscardChangesChoice?> showDiscardChangesDialog(BuildContext context) {
return showDialog<DiscardChangesChoice?>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
icon: const Icon(Icons.warning),
title: const Text('Unsaved Changes'),
content: const Text(
'You have unsaved changes. Do you want to save them before leaving?',
),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(context, DiscardChangesChoice.discard);
},
child: const Text('Discard'),
),
TextButton(
onPressed: () {
Navigator.pop(context, DiscardChangesChoice.save);
},
child: const Text('Save'),
),
],
);
},
);
}
@@ -29,6 +29,7 @@ import 'package:weblibre/features/geckoview/features/tabs/data/models/container_
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/controllers/container_topic.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/controllers/container_topic.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/dialogs/delete_container_dialog.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/dialogs/delete_container_dialog.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/dialogs/discard_changes_dialog.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_sites.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_sites.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/color_picker_dialog.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/color_picker_dialog.dart';
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart'; import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
@@ -82,207 +83,242 @@ class ContainerEditScreen extends HookConsumerWidget {
final textController = useTextEditingController( final textController = useTextEditingController(
text: initialContainer.name, text: initialContainer.name,
); );
useListenable(textController);
return Scaffold( ContainerData buildContainer() {
appBar: AppBar( final name = textController.text.trim();
title: Text(switch (_mode) { return initialContainer.copyWith(
_DialogMode.create => 'New Container', name: name.isNotEmpty ? name : null,
_DialogMode.edit => 'Edit Container', color: selectedColor.value,
}), metadata: initialContainer.metadata.copyWith(
actions: [ contextualIdentity: contextualIdentity.value,
IconButton( useProxy: useProxy.value && contextualIdentity.value != null,
onPressed: () async { clearDataOnExit:
final name = textController.text.trim(); clearDataOnExit.value && contextualIdentity.value != null,
final container = initialContainer.copyWith( assignedSites: assignedSites.value,
name: name.isNotEmpty ? name : null, ),
color: selectedColor.value, );
metadata: initialContainer.metadata.copyWith( }
contextualIdentity: contextualIdentity.value,
useProxy: useProxy.value && contextualIdentity.value != null,
clearDataOnExit:
clearDataOnExit.value && contextualIdentity.value != null,
assignedSites: assignedSites.value,
),
);
switch (_mode) { Future<ContainerData> saveContainer() async {
case _DialogMode.create: final container = buildContainer();
await ref switch (_mode) {
.read(containerRepositoryProvider.notifier) case _DialogMode.create:
.addContainer(container); await ref
case _DialogMode.edit: .read(containerRepositoryProvider.notifier)
await ref .addContainer(container);
.read(containerRepositoryProvider.notifier) case _DialogMode.edit:
.replaceContainer(container); await ref
} .read(containerRepositoryProvider.notifier)
.replaceContainer(container);
}
return container;
}
if (context.mounted) { final container = buildContainer();
context.pop(container); //Empty copy to create comparable container with same type
} final comparison = initialContainer.copyWith();
},
icon: const Icon(Icons.check), return PopScope(
), canPop: container == comparison,
], onPopInvokedWithResult: (didPop, result) async {
), if (didPop) return;
body: SafeArea(
child: Padding( final choice = await showDiscardChangesDialog(context);
padding: const EdgeInsets.symmetric(horizontal: 16.0), if (choice == null) return;
child: Column(
children: [ switch (choice) {
Expanded( case DiscardChangesChoice.discard:
child: ListView( if (context.mounted) {
children: [ context.pop();
TextField( }
decoration: InputDecoration( case DiscardChangesChoice.save:
prefixIcon: Padding( final container = await saveContainer();
padding: const EdgeInsets.all(10.0), if (context.mounted) {
child: AnimatedContainer( context.pop(container);
duration: disableAnimations }
? Duration.zero }
: const Duration(milliseconds: 300), },
height: 24, child: Scaffold(
width: 24, appBar: AppBar(
decoration: BoxDecoration( title: Text(switch (_mode) {
shape: BoxShape.circle, _DialogMode.create => 'New Container',
color: ContainerColors.preview( _DialogMode.edit => 'Edit Container',
selectedColor.value, }),
actions: [
IconButton(
onPressed: () async {
final container = await saveContainer();
if (context.mounted) {
context.pop(container);
}
},
icon: const Icon(Icons.check),
),
],
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: [
Expanded(
child: ListView(
children: [
TextField(
decoration: InputDecoration(
prefixIcon: Padding(
padding: const EdgeInsets.all(10.0),
child: AnimatedContainer(
duration: disableAnimations
? Duration.zero
: const Duration(milliseconds: 300),
height: 24,
width: 24,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: ContainerColors.preview(
selectedColor.value,
),
), ),
), ),
), ),
), label: const Text('Name'),
label: const Text('Name'), suffixIcon: _buildMagicWandButton(
suffixIcon: _buildMagicWandButton( context,
context, ref,
ref, textController,
textController,
),
),
controller: textController,
),
TextButton.icon(
label: const Text('Select Color'),
icon: const Icon(Icons.colorize),
onPressed: () async {
final color = await showDialog<Color?>(
context: context,
builder: (context) =>
ColorPickerDialog(selectedColor.value),
);
if (color != null) {
selectedColor.value = color;
}
},
),
SwitchListTile.adaptive(
value: contextualIdentity.value != null,
title: const Text('Cookie Isolation'),
secondary: const Icon(MdiIcons.cookieLock),
contentPadding: EdgeInsets.zero,
onChanged: (_mode == _DialogMode.create)
? (value) {
contextualIdentity.value = value
? initialContainer
.metadata
.contextualIdentity ??
uuid.v4()
: null;
if (!value && useProxy.value) {
useProxy.value = false;
}
}
: null,
),
SwitchListTile.adaptive(
value: useProxy.value,
title: const Text('Use Tor™ Proxy'),
secondary: const Icon(TorIcons.onionAlt),
contentPadding: EdgeInsets.zero,
onChanged: switch (_mode) {
_DialogMode.create => (value) {
if (value && contextualIdentity.value == null) {
contextualIdentity.value =
initialContainer.metadata.contextualIdentity ??
uuid.v4();
}
useProxy.value = value;
},
_DialogMode.edit =>
(contextualIdentity.value != null)
? (value) {
useProxy.value = value;
}
: null,
},
),
SwitchListTile.adaptive(
value: clearDataOnExit.value,
title: const Text('Clear Data on Exit'),
subtitle: const Text(
'Clear cookies and site data when app closes',
),
secondary: const Icon(MdiIcons.databaseRemove),
contentPadding: EdgeInsets.zero,
onChanged: (contextualIdentity.value != null)
? (value) {
clearDataOnExit.value = value;
}
: null,
),
ListTile(
leading: const Icon(Icons.web),
title: const Text('Assigned Sites'),
trailing: const Icon(Icons.chevron_right),
contentPadding: EdgeInsets.zero,
onTap: () async {
final result = await showDialog<Set<Uri>>(
context: context,
builder: (context) => ContainerSitesScreen(
initialSites: assignedSites.value?.toSet() ?? {},
), ),
); ),
controller: textController,
if (result.isEmpty) {
assignedSites.value = null;
} else {
assignedSites.value = result!.toList();
}
},
),
],
),
),
if (_mode == _DialogMode.edit)
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
side: BorderSide(
color: Theme.of(context).colorScheme.error,
), ),
foregroundColor: Theme.of(context).colorScheme.error, TextButton.icon(
iconColor: Theme.of(context).colorScheme.error, label: const Text('Select Color'),
), icon: const Icon(Icons.colorize),
label: const Text('Delete'), onPressed: () async {
icon: const Icon(Icons.delete), final color = await showDialog<Color?>(
onPressed: () async { context: context,
final result = await showDeleteContainerDialog(context); builder: (context) =>
ColorPickerDialog(selectedColor.value),
);
if (result == true) { if (color != null) {
await ref selectedColor.value = color;
.read(containerRepositoryProvider.notifier) }
.deleteContainer(initialContainer.id); },
),
SwitchListTile.adaptive(
value: contextualIdentity.value != null,
title: const Text('Cookie Isolation'),
secondary: const Icon(MdiIcons.cookieLock),
contentPadding: EdgeInsets.zero,
onChanged: (_mode == _DialogMode.create)
? (value) {
contextualIdentity.value = value
? initialContainer
.metadata
.contextualIdentity ??
uuid.v4()
: null;
if (context.mounted) { if (!value && useProxy.value) {
context.pop(); useProxy.value = false;
} }
} }
}, : null,
),
SwitchListTile.adaptive(
value: useProxy.value,
title: const Text('Use Tor™ Proxy'),
secondary: const Icon(TorIcons.onionAlt),
contentPadding: EdgeInsets.zero,
onChanged: switch (_mode) {
_DialogMode.create => (value) {
if (value && contextualIdentity.value == null) {
contextualIdentity.value =
initialContainer
.metadata
.contextualIdentity ??
uuid.v4();
}
useProxy.value = value;
},
_DialogMode.edit =>
(contextualIdentity.value != null)
? (value) {
useProxy.value = value;
}
: null,
},
),
SwitchListTile.adaptive(
value: clearDataOnExit.value,
title: const Text('Clear Data on Exit'),
subtitle: const Text(
'Clear cookies and site data when app closes',
),
secondary: const Icon(MdiIcons.databaseRemove),
contentPadding: EdgeInsets.zero,
onChanged: (contextualIdentity.value != null)
? (value) {
clearDataOnExit.value = value;
}
: null,
),
ListTile(
leading: const Icon(Icons.web),
title: const Text('Assigned Sites'),
trailing: const Icon(Icons.chevron_right),
contentPadding: EdgeInsets.zero,
onTap: () async {
final result = await showDialog<Set<Uri>>(
context: context,
builder: (context) => ContainerSitesScreen(
initialSites: assignedSites.value?.toSet() ?? {},
),
);
if (result.isEmpty) {
assignedSites.value = null;
} else {
assignedSites.value = result!.toList();
}
},
),
],
), ),
), ),
], if (_mode == _DialogMode.edit)
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
side: BorderSide(
color: Theme.of(context).colorScheme.error,
),
foregroundColor: Theme.of(context).colorScheme.error,
iconColor: Theme.of(context).colorScheme.error,
),
label: const Text('Delete'),
icon: const Icon(Icons.delete),
onPressed: () async {
final result = await showDeleteContainerDialog(context);
if (result == true) {
await ref
.read(containerRepositoryProvider.notifier)
.deleteContainer(initialContainer.id);
if (context.mounted) {
context.pop();
}
}
},
),
),
],
),
), ),
), ),
), ),