new favourites feature
This commit is contained in:
@@ -0,0 +1,491 @@
|
||||
/// 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user