ing connected, makign some features
This commit is contained in:
@@ -4,7 +4,6 @@ library;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/transaction_meta.dart';
|
||||
import '../providers/bank_provider.dart';
|
||||
import '../providers/tx_meta_provider.dart';
|
||||
import '../utils/connect_bank.dart';
|
||||
@@ -96,7 +95,7 @@ class _HomeBody extends StatelessWidget {
|
||||
final meta = context.watch<TxMetaController>();
|
||||
final recent = bank.transactions.take(5).toList(growable: false);
|
||||
final categorized = bank.transactions
|
||||
.where((tx) => meta.metaFor(tx).hasCategory)
|
||||
.where((tx) => meta.metaFor(tx).hasLabel)
|
||||
.length;
|
||||
|
||||
return ListView(
|
||||
@@ -207,10 +206,17 @@ class _HomeBody extends StatelessWidget {
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
leading: Icon(
|
||||
meta.metaFor(tx).category.icon,
|
||||
color: meta.metaFor(tx).category.color,
|
||||
),
|
||||
leading: Builder(
|
||||
builder: (context) {
|
||||
final labels =
|
||||
meta.labelsFor(meta.metaFor(tx));
|
||||
final label = labels.isEmpty ? null : labels.first;
|
||||
return Icon(
|
||||
label?.icon ?? Icons.receipt_long_outlined,
|
||||
color: label?.color,
|
||||
);
|
||||
},
|
||||
),
|
||||
title: Text(
|
||||
tx.description,
|
||||
maxLines: 1,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/// Transactions that share a given label.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/transaction_meta.dart';
|
||||
import '../providers/bank_provider.dart';
|
||||
import '../providers/tx_meta_provider.dart';
|
||||
import '../utils/helpers.dart';
|
||||
import '../widgets/responsive_body.dart';
|
||||
import 'transaction_detail_screen.dart';
|
||||
|
||||
class LabelTransactionsScreen extends StatelessWidget {
|
||||
const LabelTransactionsScreen({super.key, required this.label});
|
||||
|
||||
final TxLabel label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bank = context.watch<BankController>();
|
||||
final meta = context.watch<TxMetaController>();
|
||||
final txs = [
|
||||
for (final tx in bank.transactions)
|
||||
if (meta.metaFor(tx).labelIds.contains(label.id)) tx,
|
||||
];
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final currency = bank.balance?.currency ??
|
||||
(txs.isNotEmpty ? txs.first.currency : 'EUR');
|
||||
var totalOut = 0.0;
|
||||
for (final tx in txs) {
|
||||
if (tx.amount < 0) totalOut += tx.amount.abs();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(label.name),
|
||||
),
|
||||
body: ResponsiveBody(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 32),
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(label.icon, color: label.color),
|
||||
title: Text('${txs.length} transactions'),
|
||||
subtitle: Text('Money out ${formatMoney(totalOut, currency)}'),
|
||||
),
|
||||
const Divider(),
|
||||
if (txs.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Text(
|
||||
'No transactions with this label.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
)
|
||||
else
|
||||
for (final tx in txs)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
tx.description,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(formatIsoDate(tx.date)),
|
||||
trailing: Text(
|
||||
formatMoney(tx.amount, tx.currency),
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: tx.amount >= 0 ? colors.primary : colors.error,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => TransactionDetailScreen(tx: tx),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import '../utils/tx_calendar.dart';
|
||||
import '../widgets/empty_state.dart';
|
||||
import '../widgets/loading_view.dart';
|
||||
import '../widgets/responsive_body.dart';
|
||||
import 'label_transactions_screen.dart';
|
||||
|
||||
enum _StatsMode { category, month }
|
||||
|
||||
@@ -29,16 +30,32 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
_StatsMode _mode = _StatsMode.category;
|
||||
var _lookAheadEnabled = false;
|
||||
DateTime _lookAheadMonth = monthStart(DateTime.now());
|
||||
DateTime _chartMonth = monthStart(DateTime.now());
|
||||
|
||||
void _goLookAheadMonth(int delta) {
|
||||
final next = addMonths(_lookAheadMonth, delta);
|
||||
final now = monthStart(DateTime.now());
|
||||
// Look ahead can go forward; still allow browsing past months.
|
||||
if (delta < 0 && next.isBefore(addMonths(now, -24))) return;
|
||||
if (delta > 0 && next.isAfter(addMonths(now, 36))) return;
|
||||
setState(() => _lookAheadMonth = next);
|
||||
}
|
||||
|
||||
void _goChartMonth(int delta) {
|
||||
final next = addMonths(_chartMonth, delta);
|
||||
final now = monthStart(DateTime.now());
|
||||
if (delta > 0 && next.isAfter(now)) return;
|
||||
if (delta < 0 && next.isBefore(addMonths(now, -36))) return;
|
||||
setState(() => _chartMonth = next.isAfter(now) ? now : next);
|
||||
}
|
||||
|
||||
void _openLabel(TxLabel label) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => LabelTransactionsScreen(label: label),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bank = context.watch<BankController>();
|
||||
@@ -92,9 +109,9 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
? bank.transactions.first.currency
|
||||
: 'EUR');
|
||||
final currentBalance = bank.balance?.amount ?? 0;
|
||||
final byCategory = meta.spendByCategory(bank.transactions);
|
||||
final byMonth = meta.spendByMonth(bank.transactions);
|
||||
final totalSpend = byCategory.values.fold<double>(0, (sum, v) => sum + v);
|
||||
final byLabel = meta.spendByLabel(bank.transactions);
|
||||
final byDay = meta.spendByDayInMonth(bank.transactions, _chartMonth);
|
||||
final totalSpend = meta.labeledSpendTotal(bank.transactions);
|
||||
|
||||
final recurrings = recurringItemsFrom(
|
||||
transactions: bank.transactions,
|
||||
@@ -214,10 +231,18 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
for (final row in projected.take(12))
|
||||
ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
leading: Icon(
|
||||
row.item.category.icon,
|
||||
color: row.item.category.color,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 8),
|
||||
leading: Builder(
|
||||
builder: (context) {
|
||||
final label = row.item.labelIds.isEmpty
|
||||
? null
|
||||
: meta.labelById(row.item.labelIds.first);
|
||||
return Icon(
|
||||
label?.icon ?? Icons.autorenew,
|
||||
color: label?.color,
|
||||
);
|
||||
},
|
||||
),
|
||||
title: Text(
|
||||
row.item.description,
|
||||
@@ -247,7 +272,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
Text('Money out (categorized)', style: theme.textTheme.labelMedium),
|
||||
Text('Money out (labeled)', style: theme.textTheme.labelMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
formatMoney(totalSpend, currency),
|
||||
@@ -258,13 +283,13 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: _StatsMode.category,
|
||||
label: Text('Category'),
|
||||
label: Text('Label'),
|
||||
icon: Icon(Icons.label_outline),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _StatsMode.month,
|
||||
label: Text('By date'),
|
||||
icon: Icon(Icons.calendar_month_outlined),
|
||||
label: Text('By day'),
|
||||
icon: Icon(Icons.bar_chart_outlined),
|
||||
),
|
||||
],
|
||||
selected: {_mode},
|
||||
@@ -272,7 +297,33 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
setState(() => _mode = selection.first);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_mode == _StatsMode.month) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Previous month',
|
||||
onPressed: () => _goChartMonth(-1),
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
formatMonthYear(_chartMonth),
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Next month',
|
||||
onPressed: _chartMonth.isBefore(nowMonth)
|
||||
? () => _goChartMonth(1)
|
||||
: null,
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 260,
|
||||
child: bank.transactions.isEmpty
|
||||
@@ -283,21 +334,27 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
),
|
||||
)
|
||||
: _mode == _StatsMode.category
|
||||
? _CategoryBars(data: byCategory, currency: currency)
|
||||
: _MonthBars(data: byMonth, currency: currency),
|
||||
? _LabelBars(
|
||||
data: byLabel,
|
||||
resolve: meta.labelById,
|
||||
currency: currency,
|
||||
onBarTap: _openLabel,
|
||||
)
|
||||
: _DayBars(data: byDay, currency: currency),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_mode == _StatsMode.category)
|
||||
..._categoryLegend(context, byCategory, currency)
|
||||
..._labelLegend(context, byLabel, meta, currency)
|
||||
else
|
||||
..._monthLegend(context, byMonth, currency),
|
||||
..._dayLegend(context, byDay, currency),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _categoryLegend(
|
||||
List<Widget> _labelLegend(
|
||||
BuildContext context,
|
||||
Map<TxCategory, double> data,
|
||||
Map<String, double> data,
|
||||
TxMetaController meta,
|
||||
String currency,
|
||||
) {
|
||||
final entries = data.entries.toList()
|
||||
@@ -305,38 +362,46 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
if (entries.isEmpty) {
|
||||
return [
|
||||
Text(
|
||||
'No outflows yet. Categorize spending from the transaction list.',
|
||||
'No labeled outflows yet. Add a label from a transaction.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
];
|
||||
}
|
||||
return [
|
||||
for (final entry in entries)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
leading: Icon(entry.key.icon, color: entry.key.color),
|
||||
title: Text(entry.key.label),
|
||||
trailing: Text(formatMoney(entry.value, currency)),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final label = meta.labelById(entry.key);
|
||||
if (label == null) return const SizedBox.shrink();
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
leading: Icon(label.icon, color: label.color),
|
||||
title: Text(label.name),
|
||||
trailing: Text(formatMoney(entry.value, currency)),
|
||||
onTap: () => _openLabel(label),
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _monthLegend(
|
||||
List<Widget> _dayLegend(
|
||||
BuildContext context,
|
||||
Map<String, double> data,
|
||||
String currency,
|
||||
) {
|
||||
if (data.isEmpty) {
|
||||
final spent = data.entries.where((e) => e.value > 0).toList().reversed;
|
||||
if (spent.isEmpty) {
|
||||
return [
|
||||
Text(
|
||||
'No dated outflows to chart yet.',
|
||||
'No outflows this month.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
];
|
||||
}
|
||||
return [
|
||||
for (final entry in data.entries.toList().reversed)
|
||||
for (final entry in spent)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
@@ -348,20 +413,31 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryBars extends StatelessWidget {
|
||||
const _CategoryBars({required this.data, required this.currency});
|
||||
class _LabelBars extends StatelessWidget {
|
||||
const _LabelBars({
|
||||
required this.data,
|
||||
required this.resolve,
|
||||
required this.currency,
|
||||
required this.onBarTap,
|
||||
});
|
||||
|
||||
final Map<TxCategory, double> data;
|
||||
final Map<String, double> data;
|
||||
final TxLabel? Function(String? id) resolve;
|
||||
final String currency;
|
||||
final void Function(TxLabel label) onBarTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final entries = data.entries.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
final entries = <({TxLabel label, double value})>[
|
||||
for (final entry in data.entries)
|
||||
if (resolve(entry.key) != null)
|
||||
(label: resolve(entry.key)!, value: entry.value),
|
||||
]..sort((a, b) => b.value.compareTo(a.value));
|
||||
|
||||
if (entries.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Categorize transactions to see this chart',
|
||||
'Label transactions to see this chart',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -373,11 +449,19 @@ class _CategoryBars extends StatelessWidget {
|
||||
BarChartData(
|
||||
maxY: maxY * 1.15,
|
||||
barTouchData: BarTouchData(
|
||||
touchCallback: (event, response) {
|
||||
if (!event.isInterestedForInteractions) return;
|
||||
final index = response?.spot?.touchedBarGroupIndex;
|
||||
if (index == null || index < 0 || index >= entries.length) return;
|
||||
if (event is FlTapUpEvent) {
|
||||
onBarTap(entries[index].label);
|
||||
}
|
||||
},
|
||||
touchTooltipData: BarTouchTooltipData(
|
||||
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
||||
final entry = entries[group.x.toInt()];
|
||||
return BarTooltipItem(
|
||||
'${entry.key.label}\n${formatMoney(entry.value, currency)}',
|
||||
'${entry.label.name}\n${formatMoney(entry.value, currency)}',
|
||||
TextStyle(
|
||||
color: Theme.of(context).colorScheme.onInverseSurface,
|
||||
),
|
||||
@@ -399,7 +483,7 @@ class _CategoryBars extends StatelessWidget {
|
||||
if (index < 0 || index >= entries.length) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final label = entries[index].key.label;
|
||||
final label = entries[index].label.name;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
@@ -423,7 +507,7 @@ class _CategoryBars extends StatelessWidget {
|
||||
width: 18,
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(6)),
|
||||
color: entries[i].key.color,
|
||||
color: entries[i].label.color,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -433,8 +517,8 @@ class _CategoryBars extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MonthBars extends StatelessWidget {
|
||||
const _MonthBars({required this.data, required this.currency});
|
||||
class _DayBars extends StatelessWidget {
|
||||
const _DayBars({required this.data, required this.currency});
|
||||
|
||||
final Map<String, double> data;
|
||||
final String currency;
|
||||
@@ -442,26 +526,30 @@ class _MonthBars extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final entries = data.entries.toList();
|
||||
if (entries.isEmpty) {
|
||||
if (entries.every((e) => e.value == 0)) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No monthly spend yet',
|
||||
'No spend this month',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final maxY = entries.map((e) => e.value).reduce((a, b) => a > b ? a : b);
|
||||
final maxY = entries.map((e) => e.value).fold<double>(0, (a, b) => a > b ? a : b);
|
||||
final safeMax = maxY <= 0 ? 1.0 : maxY * 1.15;
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return BarChart(
|
||||
BarChartData(
|
||||
maxY: maxY * 1.15,
|
||||
maxY: safeMax,
|
||||
barTouchData: BarTouchData(
|
||||
touchTooltipData: BarTouchTooltipData(
|
||||
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
||||
final entry = entries[group.x.toInt()];
|
||||
final day = entry.key.length >= 10
|
||||
? entry.key.substring(8)
|
||||
: entry.key;
|
||||
return BarTooltipItem(
|
||||
'${entry.key}\n${formatMoney(entry.value, currency)}',
|
||||
'Day $day\n${formatMoney(entry.value, currency)}',
|
||||
TextStyle(color: colors.onInverseSurface),
|
||||
);
|
||||
},
|
||||
@@ -476,17 +564,20 @@ class _MonthBars extends StatelessWidget {
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 28,
|
||||
interval: entries.length > 16 ? 5 : 2,
|
||||
getTitlesWidget: (value, meta) {
|
||||
final index = value.toInt();
|
||||
if (index < 0 || index >= entries.length) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final key = entries[index].key;
|
||||
final short = key.length >= 7 ? key.substring(5) : key;
|
||||
if (entries.length > 16 && index % 5 != 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final day = (index + 1).toString();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
short,
|
||||
day,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
);
|
||||
@@ -503,10 +594,12 @@ class _MonthBars extends StatelessWidget {
|
||||
barRods: [
|
||||
BarChartRodData(
|
||||
toY: entries[i].value,
|
||||
width: 22,
|
||||
width: entries.length > 20 ? 6 : 8,
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(6)),
|
||||
color: colors.primary,
|
||||
const BorderRadius.vertical(top: Radius.circular(3)),
|
||||
color: entries[i].value > 0
|
||||
? colors.primary
|
||||
: colors.surfaceContainerHighest,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -92,9 +92,9 @@ class SettingsScreen extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
const ListTile(
|
||||
leading: Icon(Icons.open_in_browser_outlined),
|
||||
title: Text('Enable Banking redirect'),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.open_in_browser_outlined),
|
||||
title: const Text('Enable Banking redirect'),
|
||||
subtitle: Text(kEnableBankingRedirectUri),
|
||||
),
|
||||
],
|
||||
@@ -104,9 +104,8 @@ class SettingsScreen extends StatelessWidget {
|
||||
Text(
|
||||
'Enable Banking only allows http(s) redirects. Keep '
|
||||
'$kEnableBankingRedirectUri in the Control Panel and backend '
|
||||
'REDIRECT_URI. The app captures that URL inside a WebView. '
|
||||
'Override the API host with '
|
||||
'`--dart-define=API_BASE_URL=http://<host>:8000`.',
|
||||
'REDIRECT_URI. The app captures that callback path in a WebView. '
|
||||
'Override with `--dart-define=API_BASE_URL=https://…`.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/// Transaction detail: category, recurring, and user notes.
|
||||
/// Transaction detail: label, recurring, notes, apply-to-similar.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/bank.dart';
|
||||
import '../models/transaction_meta.dart';
|
||||
import '../providers/bank_provider.dart';
|
||||
import '../providers/tx_meta_provider.dart';
|
||||
import '../utils/helpers.dart';
|
||||
import '../widgets/responsive_body.dart';
|
||||
@@ -23,12 +24,18 @@ class TransactionDetailScreen extends StatefulWidget {
|
||||
class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
late final TextEditingController _notesController;
|
||||
var _dirty = false;
|
||||
late bool _applyToSimilar;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final notes = context.read<TxMetaController>().metaFor(widget.tx).notes;
|
||||
final meta = context.read<TxMetaController>();
|
||||
final notes = meta.metaFor(widget.tx).notes;
|
||||
_notesController = TextEditingController(text: notes);
|
||||
final all = context.read<BankController>().transactions;
|
||||
final similarCount = meta.countSimilar(widget.tx, all);
|
||||
// Default on when there are peers — otherwise users never notice the toggle.
|
||||
_applyToSimilar = similarCount > 1;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -37,25 +44,116 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<BankTransaction> _similarScope(TxMetaController meta) {
|
||||
final all = context.read<BankController>().transactions;
|
||||
if (!_applyToSimilar) return const [];
|
||||
return meta.similarTo(widget.tx, all);
|
||||
}
|
||||
|
||||
Future<void> _saveNotes() async {
|
||||
await context.read<TxMetaController>().setNotes(
|
||||
widget.tx,
|
||||
_notesController.text,
|
||||
);
|
||||
final meta = context.read<TxMetaController>();
|
||||
final similar = _similarScope(meta);
|
||||
await meta.setNotes(
|
||||
widget.tx,
|
||||
_notesController.text,
|
||||
applyToSimilar: similar,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _dirty = false);
|
||||
final count = similar.isEmpty ? 1 : similar.length;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Notes saved')),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
count == 1
|
||||
? 'Notes saved'
|
||||
: 'Notes saved on $count similar transactions',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleLabel(String labelId) async {
|
||||
final meta = context.read<TxMetaController>();
|
||||
final similar = _similarScope(meta);
|
||||
await meta.toggleLabel(
|
||||
widget.tx,
|
||||
labelId,
|
||||
applyToSimilar: similar,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final count = similar.isEmpty ? 1 : similar.length;
|
||||
if (count > 1) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Labels updated on $count similar transactions'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearLabels() async {
|
||||
final meta = context.read<TxMetaController>();
|
||||
final similar = _similarScope(meta);
|
||||
await meta.clearLabels(
|
||||
widget.tx,
|
||||
applyToSimilar: similar,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createLabel() async {
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => const _NewLabelDialog(),
|
||||
);
|
||||
if (name == null || name.isEmpty || !mounted) return;
|
||||
final meta = context.read<TxMetaController>();
|
||||
final label = await meta.createLabel(name);
|
||||
if (!mounted) return;
|
||||
await _toggleLabel(label.id);
|
||||
}
|
||||
|
||||
Future<void> _confirmRemoveLabel(TxLabel label) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text('Remove “${label.name}”?'),
|
||||
content: const Text(
|
||||
'This deletes the label. Transactions keep any other labels.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Remove'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (ok != true || !mounted) return;
|
||||
await context.read<TxMetaController>().removeLabel(label.id);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tx = widget.tx;
|
||||
final meta = context.watch<TxMetaController>().metaFor(tx);
|
||||
final metaController = context.watch<TxMetaController>();
|
||||
final meta = metaController.metaFor(tx);
|
||||
final all = context.watch<BankController>().transactions;
|
||||
final similarCount = metaController.countSimilar(tx, all);
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final amountColor = tx.amount >= 0 ? colors.primary : colors.error;
|
||||
final selectedIds = meta.labelIds.toSet();
|
||||
final merchantPreview = tx.merchantKey.isEmpty
|
||||
? tx.description
|
||||
: (tx.merchantKey.length > 48
|
||||
? '${tx.merchantKey.substring(0, 47)}…'
|
||||
: tx.merchantKey);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -86,7 +184,20 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
].join(' · '),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (similarCount > 1) ...[
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.copy_all_outlined),
|
||||
title: const Text('Apply to all similar'),
|
||||
subtitle: Text(
|
||||
'$similarCount matches · $merchantPreview',
|
||||
),
|
||||
value: _applyToSimilar,
|
||||
onChanged: (value) => setState(() => _applyToSimilar = value),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.autorenew),
|
||||
@@ -98,25 +209,39 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Category', style: theme.textTheme.titleSmall),
|
||||
Row(
|
||||
children: [
|
||||
Text('Labels', style: theme.textTheme.titleSmall),
|
||||
const Spacer(),
|
||||
if (meta.hasLabel)
|
||||
TextButton(
|
||||
onPressed: _clearLabels,
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final category in TxCategory.values)
|
||||
ChoiceChip(
|
||||
avatar: Icon(
|
||||
category.icon,
|
||||
size: 16,
|
||||
color: category.color,
|
||||
for (final label in metaController.allLabels)
|
||||
GestureDetector(
|
||||
onLongPress: label.custom
|
||||
? () => _confirmRemoveLabel(label)
|
||||
: null,
|
||||
child: FilterChip(
|
||||
avatar: Icon(label.icon, size: 16, color: label.color),
|
||||
label: Text(label.name),
|
||||
selected: selectedIds.contains(label.id),
|
||||
onSelected: (_) => _toggleLabel(label.id),
|
||||
),
|
||||
label: Text(category.label),
|
||||
selected: meta.category == category,
|
||||
onSelected: (_) {
|
||||
context.read<TxMetaController>().setCategory(tx, category);
|
||||
},
|
||||
),
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.add, size: 16),
|
||||
label: const Text('New label'),
|
||||
onPressed: _createLabel,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
@@ -137,12 +262,17 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: _dirty ? _saveNotes : null,
|
||||
child: const Text('Save notes'),
|
||||
child: Text(
|
||||
_applyToSimilar && similarCount > 1
|
||||
? 'Save notes for $similarCount'
|
||||
: 'Save notes',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Matching descriptions reuse this category automatically. '
|
||||
'Exact same amount + description also copies notes.',
|
||||
'Tap labels to add or remove several. Long-press a custom label '
|
||||
'to delete it. Similar matches strip dates and ids so supermarket '
|
||||
'purchases group together.',
|
||||
style: theme.textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
@@ -151,3 +281,54 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NewLabelDialog extends StatefulWidget {
|
||||
const _NewLabelDialog();
|
||||
|
||||
@override
|
||||
State<_NewLabelDialog> createState() => _NewLabelDialogState();
|
||||
}
|
||||
|
||||
class _NewLabelDialogState extends State<_NewLabelDialog> {
|
||||
late final TextEditingController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
Navigator.pop(context, _controller.text.trim());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('New label'),
|
||||
content: TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: const InputDecoration(hintText: 'e.g. Supermarket'),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _submit,
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
DateTime _month = monthStart(DateTime.now());
|
||||
DateTime? _selectedDay;
|
||||
var _searchInMonthOnly = false;
|
||||
final Set<TxCategory> _categoryFilters = {};
|
||||
final Set<String> _labelFilters = {};
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -40,7 +40,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
}
|
||||
|
||||
bool get _isSearching =>
|
||||
_query.trim().isNotEmpty || _categoryFilters.isNotEmpty;
|
||||
_query.trim().isNotEmpty || _labelFilters.isNotEmpty;
|
||||
|
||||
void _goMonth(int delta) {
|
||||
final next = addMonths(_month, delta);
|
||||
@@ -67,15 +67,15 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
return isSameMonth(date, _month);
|
||||
}
|
||||
|
||||
Future<void> _pickCategories(List<TxCategory> available) async {
|
||||
Future<void> _pickLabels(List<TxLabel> available) async {
|
||||
if (available.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No categories in use yet')),
|
||||
const SnackBar(content: Text('No labels in use yet')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final selected = Set<TxCategory>.of(_categoryFilters);
|
||||
final selected = Set<String>.of(_labelFilters);
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
@@ -90,7 +90,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Filter by category',
|
||||
'Filter by label',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -98,21 +98,21 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final category in available)
|
||||
for (final label in available)
|
||||
FilterChip(
|
||||
avatar: Icon(
|
||||
category.icon,
|
||||
label.icon,
|
||||
size: 16,
|
||||
color: category.color,
|
||||
color: label.color,
|
||||
),
|
||||
label: Text(category.label),
|
||||
selected: selected.contains(category),
|
||||
label: Text(label.name),
|
||||
selected: selected.contains(label.id),
|
||||
onSelected: (value) {
|
||||
setSheetState(() {
|
||||
if (value) {
|
||||
selected.add(category);
|
||||
selected.add(label.id);
|
||||
} else {
|
||||
selected.remove(category);
|
||||
selected.remove(label.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -123,7 +123,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_categoryFilters
|
||||
_labelFilters
|
||||
..clear()
|
||||
..addAll(selected);
|
||||
});
|
||||
@@ -241,7 +241,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
final filtered = metaController.search(
|
||||
searchPool,
|
||||
_query,
|
||||
categories: _categoryFilters,
|
||||
labelIds: _labelFilters,
|
||||
);
|
||||
|
||||
var moneyOut = 0.0;
|
||||
@@ -257,7 +257,10 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
(bank.transactions.isNotEmpty
|
||||
? bank.transactions.first.currency
|
||||
: 'EUR');
|
||||
final usedCategories = metaController.usedCategories(bank.transactions);
|
||||
final usedLabels = metaController.usedLabels(bank.transactions);
|
||||
final labelById = {
|
||||
for (final label in metaController.allLabels) label.id: label,
|
||||
};
|
||||
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
@@ -274,13 +277,11 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search description, notes…',
|
||||
prefixIcon: IconButton(
|
||||
tooltip: 'Categories',
|
||||
onPressed: () => _pickCategories(usedCategories),
|
||||
tooltip: 'Labels',
|
||||
onPressed: () => _pickLabels(usedLabels),
|
||||
icon: Icon(
|
||||
Icons.search,
|
||||
color: _categoryFilters.isEmpty
|
||||
? null
|
||||
: colors.primary,
|
||||
color: _labelFilters.isEmpty ? null : colors.primary,
|
||||
),
|
||||
),
|
||||
suffixIcon: _query.isEmpty
|
||||
@@ -316,21 +317,26 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_categoryFilters.isNotEmpty)
|
||||
if (_labelFilters.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final category in _categoryFilters)
|
||||
InputChip(
|
||||
avatar: Icon(category.icon, size: 16, color: category.color),
|
||||
label: Text(category.label),
|
||||
onDeleted: () {
|
||||
setState(() => _categoryFilters.remove(category));
|
||||
},
|
||||
),
|
||||
for (final id in _labelFilters)
|
||||
if (labelById[id] != null)
|
||||
InputChip(
|
||||
avatar: Icon(
|
||||
labelById[id]!.icon,
|
||||
size: 16,
|
||||
color: labelById[id]!.color,
|
||||
),
|
||||
label: Text(labelById[id]!.name),
|
||||
onDeleted: () {
|
||||
setState(() => _labelFilters.remove(id));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -411,6 +417,8 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
_CompactTransactionTile(
|
||||
tx: filtered[i],
|
||||
meta: metaController.metaFor(filtered[i]),
|
||||
labels: metaController
|
||||
.labelsFor(metaController.metaFor(filtered[i])),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -467,16 +475,21 @@ class _CompactTransactionTile extends StatelessWidget {
|
||||
const _CompactTransactionTile({
|
||||
required this.tx,
|
||||
required this.meta,
|
||||
this.labels = const [],
|
||||
});
|
||||
|
||||
final BankTransaction tx;
|
||||
final TxMeta meta;
|
||||
final List<TxLabel> labels;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final amountColor = tx.amount >= 0 ? colors.primary : colors.error;
|
||||
final primary = labels.isEmpty ? null : labels.first;
|
||||
final iconColor = primary?.color ?? colors.outline;
|
||||
final iconData = primary?.icon ?? Icons.receipt_long_outlined;
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
@@ -494,13 +507,13 @@ class _CompactTransactionTile extends StatelessWidget {
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: meta.category.color.withValues(alpha: 0.14),
|
||||
color: iconColor.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
meta.category.icon,
|
||||
iconData,
|
||||
size: 18,
|
||||
color: meta.category.color,
|
||||
color: iconColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -521,7 +534,8 @@ class _CompactTransactionTile extends StatelessWidget {
|
||||
Text(
|
||||
[
|
||||
formatIsoDate(tx.date),
|
||||
if (meta.hasCategory) meta.category.label,
|
||||
if (labels.isNotEmpty)
|
||||
labels.map((l) => l.name).join(', '),
|
||||
if (meta.recurring) 'Recurring',
|
||||
if (meta.hasNotes) 'Note',
|
||||
if (tx.pending) 'Pending',
|
||||
|
||||
Reference in New Issue
Block a user