492 lines
16 KiB
Dart
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),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|