/// Gear that opens the settings sheet. library; import 'package:flutter/material.dart'; import '../models/supermarket.dart'; import '../services/app_settings.dart'; import '../services/backup_service.dart'; class SettingsButton extends StatelessWidget { const SettingsButton({super.key}); @override Widget build(BuildContext context) { return IconButton( tooltip: 'Settings', onPressed: () => showSettingsSheet(context), icon: const Icon(Icons.settings_outlined), ); } } Future showSettingsSheet(BuildContext context) { return showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, builder: (context) { return const _SettingsSheet(); }, ); } class _SettingsSheet extends StatefulWidget { const _SettingsSheet(); @override State<_SettingsSheet> createState() => _SettingsSheetState(); } class _SettingsSheetState extends State<_SettingsSheet> { bool _busy = false; Future _export(AppSettings settings) async { setState(() => _busy = true); try { final shared = await BackupService(settings.repository).exportBackup(); if (!mounted) return; if (shared) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Backup ready.')), ); } } catch (_) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Could not export the backup.')), ); } finally { if (mounted) setState(() => _busy = false); } } Future _import(AppSettings settings) async { final confirmed = await showDialog( context: context, builder: (context) { return AlertDialog( title: const Text('Import backup?'), content: const Text( 'This replaces all products, shopping trips, photos, and settings on this device.', ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.pop(context, true), child: const Text('Import'), ), ], ); }, ); if (confirmed != true || !mounted) return; setState(() => _busy = true); try { final imported = await BackupService(settings.repository).importBackup(); if (!mounted) return; if (imported) { await settings.reload(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Backup imported.')), ); } } catch (_) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Could not import this backup.')), ); } finally { if (mounted) setState(() => _busy = false); } } @override Widget build(BuildContext context) { final settings = SettingsScope.of(context); final bottom = MediaQuery.viewInsetsOf(context).bottom; return ListenableBuilder( listenable: settings, builder: (context, _) { return Padding( padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom), child: SizedBox( height: MediaQuery.sizeOf(context).height * 0.75, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text('Settings', style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 8), SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Dark mode'), value: settings.darkMode, onChanged: _busy ? null : settings.setDarkMode, ), Text('Backup', style: Theme.of(context).textTheme.titleMedium), Text( 'Save everything to a JSON file, or replace this device from a backup.', style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 8), Row( children: [ Expanded( child: OutlinedButton.icon( onPressed: _busy ? null : () => _export(settings), icon: const Icon(Icons.file_upload_outlined), label: const Text('Export'), ), ), const SizedBox(width: 8), Expanded( child: OutlinedButton.icon( onPressed: _busy ? null : () => _import(settings), icon: const Icon(Icons.file_download_outlined), label: const Text('Import'), ), ), ], ), if (_busy) ...[ const SizedBox(height: 8), const LinearProgressIndicator(), ], const SizedBox(height: 16), Text( 'Supermarkets', style: Theme.of(context).textTheme.titleMedium, ), Text( 'Name and color used as tags on products.', style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 8), Expanded( child: ListView( children: [ for (final store in settings.supermarkets) _StoreRow( store: store, onEdit: _busy ? () {} : () => _editStore(context, settings, store), onDelete: _busy || store.id == null ? null : () => settings.deleteSupermarket(store.id!), ), ], ), ), const SizedBox(height: 8), OutlinedButton.icon( onPressed: _busy ? null : () => _editStore(context, settings, null), icon: const Icon(Icons.add), label: const Text('Add supermarket'), ), ], ), ), ); }, ); } Future _editStore( BuildContext context, AppSettings settings, Supermarket? existing, ) async { final result = await showDialog( context: context, builder: (context) => _StoreEditDialog(store: existing), ); if (result == null) return; if (existing == null) { try { await settings.addSupermarket(result.name, result.colorValue); } catch (_) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('That supermarket already exists.')), ); } } } else { await settings.updateSupermarket( existing.copyWith(name: result.name, colorValue: result.colorValue), ); } } } class _StoreRow extends StatelessWidget { const _StoreRow({ required this.store, required this.onEdit, this.onDelete, }); final Supermarket store; final VoidCallback onEdit; final VoidCallback? onDelete; @override Widget build(BuildContext context) { final onColor = store.color.computeLuminance() > 0.55 ? Colors.black87 : Colors.white; return ListTile( contentPadding: EdgeInsets.zero, leading: CircleAvatar( backgroundColor: store.color, child: Text( store.name.isEmpty ? '?' : store.name.substring(0, 1), style: TextStyle(color: onColor, fontWeight: FontWeight.w700), ), ), title: Text(store.name), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( tooltip: 'Edit', onPressed: onEdit, icon: const Icon(Icons.edit_outlined), ), IconButton( tooltip: 'Delete', onPressed: onDelete, icon: const Icon(Icons.delete_outline), ), ], ), ); } } class _StoreEditDialog extends StatefulWidget { const _StoreEditDialog({this.store}); final Supermarket? store; @override State<_StoreEditDialog> createState() => _StoreEditDialogState(); } class _StoreEditDialogState extends State<_StoreEditDialog> { late final TextEditingController _name; late int _color; @override void initState() { super.initState(); _name = TextEditingController(text: widget.store?.name ?? ''); _color = widget.store?.colorValue ?? kStoreColorPalette.first; } @override void dispose() { _name.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AlertDialog( title: Text(widget.store == null ? 'Add supermarket' : 'Edit supermarket'), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: _name, textCapitalization: TextCapitalization.words, decoration: const InputDecoration(labelText: 'Name'), autofocus: widget.store == null, ), const SizedBox(height: 16), Align( alignment: Alignment.centerLeft, child: Text('Color', style: Theme.of(context).textTheme.titleMedium), ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ for (final value in kStoreColorPalette) GestureDetector( onTap: () => setState(() => _color = value), child: Container( width: 32, height: 32, decoration: BoxDecoration( color: Color(value), shape: BoxShape.circle, border: Border.all( color: _color == value ? Theme.of(context).colorScheme.onSurface : Colors.transparent, width: 2, ), ), ), ), ], ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), FilledButton( onPressed: () { final name = _name.text.trim(); if (name.isEmpty) return; Navigator.pop( context, Supermarket( id: widget.store?.id, name: name, colorValue: _color, sortOrder: widget.store?.sortOrder ?? 0, ), ); }, child: const Text('Save'), ), ], ); } }