Files
ingtrakapp/lib/widgets/overview_favourites_sheet.dart
2026-09-10 16:25:24 +02:00

492 lines
16 KiB
Dart

/// 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<void> showOverviewFavouritesSheet(
BuildContext context, {
required OverviewFavourite? Function() currentFilterSnapshot,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(context).bottom,
),
child: _OverviewFavouritesSheet(
currentFilterSnapshot: currentFilterSnapshot,
),
);
},
);
}
Future<String?> promptFavouriteName(
BuildContext context, {
String initial = '',
String title = 'Name this favourite',
}) {
return showDialog<String>(
context: context,
useRootNavigator: true,
builder: (dialogContext) {
return _FavouriteNameDialog(
initial: initial,
title: title,
);
},
);
}
/// Edit an existing favourite (name, metric, period, labels, display).
Future<void> showEditOverviewFavouriteSheet(
BuildContext context, {
required OverviewFavourite favourite,
}) {
return showModalBottomSheet<void>(
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<void> _saveCurrent(BuildContext context) async {
final prefs = context.read<PreferencesController>();
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<void>.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<PreferencesController>();
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<void> _persist(OverviewFavourite next) async {
setState(() => _draft = next);
await context.read<PreferencesController>().updateOverviewFavourite(next);
}
Future<void> _editLabels() async {
final meta = context.read<TxMetaController>();
final available = meta.allLabels;
final initial = <String>{
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 <String>[]
: 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<PreferencesController>();
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<OverviewMetricMode>(
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<OverviewPeriodMode>(
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),
),
),
],
),
),
);
}
}