Files
ingtrakapp/lib/widgets/home_widgets_sheet.dart
T

550 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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<void> showHomeWidgetsSheet(BuildContext context) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(context).bottom,
),
child: const _HomeWidgetsSheet(),
);
},
);
}
Future<HomeFilterWidget?> 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<BankController>();
final meta = context.read<TxMetaController>();
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),
labelMatch: filter.labelMatch,
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<HomeFilterWidget?> _promptWidgetMetrics(
BuildContext context,
HomeFilterWidget draft,
) {
return showDialog<HomeFilterWidget>(
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<HomeWidgetType>(
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<HomeBudgetCapMode>(
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<int>(
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<PreferencesController>();
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<PreferencesController>();
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<String>(
tooltip: 'Widget actions',
onSelected: (value) async {
if (value == 'edit') {
final preferences =
context.read<PreferencesController>();
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 = <String>[
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(' · ');
}
}