widgets looking ok

This commit is contained in:
davmar
2026-09-10 18:51:35 +02:00
parent ecc5185d3b
commit 55a13ee650
4 changed files with 807 additions and 395 deletions
+287 -265
View File
@@ -10,7 +10,6 @@ import '../providers/bank_provider.dart';
import '../providers/preferences_provider.dart';
import '../providers/tx_meta_provider.dart';
import '../widgets/label_filter_dialog.dart';
import '../widgets/overview_favourites_sheet.dart' show promptFavouriteName;
Future<void> showHomeWidgetsSheet(BuildContext context) {
return showModalBottomSheet<void>(
@@ -50,22 +49,11 @@ Future<HomeFilterWidget?> composeHomeFilterWidget(
if (filter == null || !navigator.mounted) return null;
}
if (!navigator.mounted) return null;
final name = await promptFavouriteName(
navigator.context,
initial: existing?.name ?? '',
title: existing == null ? 'Name this widget' : 'Rename widget',
);
if (name == null || !navigator.mounted) return null;
// Let the name dialog finish popping before presenting options.
await WidgetsBinding.instance.endOfFrame;
if (!navigator.mounted) return null;
final draft = existing == null
? HomeFilterWidget.fromFilter(name: name, filter: filter)
? HomeFilterWidget.fromFilter(name: 'Filter', filter: filter)
: existing.copyWith(
name: name,
labelIds: filter.labelIds.toList(growable: false),
flow: filter.flow,
amountRange: filter.amountRange,
@@ -73,6 +61,9 @@ Future<HomeFilterWidget?> composeHomeFilterWidget(
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);
}
@@ -80,265 +71,296 @@ Future<HomeFilterWidget?> _promptWidgetMetrics(
BuildContext context,
HomeFilterWidget draft,
) {
var type = draft.type;
var iconId = draft.iconId;
var chartStyle = draft.chartStyle;
var budgetCapMode = draft.budgetCapMode;
var budgetFixedAmount = draft.budgetFixedAmount;
var budgetAverageMonths = draft.budgetAverageMonths;
var showIn = draft.showAmountIn;
var showOut = draft.showAmountOut;
var showCount = draft.showCount;
var halfWidth = draft.halfWidth;
final amountController = TextEditingController(
text: budgetFixedAmount > 0
? (budgetFixedAmount == budgetFixedAmount.roundToDouble()
? budgetFixedAmount.toStringAsFixed(0)
: budgetFixedAmount.toStringAsFixed(2))
: '',
);
return showDialog<HomeFilterWidget>(
context: context,
useRootNavigator: true,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
final theme = Theme.of(context);
return AlertDialog(
title: const Text('Widget options'),
content: SizedBox(
width: 360,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Type', style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
SegmentedButton<HomeWidgetType>(
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('Amount out'),
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>(
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
Row(
children: [
Expanded(
child: Text(
'Average |net| of last',
style: theme.textTheme.bodyMedium,
),
),
SizedBox(
width: 88,
child: DropdownButtonFormField<int>(
initialValue: budgetAverageMonths,
decoration: const InputDecoration(
border: OutlineInputBorder(),
isDense: true,
suffixText: 'mo',
),
items: [
for (final n in const [1, 2, 3, 6, 12, 24])
DropdownMenuItem(
value: n,
child: Text('$n'),
),
],
onChanged: (value) {
if (value == null) return;
setState(
() => budgetAverageMonths = value,
);
},
),
),
],
),
const SizedBox(height: 16),
Text('Chart', style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
SegmentedButton<HomeWidgetChartStyle>(
segments: const [
ButtonSegment(
value: HomeWidgetChartStyle.bar,
label: Text('Bar'),
icon: Icon(Icons.linear_scale),
),
ButtonSegment(
value: HomeWidgetChartStyle.pie,
label: Text('Pie'),
icon: Icon(Icons.pie_chart_outline),
),
ButtonSegment(
value: HomeWidgetChartStyle.ring,
label: Text('Ring'),
icon: Icon(Icons.donut_large),
),
],
selected: {chartStyle},
onSelectionChanged: (value) {
setState(() => chartStyle = value.first);
},
),
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,
),
),
],
],
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,
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
if (type == HomeWidgetType.stats &&
!showIn &&
!showOut &&
!showCount) {
return;
}
var fixed = 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(
draft.copyWith(
type: type,
iconId: iconId,
chartStyle: chartStyle,
budgetCapMode: budgetCapMode,
budgetFixedAmount: fixed,
budgetAverageMonths: budgetAverageMonths,
showAmountIn: showIn,
showAmountOut: showOut,
showCount: showCount,
halfWidth: halfWidth,
),
);
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);
},
child: const Text('Save'),
),
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,
),
),
],
],
);
},
);
},
).whenComplete(amountController.dispose);
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _save,
child: const Text('Save'),
),
],
);
}
}
class _HomeWidgetsSheet extends StatelessWidget {
@@ -517,7 +539,7 @@ class _HomeWidgetsSheet extends StatelessWidget {
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) 'Out',
if (w.type == HomeWidgetType.stats && w.showAmountOut) 'Spent',
if (w.type == HomeWidgetType.stats && w.showCount) 'Count',
if (w.halfWidth) 'Half',
];