/// Manage Home filter widgets: display, metrics, add/remove. library; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/home_filter_widget.dart'; import '../models/transaction_meta.dart'; import '../providers/bank_provider.dart'; import '../providers/preferences_provider.dart'; import '../providers/tx_meta_provider.dart'; import '../widgets/label_filter_dialog.dart'; Future showHomeWidgetsSheet(BuildContext context) { return showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, builder: (context) { return Padding( padding: EdgeInsets.only( bottom: MediaQuery.viewInsetsOf(context).bottom, ), child: const _HomeWidgetsSheet(), ); }, ); } Future composeHomeFilterWidget( BuildContext context, { TransactionFilterSelection? initialFilter, HomeFilterWidget? existing, bool skipFilterDialog = false, }) async { // Capture before any modal opens — the caller's element may be rebuilt. final bank = context.read(); final meta = context.read(); final navigator = Navigator.of(context); TransactionFilterSelection? filter = initialFilter ?? existing?.filter; if (!skipFilterDialog || filter == null) { filter = await showTransactionFilterDialog( navigator.context, available: meta.allLabels, transactions: bank.transactions, initial: filter ?? const TransactionFilterSelection(labelIds: {}), ); if (filter == null || !navigator.mounted) return null; } if (!navigator.mounted) return null; final draft = existing == null ? HomeFilterWidget.fromFilter(name: 'Filter', filter: filter) : existing.copyWith( labelIds: filter.labelIds.toList(growable: false), flow: filter.flow, amountRange: filter.amountRange, query: filter.query, monthOnly: filter.monthOnly, ); // Name is edited inside Widget options (no separate rename dialog). await WidgetsBinding.instance.endOfFrame; if (!navigator.mounted) return null; return _promptWidgetMetrics(navigator.context, draft); } Future _promptWidgetMetrics( BuildContext context, HomeFilterWidget draft, ) { return showDialog( context: context, useRootNavigator: true, builder: (context) => _WidgetOptionsDialog(draft: draft), ); } class _WidgetOptionsDialog extends StatefulWidget { const _WidgetOptionsDialog({required this.draft}); final HomeFilterWidget draft; @override State<_WidgetOptionsDialog> createState() => _WidgetOptionsDialogState(); } class _WidgetOptionsDialogState extends State<_WidgetOptionsDialog> { late HomeWidgetType _type; late String _iconId; late HomeWidgetChartStyle _chartStyle; late HomeBudgetCapMode _budgetCapMode; late int _budgetAverageMonths; late bool _showIn; late bool _showOut; late bool _showCount; late bool _halfWidth; late final TextEditingController _nameController; late final TextEditingController _amountController; @override void initState() { super.initState(); final draft = widget.draft; _type = draft.type; _iconId = draft.iconId; _chartStyle = draft.chartStyle; _budgetCapMode = draft.budgetCapMode; _budgetAverageMonths = draft.budgetAverageMonths; _showIn = draft.showAmountIn; _showOut = draft.showAmountOut; _showCount = draft.showCount; _halfWidth = draft.halfWidth; _nameController = TextEditingController(text: draft.name); final fixed = draft.budgetFixedAmount; _amountController = TextEditingController( text: fixed > 0 ? (fixed == fixed.roundToDouble() ? fixed.toStringAsFixed(0) : fixed.toStringAsFixed(2)) : '', ); } @override void dispose() { _nameController.dispose(); _amountController.dispose(); super.dispose(); } void _save() { final name = _nameController.text.trim(); if (name.isEmpty) return; if (_type == HomeWidgetType.stats && !_showIn && !_showOut && !_showCount) { return; } var fixed = widget.draft.budgetFixedAmount; if (_type == HomeWidgetType.budget && _budgetCapMode == HomeBudgetCapMode.fixed) { final parsed = double.tryParse( _amountController.text.trim().replaceAll(',', '.'), ); if (parsed == null || parsed <= 0) return; fixed = parsed; } Navigator.of(context).pop( widget.draft.copyWith( name: name, type: _type, iconId: _iconId, chartStyle: _chartStyle, budgetCapMode: _budgetCapMode, budgetFixedAmount: fixed, budgetAverageMonths: _budgetAverageMonths, showAmountIn: _showIn, showAmountOut: _showOut, showCount: _showCount, halfWidth: _halfWidth, ), ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return AlertDialog( title: const Text('Widget options'), content: SizedBox( width: double.maxFinite, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextField( controller: _nameController, textCapitalization: TextCapitalization.sentences, decoration: const InputDecoration( labelText: 'Name', border: OutlineInputBorder(), isDense: true, ), ), const SizedBox(height: 16), Text('Type', style: theme.textTheme.titleSmall), const SizedBox(height: 8), SegmentedButton( showSelectedIcon: false, segments: const [ ButtonSegment( value: HomeWidgetType.stats, label: Text('Stats'), icon: Icon(Icons.analytics_outlined), ), ButtonSegment( value: HomeWidgetType.budget, label: Text('Budget'), icon: Icon(Icons.savings_outlined), ), ], selected: {_type}, onSelectionChanged: (value) { setState(() => _type = value.first); }, ), const SizedBox(height: 16), Text('Icon', style: theme.textTheme.titleSmall), const SizedBox(height: 8), Wrap( spacing: 6, runSpacing: 6, children: [ for (final entry in HomeWidgetIcons.all) ChoiceChip( label: Icon(entry.$2, size: 18), selected: _iconId == entry.$1, onSelected: (_) => setState(() => _iconId = entry.$1), tooltip: entry.$3, visualDensity: VisualDensity.compact, padding: const EdgeInsets.all(4), ), ], ), const SizedBox(height: 12), SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Half width'), subtitle: const Text('Share the row with another widget'), value: _halfWidth, onChanged: (value) => setState(() => _halfWidth = value), ), if (_type == HomeWidgetType.stats) ...[ const Divider(), CheckboxListTile( contentPadding: EdgeInsets.zero, title: const Text('Amount in'), value: _showIn, onChanged: (value) => setState(() => _showIn = value ?? false), ), CheckboxListTile( contentPadding: EdgeInsets.zero, title: const Text('Spent'), value: _showOut, onChanged: (value) => setState(() => _showOut = value ?? false), ), CheckboxListTile( contentPadding: EdgeInsets.zero, title: const Text('Number of transactions'), value: _showCount, onChanged: (value) => setState(() => _showCount = value ?? false), ), ] else ...[ const Divider(), Text('Budget cap', style: theme.textTheme.titleSmall), const SizedBox(height: 8), SegmentedButton( showSelectedIcon: false, segments: const [ ButtonSegment( value: HomeBudgetCapMode.fixed, label: Text('Fixed'), ), ButtonSegment( value: HomeBudgetCapMode.averageMonths, label: Text('Average'), ), ], selected: {_budgetCapMode}, onSelectionChanged: (value) { setState(() => _budgetCapMode = value.first); }, ), const SizedBox(height: 12), if (_budgetCapMode == HomeBudgetCapMode.fixed) TextField( controller: _amountController, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), decoration: const InputDecoration( labelText: 'Budget amount', hintText: 'e.g. 500', border: OutlineInputBorder(), isDense: true, ), ) else DropdownButtonFormField( key: ValueKey(_budgetAverageMonths), initialValue: _budgetAverageMonths, isExpanded: true, decoration: const InputDecoration( labelText: 'Average of last N months', border: OutlineInputBorder(), isDense: true, ), items: [ for (final n in const [1, 2, 3, 6, 12, 24]) DropdownMenuItem( value: n, child: Text('$n months'), ), ], onChanged: (value) { if (value == null) return; setState(() => _budgetAverageMonths = value); }, ), const SizedBox(height: 16), Text('Chart', style: theme.textTheme.titleSmall), const SizedBox(height: 8), Wrap( spacing: 8, children: [ for (final entry in const [ (HomeWidgetChartStyle.bar, Icons.linear_scale, 'Bar'), ( HomeWidgetChartStyle.pie, Icons.pie_chart_outline, 'Pie' ), (HomeWidgetChartStyle.ring, Icons.donut_large, 'Ring'), ]) ChoiceChip( avatar: Icon(entry.$2, size: 18), label: Text(entry.$3), selected: _chartStyle == entry.$1, onSelected: (_) => setState(() => _chartStyle = entry.$1), ), ], ), const SizedBox(height: 8), Text( 'Progress uses this month’s net (|in − out|) for the ' 'filter toward the cap.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: _save, child: const Text('Save'), ), ], ); } } class _HomeWidgetsSheet extends StatelessWidget { const _HomeWidgetsSheet(); @override Widget build(BuildContext context) { final prefs = context.watch(); final theme = Theme.of(context); final widgets = prefs.homeFilterWidgets; 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('Home layout', style: theme.textTheme.titleLarge), const SizedBox(height: 4), Text( 'Choose which built-in sections and filter widgets appear ' 'on Home. Check Display to show an item.', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(8, 0, 8, 0), child: Column( children: [ CheckboxListTile( value: prefs.homeBuiltInSections.showTransactions, onChanged: (value) { if (value == null) return; prefs.setHomeShowTransactions(value); }, title: const Text('Transactions'), subtitle: const Text('Total transaction count'), controlAffinity: ListTileControlAffinity.leading, ), CheckboxListTile( value: prefs.homeBuiltInSections.showCategorized, onChanged: (value) { if (value == null) return; prefs.setHomeShowCategorized(value); }, title: const Text('Categorized'), subtitle: const Text('Labeled transaction count'), controlAffinity: ListTileControlAffinity.leading, ), CheckboxListTile( value: prefs.homeBuiltInSections.showRecent, onChanged: (value) { if (value == null) return; prefs.setHomeShowRecent(value); }, title: const Text('Recent transactions'), subtitle: const Text('Latest activity list'), controlAffinity: ListTileControlAffinity.leading, ), ], ), ), const Divider(height: 1), Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: OutlinedButton.icon( onPressed: () async { final preferences = context.read(); final created = await composeHomeFilterWidget(context); if (created == null) return; await preferences.addHomeFilterWidget(created); }, icon: const Icon(Icons.add), label: const Text('Add filter widget'), ), ), const Divider(height: 1), Expanded( child: widgets.isEmpty ? Center( child: Padding( padding: const EdgeInsets.all(24), child: Text( 'No filter widgets yet. Add one here, or save a ' 'filter from Transactions.', textAlign: TextAlign.center, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), ) : ListView.separated( padding: const EdgeInsets.fromLTRB(8, 8, 8, 24), itemCount: widgets.length, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { final widgetItem = widgets[index]; return CheckboxListTile( value: widgetItem.display, onChanged: (value) { if (value == null) return; prefs.setHomeFilterWidgetDisplay( widgetItem.id, value, ); }, secondary: PopupMenuButton( tooltip: 'Widget actions', onSelected: (value) async { if (value == 'edit') { final preferences = context.read(); final updated = await composeHomeFilterWidget( context, existing: widgetItem, ); if (updated == null) return; await preferences .updateHomeFilterWidget(updated); } else if (value == 'delete') { await prefs .removeHomeFilterWidget(widgetItem.id); } }, itemBuilder: (context) => const [ PopupMenuItem( value: 'edit', child: Text('Edit'), ), PopupMenuItem( value: 'delete', child: Text('Delete'), ), ], ), title: Text(widgetItem.name), subtitle: Text( '${_subtitle(widgetItem)}\n' 'Display on Home', ), isThreeLine: true, controlAffinity: ListTileControlAffinity.leading, ); }, ), ), ], ), ), ); } static String _subtitle(HomeFilterWidget w) { final parts = [ w.type == HomeWidgetType.budget ? 'Budget' : 'Stats', if (w.type == HomeWidgetType.budget) w.budgetCapMode == HomeBudgetCapMode.fixed ? 'Fixed' : 'Avg ${w.budgetAverageMonths}mo', if (w.type == HomeWidgetType.budget) w.chartStyle.name, if (w.monthOnly) 'This month', if (w.flow != TxFlowFilter.all) w.flow.name, if (w.labelIds.isNotEmpty) '${w.labelIds.length} labels', if (w.query.trim().isNotEmpty) '“${w.query.trim()}”', if (w.type == HomeWidgetType.stats && w.showAmountIn) 'In', if (w.type == HomeWidgetType.stats && w.showAmountOut) 'Spent', if (w.type == HomeWidgetType.stats && w.showCount) 'Count', if (w.halfWidth) 'Half', ]; return parts.join(' · '); } }