/// Manage Overview favourites: display toggles, rename, delete, save current. library; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/overview_favourite.dart'; import '../models/transaction_meta.dart'; import '../providers/preferences_provider.dart'; import '../providers/tx_meta_provider.dart'; import 'label_filter_dialog.dart'; Future showOverviewFavouritesSheet( BuildContext context, { required OverviewFavourite? Function() currentFilterSnapshot, }) { return showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, builder: (context) { return Padding( padding: EdgeInsets.only( bottom: MediaQuery.viewInsetsOf(context).bottom, ), child: _OverviewFavouritesSheet( currentFilterSnapshot: currentFilterSnapshot, ), ); }, ); } Future promptFavouriteName( BuildContext context, { String initial = '', String title = 'Name this favourite', }) { return showDialog( context: context, useRootNavigator: true, builder: (dialogContext) { return _FavouriteNameDialog( initial: initial, title: title, ); }, ); } /// Edit an existing favourite (name, metric, period, labels, display). Future showEditOverviewFavouriteSheet( BuildContext context, { required OverviewFavourite favourite, }) { return showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, builder: (context) { return Padding( padding: EdgeInsets.only( bottom: MediaQuery.viewInsetsOf(context).bottom, ), child: _EditOverviewFavouriteSheet(favourite: favourite), ); }, ); } class _FavouriteNameDialog extends StatefulWidget { const _FavouriteNameDialog({ required this.initial, required this.title, }); final String initial; final String title; @override State<_FavouriteNameDialog> createState() => _FavouriteNameDialogState(); } class _FavouriteNameDialogState extends State<_FavouriteNameDialog> { late final TextEditingController _controller; @override void initState() { super.initState(); _controller = TextEditingController(text: widget.initial); } @override void dispose() { _controller.dispose(); super.dispose(); } void _submit() { final name = _controller.text.trim(); if (name.isEmpty) return; Navigator.of(context).pop(name); } @override Widget build(BuildContext context) { return AlertDialog( title: Text(widget.title), content: TextField( controller: _controller, autofocus: true, textCapitalization: TextCapitalization.sentences, decoration: const InputDecoration( labelText: 'Name', hintText: 'e.g. Groceries this year', ), onSubmitted: (_) => _submit(), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: _submit, child: const Text('Save'), ), ], ); } } class _OverviewFavouritesSheet extends StatelessWidget { const _OverviewFavouritesSheet({required this.currentFilterSnapshot}); final OverviewFavourite? Function() currentFilterSnapshot; Future _saveCurrent(BuildContext context) async { final prefs = context.read(); final snap = currentFilterSnapshot(); if (snap == null) return; final name = await promptFavouriteName(context); if (name == null || !context.mounted) return; // Let the dialog route + IME finish tearing down before notifying // dependents (avoids wrong-build-scope / _dependents asserts). await Future.delayed(Duration.zero); if (!context.mounted) return; await prefs.addOverviewFavourite(snap.copyWith(name: name)); if (!context.mounted) return; final messenger = ScaffoldMessenger.maybeOf(context); messenger?.showSnackBar(SnackBar(content: Text('Saved “$name”'))); } @override Widget build(BuildContext context) { final prefs = context.watch(); final theme = Theme.of(context); final favourites = prefs.overviewFavourites; return SafeArea( child: ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.sizeOf(context).height * 0.72, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 4, 20, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Favourites', style: theme.textTheme.titleLarge), const SizedBox(height: 4), Text( 'Check Display for each favourite you want on the Favourites ' 'view — all checked ones show as charts. Save as many filters ' 'as you like.', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: OutlinedButton.icon( onPressed: () => _saveCurrent(context), icon: const Icon(Icons.star_outline), label: const Text('Save current filter'), ), ), const Divider(height: 1), Expanded( child: favourites.isEmpty ? Center( child: Padding( padding: const EdgeInsets.all(24), child: Text( 'No favourites yet. Set up Flow or Labels on Overview, ' 'then save the current filter.', textAlign: TextAlign.center, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), ) : ListView.separated( padding: const EdgeInsets.fromLTRB(8, 8, 8, 24), itemCount: favourites.length, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { final fav = favourites[index]; return CheckboxListTile( value: fav.display, onChanged: (value) { if (value == null) return; prefs.setOverviewFavouriteDisplay(fav.id, value); }, secondary: IconButton( tooltip: 'Delete', onPressed: () => prefs.removeOverviewFavourite(fav.id), icon: const Icon(Icons.delete_outline), ), title: Text(fav.name), subtitle: Text( '${_subtitle(fav)}\n' 'Display on Favourites view', ), isThreeLine: true, controlAffinity: ListTileControlAffinity.leading, ); }, ), ), ], ), ), ); } static String _subtitle(OverviewFavourite fav) { final metric = fav.metric == OverviewMetricMode.flow ? 'Flow' : 'Labels'; final period = fav.period == OverviewPeriodMode.monthly ? 'Month' : 'Year'; if (fav.metric == OverviewMetricMode.flow) { return '$metric · $period'; } final flow = switch (fav.flow) { TxFlowFilter.all => 'All', TxFlowFilter.income => 'Income', TxFlowFilter.expense => 'Expenses', }; final labels = fav.labelIds.isEmpty ? 'All labels' : '${fav.labelIds.length} labels'; return '$metric · $period · $flow · $labels'; } } class _EditOverviewFavouriteSheet extends StatefulWidget { const _EditOverviewFavouriteSheet({required this.favourite}); final OverviewFavourite favourite; @override State<_EditOverviewFavouriteSheet> createState() => _EditOverviewFavouriteSheetState(); } class _EditOverviewFavouriteSheetState extends State<_EditOverviewFavouriteSheet> { late OverviewFavourite _draft; late final TextEditingController _nameController; static const _segmentStyle = ButtonStyle( visualDensity: VisualDensity(horizontal: -2, vertical: -2), tapTargetSize: MaterialTapTargetSize.shrinkWrap, padding: WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 12)), minimumSize: WidgetStatePropertyAll(Size(0, 36)), ); @override void initState() { super.initState(); _draft = widget.favourite; _nameController = TextEditingController(text: _draft.name); } @override void dispose() { _nameController.dispose(); super.dispose(); } Future _persist(OverviewFavourite next) async { setState(() => _draft = next); await context.read().updateOverviewFavourite(next); } Future _editLabels() async { final meta = context.read(); final available = meta.allLabels; final initial = { if (_draft.labelIds.isEmpty) ...available.map((l) => l.id) else ...[ for (final id in _draft.labelIds) if (id == kUncategorizedFilterId || available.any((l) => l.id == id)) id, ], }; final result = await showLabelFilterDialog( context, available: available, initiallySelected: initial, title: 'Labels on chart', subtitle: 'Choose which labels appear on this favourite.', allowMove: true, includeUncategorized: true, includeFlow: true, initialFlow: _draft.flow, includeBarStyle: true, initialBarStyle: _draft.barStyle, includeInvertChart: true, initialInvertChart: _draft.invertExpensePerspective, ); if (result == null || !mounted) return; final allIds = available.map((l) => l.id).toSet(); final hasUncategorized = result.labelIds.contains(kUncategorizedFilterId); final realIds = result.labelIds .where((id) => id != kUncategorizedFilterId) .toSet(); final allRealSelected = realIds.length == allIds.length && realIds.containsAll(allIds); final labelIds = !hasUncategorized && (realIds.isEmpty || allRealSelected) ? const [] : result.labelIds.toList(growable: false); await _persist( _draft.copyWith( metric: OverviewMetricMode.labels, labelIds: labelIds, flow: result.flow, barStyle: result.barStyle, invertExpensePerspective: result.flow == TxFlowFilter.all && result.invertExpensePerspective, ), ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final prefs = context.read(); return SafeArea( child: ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.sizeOf(context).height * 0.85, ), child: ListView( padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [ Text('Edit favourite', style: theme.textTheme.titleLarge), const SizedBox(height: 16), TextField( controller: _nameController, textCapitalization: TextCapitalization.sentences, decoration: const InputDecoration( labelText: 'Name', ), textInputAction: TextInputAction.done, onEditingComplete: () { FocusScope.of(context).unfocus(); }, ), const SizedBox(height: 20), Text('Chart type', style: theme.textTheme.titleSmall), const SizedBox(height: 8), SegmentedButton( showSelectedIcon: false, style: _segmentStyle, segments: const [ ButtonSegment( value: OverviewMetricMode.flow, label: Text('Flow'), ), ButtonSegment( value: OverviewMetricMode.labels, label: Text('Labels'), ), ], selected: {_draft.metric}, onSelectionChanged: (selection) { _persist(_draft.copyWith(metric: selection.first)); }, ), const SizedBox(height: 16), Text('Period', style: theme.textTheme.titleSmall), const SizedBox(height: 8), SegmentedButton( showSelectedIcon: false, style: _segmentStyle, segments: const [ ButtonSegment( value: OverviewPeriodMode.monthly, label: Text('Month'), ), ButtonSegment( value: OverviewPeriodMode.yearly, label: Text('Year'), ), ], selected: {_draft.period}, onSelectionChanged: (selection) { _persist(_draft.copyWith(period: selection.first)); }, ), const SizedBox(height: 8), SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Display on Favourites view'), value: _draft.display, onChanged: (value) { _persist(_draft.copyWith(display: value)); }, ), if (_draft.metric == OverviewMetricMode.labels) ...[ const SizedBox(height: 8), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.filter_list), title: const Text('Label filter'), subtitle: Text( _draft.labelIds.isEmpty ? 'All labels · ${_draft.flow.name}' : '${_draft.labelIds.length} labels · ${_draft.flow.name}', ), trailing: const Icon(Icons.chevron_right), onTap: _editLabels, ), ], const SizedBox(height: 16), FilledButton( onPressed: () async { final name = _nameController.text.trim(); final next = name.isEmpty ? _draft : _draft.copyWith(name: name); await prefs.updateOverviewFavourite(next); if (!context.mounted) return; Navigator.of(context).pop(); }, child: const Text('Done'), ), const SizedBox(height: 8), TextButton.icon( onPressed: () async { await prefs.removeOverviewFavourite(_draft.id); if (!context.mounted) return; Navigator.of(context).pop(); }, icon: Icon( Icons.delete_outline, color: theme.colorScheme.error, ), label: Text( 'Delete favourite', style: TextStyle(color: theme.colorScheme.error), ), ), ], ), ), ); } }