Compare commits
4
Commits
b62164dde5
..
3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a93dbbe30d | ||
|
|
22a535a6fd | ||
|
|
bd3bc5357e | ||
|
|
a23739d717 |
@@ -521,10 +521,15 @@ class TxMetaController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Replace labels on every transaction in [txs] (per-id overrides).
|
/// Replace labels on every transaction in [txs] (per-id overrides).
|
||||||
|
///
|
||||||
|
/// When [matchPattern] is set (apply-to-matched), also refreshes the
|
||||||
|
/// merchant rule so future transactions matching the pattern inherit the
|
||||||
|
/// full label set (parent + sub-labels).
|
||||||
Future<void> setLabelsForMany(
|
Future<void> setLabelsForMany(
|
||||||
List<BankTransaction> txs,
|
List<BankTransaction> txs,
|
||||||
List<String> labelIds,
|
List<String> labelIds, {
|
||||||
) async {
|
String? matchPattern,
|
||||||
|
}) async {
|
||||||
if (txs.isEmpty) return;
|
if (txs.isEmpty) return;
|
||||||
|
|
||||||
final expanded = expandWithAncestors([
|
final expanded = expandWithAncestors([
|
||||||
@@ -545,6 +550,22 @@ class TxMetaController extends ChangeNotifier {
|
|||||||
: current.copyWith(labelIds: unique);
|
: current.copyWith(labelIds: unique);
|
||||||
}
|
}
|
||||||
_byId = nextById;
|
_byId = nextById;
|
||||||
|
|
||||||
|
final pattern = (matchPattern ?? '').trim().toLowerCase();
|
||||||
|
if (pattern.isNotEmpty) {
|
||||||
|
final nextRules = Map<String, List<String>>.of(_labelsByMerchant);
|
||||||
|
if (unique.isEmpty) {
|
||||||
|
nextRules.remove(pattern);
|
||||||
|
for (final tx in txs) {
|
||||||
|
nextRules.remove(tx.merchantKey);
|
||||||
|
nextRules.remove(tx.descriptionKey);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextRules[pattern] = unique;
|
||||||
|
}
|
||||||
|
_labelsByMerchant = nextRules;
|
||||||
|
}
|
||||||
|
|
||||||
_schedulePersist();
|
_schedulePersist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:pocketbase/pocketbase.dart';
|
import 'package:pocketbase/pocketbase.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
@@ -46,6 +47,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
email: _emailController.text,
|
email: _emailController.text,
|
||||||
password: _passwordController.text,
|
password: _passwordController.text,
|
||||||
);
|
);
|
||||||
|
// Lets iOS/Android password managers offer to save / confirm the login.
|
||||||
|
TextInput.finishAutofillContext();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (!widget.asRoot) {
|
if (!widget.asRoot) {
|
||||||
Navigator.of(context).pop(true);
|
Navigator.of(context).pop(true);
|
||||||
@@ -96,68 +99,77 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Form(
|
AutofillGroup(
|
||||||
key: _formKey,
|
child: Form(
|
||||||
child: Column(
|
key: _formKey,
|
||||||
children: [
|
child: Column(
|
||||||
AppTextField(
|
children: [
|
||||||
controller: _emailController,
|
AppTextField(
|
||||||
label: 'Email',
|
controller: _emailController,
|
||||||
keyboardType: TextInputType.emailAddress,
|
label: 'Email',
|
||||||
textInputAction: TextInputAction.next,
|
keyboardType: TextInputType.emailAddress,
|
||||||
autofocus: true,
|
textInputAction: TextInputAction.next,
|
||||||
validator: (value) {
|
autofocus: true,
|
||||||
final text = value?.trim() ?? '';
|
autofillHints: const [
|
||||||
if (text.isEmpty) return 'Enter your email';
|
AutofillHints.email,
|
||||||
if (!text.contains('@')) return 'Enter a valid email';
|
AutofillHints.username,
|
||||||
return null;
|
],
|
||||||
},
|
validator: (value) {
|
||||||
),
|
final text = value?.trim() ?? '';
|
||||||
const SizedBox(height: 16),
|
if (text.isEmpty) return 'Enter your email';
|
||||||
TextFormField(
|
if (!text.contains('@')) return 'Enter a valid email';
|
||||||
controller: _passwordController,
|
return null;
|
||||||
obscureText: _obscurePassword,
|
},
|
||||||
textInputAction: TextInputAction.done,
|
),
|
||||||
onFieldSubmitted: (_) => _submit(),
|
const SizedBox(height: 16),
|
||||||
validator: (value) {
|
TextFormField(
|
||||||
if (value == null || value.isEmpty) {
|
controller: _passwordController,
|
||||||
return 'Enter your password';
|
obscureText: _obscurePassword,
|
||||||
}
|
textInputAction: TextInputAction.done,
|
||||||
return null;
|
autofillHints: const [AutofillHints.password],
|
||||||
},
|
enableSuggestions: false,
|
||||||
decoration: InputDecoration(
|
autocorrect: false,
|
||||||
labelText: 'Password',
|
onFieldSubmitted: (_) => _submit(),
|
||||||
suffixIcon: IconButton(
|
validator: (value) {
|
||||||
tooltip: _obscurePassword
|
if (value == null || value.isEmpty) {
|
||||||
? 'Show password'
|
return 'Enter your password';
|
||||||
: 'Hide password',
|
}
|
||||||
onPressed: () => setState(
|
return null;
|
||||||
() => _obscurePassword = !_obscurePassword,
|
},
|
||||||
),
|
decoration: InputDecoration(
|
||||||
icon: Icon(
|
labelText: 'Password',
|
||||||
_obscurePassword
|
suffixIcon: IconButton(
|
||||||
? Icons.visibility_outlined
|
tooltip: _obscurePassword
|
||||||
: Icons.visibility_off_outlined,
|
? 'Show password'
|
||||||
|
: 'Hide password',
|
||||||
|
onPressed: () => setState(
|
||||||
|
() => _obscurePassword = !_obscurePassword,
|
||||||
|
),
|
||||||
|
icon: Icon(
|
||||||
|
_obscurePassword
|
||||||
|
? Icons.visibility_outlined
|
||||||
|
: Icons.visibility_off_outlined,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 24),
|
||||||
const SizedBox(height: 24),
|
FilledButton.icon(
|
||||||
FilledButton.icon(
|
onPressed: _busy ? null : _submit,
|
||||||
onPressed: _busy ? null : _submit,
|
icon: _busy
|
||||||
icon: _busy
|
? SizedBox(
|
||||||
? SizedBox(
|
width: 18,
|
||||||
width: 18,
|
height: 18,
|
||||||
height: 18,
|
child: CircularProgressIndicator(
|
||||||
child: CircularProgressIndicator(
|
strokeWidth: 2,
|
||||||
strokeWidth: 2,
|
color: theme.colorScheme.onPrimary,
|
||||||
color: theme.colorScheme.onPrimary,
|
),
|
||||||
),
|
)
|
||||||
)
|
: const Icon(Icons.login),
|
||||||
: const Icon(Icons.login),
|
label: Text(_busy ? 'Signing in…' : 'Sign in'),
|
||||||
label: Text(_busy ? 'Signing in…' : 'Sign in'),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
+539
-100
@@ -3,6 +3,7 @@ library;
|
|||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
@@ -33,6 +34,18 @@ class _ItemsSnapshot {
|
|||||||
final List<RecurringItem> items;
|
final List<RecurringItem> items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _BalancePoint {
|
||||||
|
const _BalancePoint({
|
||||||
|
required this.key,
|
||||||
|
required this.label,
|
||||||
|
required this.balance,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DateTime key;
|
||||||
|
final String label;
|
||||||
|
final double balance;
|
||||||
|
}
|
||||||
|
|
||||||
class LookAheadScreen extends StatefulWidget {
|
class LookAheadScreen extends StatefulWidget {
|
||||||
const LookAheadScreen({super.key});
|
const LookAheadScreen({super.key});
|
||||||
|
|
||||||
@@ -48,6 +61,7 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
|
|||||||
_ItemsSnapshot? _snapshot;
|
_ItemsSnapshot? _snapshot;
|
||||||
int? _loadingKey;
|
int? _loadingKey;
|
||||||
int _computeGen = 0;
|
int _computeGen = 0;
|
||||||
|
var _showChart = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -63,14 +77,15 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
|
|||||||
|
|
||||||
/// Inclusive end date for the selected period.
|
/// Inclusive end date for the selected period.
|
||||||
DateTime get _through {
|
DateTime get _through {
|
||||||
if (_period == _PeriodMode.yearly) {
|
// Chart view (and yearly list) always project through year-end.
|
||||||
|
if (_showChart || _period == _PeriodMode.yearly) {
|
||||||
return DateTime(_cursor.year, 12, 31);
|
return DateTime(_cursor.year, 12, 31);
|
||||||
}
|
}
|
||||||
return DateTime(_cursor.year, _cursor.month + 1, 0);
|
return DateTime(_cursor.year, _cursor.month + 1, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
String get _periodTitle {
|
String get _periodTitle {
|
||||||
if (_period == _PeriodMode.yearly) return '${_cursor.year}';
|
if (_showChart || _period == _PeriodMode.yearly) return '${_cursor.year}';
|
||||||
return formatMonthYear(_cursor);
|
return formatMonthYear(_cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,28 +94,32 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
|
|||||||
return DateTime(t.year + maxYears, t.month);
|
return DateTime(t.year + maxYears, t.month);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _maxYear(int maxYears) => _today.year + maxYears;
|
||||||
|
|
||||||
|
bool get _navByYear => _showChart || _period == _PeriodMode.yearly;
|
||||||
|
|
||||||
bool _canGoBack() {
|
bool _canGoBack() {
|
||||||
if (_period == _PeriodMode.yearly) {
|
if (_navByYear) return _cursor.year > _today.year;
|
||||||
return _cursor.year > _today.year;
|
|
||||||
}
|
|
||||||
return monthStart(_cursor).isAfter(_minMonth);
|
return monthStart(_cursor).isAfter(_minMonth);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _canGoForward(int maxYears) {
|
bool _canGoForward(int maxYears) {
|
||||||
if (_period == _PeriodMode.yearly) {
|
if (_navByYear) return _cursor.year < _maxYear(maxYears);
|
||||||
return _cursor.year < _today.year + maxYears;
|
|
||||||
}
|
|
||||||
return monthStart(_cursor).isBefore(_maxMonthFor(maxYears));
|
return monthStart(_cursor).isBefore(_maxMonthFor(maxYears));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clampCursor(int maxYears) {
|
void _clampCursor(int maxYears) {
|
||||||
if (_period == _PeriodMode.yearly) {
|
if (_navByYear) {
|
||||||
if (_cursor.year > _today.year + maxYears) {
|
if (_cursor.year > _maxYear(maxYears)) {
|
||||||
_cursor = DateTime(_today.year + maxYears);
|
_cursor = DateTime(_maxYear(maxYears));
|
||||||
|
}
|
||||||
|
if (_cursor.year < _today.year) {
|
||||||
|
_cursor = DateTime(_today.year);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
final maxMonth = _maxMonthFor(maxYears);
|
final maxMonth = _maxMonthFor(maxYears);
|
||||||
if (_cursor.isAfter(maxMonth)) _cursor = maxMonth;
|
if (_cursor.isAfter(maxMonth)) _cursor = maxMonth;
|
||||||
|
if (_cursor.isBefore(_minMonth)) _cursor = _minMonth;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,26 +127,22 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
|
|||||||
if (mode == _period) return;
|
if (mode == _period) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_period = mode;
|
_period = mode;
|
||||||
if (mode == _PeriodMode.yearly) {
|
if (mode == _PeriodMode.yearly || _showChart) {
|
||||||
_cursor = DateTime(_cursor.year);
|
_cursor = DateTime(_cursor.year);
|
||||||
if (_cursor.year < _today.year) _cursor = DateTime(_today.year);
|
_clampCursor(maxYears);
|
||||||
if (_cursor.year > _today.year + maxYears) {
|
|
||||||
_cursor = DateTime(_today.year + maxYears);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
final month = DateTime(_cursor.year, _today.month);
|
final month = DateTime(_cursor.year, _today.month);
|
||||||
_cursor = month.isBefore(_minMonth) ? _minMonth : monthStart(month);
|
_cursor = month.isBefore(_minMonth) ? _minMonth : monthStart(month);
|
||||||
final maxMonth = _maxMonthFor(maxYears);
|
_clampCursor(maxYears);
|
||||||
if (_cursor.isAfter(maxMonth)) _cursor = maxMonth;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _go(int delta, int maxYears) {
|
void _go(int delta, int maxYears) {
|
||||||
setState(() {
|
setState(() {
|
||||||
if (_period == _PeriodMode.yearly) {
|
if (_navByYear) {
|
||||||
final next = DateTime(_cursor.year + delta);
|
final next = DateTime(_cursor.year + delta);
|
||||||
if (next.year < _today.year || next.year > _today.year + maxYears) {
|
if (next.year < _today.year || next.year > _maxYear(maxYears)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_cursor = next;
|
_cursor = next;
|
||||||
@@ -140,9 +155,80 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _toggleChart(int maxYears) {
|
||||||
|
setState(() {
|
||||||
|
_showChart = !_showChart;
|
||||||
|
if (_showChart) {
|
||||||
|
_cursor = DateTime(_cursor.year);
|
||||||
|
} else if (_period == _PeriodMode.monthly) {
|
||||||
|
final month = DateTime(_cursor.year, _today.month);
|
||||||
|
_cursor = month.isBefore(_minMonth) ? _minMonth : monthStart(month);
|
||||||
|
}
|
||||||
|
_clampCursor(maxYears);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
int _itemsCacheKey(TxMetaController meta, BankController bank) =>
|
int _itemsCacheKey(TxMetaController meta, BankController bank) =>
|
||||||
Object.hash(meta.storeRevision, identityHashCode(bank.transactions));
|
Object.hash(meta.storeRevision, identityHashCode(bank.transactions));
|
||||||
|
|
||||||
|
/// Projected account balance at each month-end / year-end.
|
||||||
|
List<_BalancePoint> _balanceSeries({
|
||||||
|
required List<RecurringItem> items,
|
||||||
|
required double currentBalance,
|
||||||
|
required int maxYears,
|
||||||
|
}) {
|
||||||
|
final today = _today;
|
||||||
|
if (_period == _PeriodMode.monthly) {
|
||||||
|
final year = _cursor.year;
|
||||||
|
const months = [
|
||||||
|
'J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D',
|
||||||
|
];
|
||||||
|
final out = <_BalancePoint>[];
|
||||||
|
for (var m = 1; m <= 12; m++) {
|
||||||
|
final end = DateTime(year, m + 1, 0);
|
||||||
|
final balance = !end.isAfter(today)
|
||||||
|
? currentBalance
|
||||||
|
: predictBalance(
|
||||||
|
currentBalance: currentBalance,
|
||||||
|
from: today,
|
||||||
|
throughDate: end,
|
||||||
|
items: items,
|
||||||
|
);
|
||||||
|
out.add(
|
||||||
|
_BalancePoint(
|
||||||
|
key: DateTime(year, m),
|
||||||
|
label: months[m - 1],
|
||||||
|
balance: balance,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
final startYear = _today.year;
|
||||||
|
final endYear = _maxYear(maxYears);
|
||||||
|
final out = <_BalancePoint>[];
|
||||||
|
for (var y = startYear; y <= endYear; y++) {
|
||||||
|
final end = DateTime(y, 12, 31);
|
||||||
|
final balance = !end.isAfter(today)
|
||||||
|
? currentBalance
|
||||||
|
: predictBalance(
|
||||||
|
currentBalance: currentBalance,
|
||||||
|
from: today,
|
||||||
|
throughDate: end,
|
||||||
|
items: items,
|
||||||
|
);
|
||||||
|
out.add(
|
||||||
|
_BalancePoint(
|
||||||
|
key: DateTime(y),
|
||||||
|
label: '$y',
|
||||||
|
balance: balance,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve recurring amounts off the build path, yielding so nav stays live.
|
/// Resolve recurring amounts off the build path, yielding so nav stays live.
|
||||||
void _ensureItemsLoaded({
|
void _ensureItemsLoaded({
|
||||||
required int cacheKey,
|
required int cacheKey,
|
||||||
@@ -261,7 +347,16 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Look ahead'),
|
title: const Text('Look ahead'),
|
||||||
actions: bankAppBarActions(context: context),
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: _showChart ? 'Show list' : 'Show chart',
|
||||||
|
onPressed: () => _toggleChart(maxYears),
|
||||||
|
icon: Icon(
|
||||||
|
_showChart ? Icons.view_list_outlined : Icons.calendar_month,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...bankAppBarActions(context: context),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: const LoadingView(message: 'Calculating look ahead…'),
|
body: const LoadingView(message: 'Calculating look ahead…'),
|
||||||
);
|
);
|
||||||
@@ -303,7 +398,16 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Look ahead'),
|
title: const Text('Look ahead'),
|
||||||
actions: bankAppBarActions(context: context),
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: _showChart ? 'Show list' : 'Show chart',
|
||||||
|
onPressed: () => _toggleChart(maxYears),
|
||||||
|
icon: Icon(
|
||||||
|
_showChart ? Icons.view_list_outlined : Icons.calendar_month,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...bankAppBarActions(context: context),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: ResponsiveBody(
|
body: ResponsiveBody(
|
||||||
child: ListView(
|
child: ListView(
|
||||||
@@ -321,10 +425,8 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
|
|||||||
canGoForward: _canGoForward(maxYears),
|
canGoForward: _canGoForward(maxYears),
|
||||||
onBack: () => _go(-1, maxYears),
|
onBack: () => _go(-1, maxYears),
|
||||||
onForward: () => _go(1, maxYears),
|
onForward: () => _go(1, maxYears),
|
||||||
backTooltip:
|
backTooltip: _navByYear ? 'Previous year' : 'Previous month',
|
||||||
_period == _PeriodMode.yearly ? 'Previous year' : 'Previous month',
|
forwardTooltip: _navByYear ? 'Next year' : 'Next month',
|
||||||
forwardTooltip:
|
|
||||||
_period == _PeriodMode.yearly ? 'Next year' : 'Next month',
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Material(
|
Material(
|
||||||
@@ -408,82 +510,110 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
if (_showChart) ...[
|
||||||
Row(
|
const SizedBox(height: 16),
|
||||||
children: [
|
_LookAheadBalanceChart(
|
||||||
Expanded(
|
points: _balanceSeries(
|
||||||
child: Text(
|
items: items,
|
||||||
'Recurring spendings',
|
currentBalance: currentAmount,
|
||||||
style: theme.textTheme.titleMedium,
|
maxYears: maxYears,
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () => showCreateRecurringTemplateSheet(context),
|
|
||||||
icon: const Icon(Icons.add, size: 18),
|
|
||||||
label: const Text('New'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _SummaryCard(
|
|
||||||
icon: Icons.south_west,
|
|
||||||
iconBackground: AppTheme.accent.withValues(alpha: 0.18),
|
|
||||||
iconColor: AppTheme.accent,
|
|
||||||
amount: formatMoney(monthlyIncome, currency),
|
|
||||||
label: 'Monthly income',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: _SummaryCard(
|
|
||||||
icon: Icons.north_east,
|
|
||||||
iconBackground: AppTheme.expense.withValues(alpha: 0.18),
|
|
||||||
iconColor: AppTheme.expense,
|
|
||||||
amount: formatMoney(monthlyExpense, currency),
|
|
||||||
label: 'Monthly expense',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
if (items.isEmpty)
|
|
||||||
const EmptyState(
|
|
||||||
icon: Icons.event_repeat_outlined,
|
|
||||||
title: 'No recurring spendings',
|
|
||||||
message:
|
|
||||||
'Open a transaction and tap Create recurring spending, '
|
|
||||||
'or use New to pick a transaction or label average.',
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Card(
|
|
||||||
color: colors.surfaceContainer,
|
|
||||||
elevation: 0,
|
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
for (var i = 0; i < items.length; i++) ...[
|
|
||||||
if (i > 0)
|
|
||||||
Divider(
|
|
||||||
height: 1,
|
|
||||||
indent: 16,
|
|
||||||
color: colors.outlineVariant.withValues(alpha: 0.4),
|
|
||||||
),
|
|
||||||
_TemplateTile(
|
|
||||||
item: items[i],
|
|
||||||
template:
|
|
||||||
meta.recurringTemplateById(items[i].templateId),
|
|
||||||
transactions: bank.transactions,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
period: _period,
|
||||||
|
currency: currency,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_period == _PeriodMode.yearly
|
||||||
|
? 'Projected account balance by year'
|
||||||
|
: 'Projected account balance by month',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Recurring spendings',
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => showCreateRecurringTemplateSheet(context),
|
||||||
|
icon: const Icon(Icons.add, size: 18),
|
||||||
|
label: const Text('New'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _SummaryCard(
|
||||||
|
icon: Icons.south_west,
|
||||||
|
iconBackground: AppTheme.accent.withValues(alpha: 0.18),
|
||||||
|
iconColor: AppTheme.accent,
|
||||||
|
amount: formatMoney(monthlyIncome, currency),
|
||||||
|
label: 'Monthly income',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _SummaryCard(
|
||||||
|
icon: Icons.north_east,
|
||||||
|
iconBackground: AppTheme.expense.withValues(alpha: 0.18),
|
||||||
|
iconColor: AppTheme.expense,
|
||||||
|
amount: formatMoney(monthlyExpense, currency),
|
||||||
|
label: 'Monthly expense',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_SavingsCard(
|
||||||
|
monthlySavings: monthlyIncome - monthlyExpense,
|
||||||
|
currency: currency,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
if (items.isEmpty)
|
||||||
|
const EmptyState(
|
||||||
|
icon: Icons.event_repeat_outlined,
|
||||||
|
title: 'No recurring spendings',
|
||||||
|
message:
|
||||||
|
'Open a transaction and tap Create recurring spending, '
|
||||||
|
'or use New to pick a transaction or label average.',
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Card(
|
||||||
|
color: colors.surfaceContainer,
|
||||||
|
elevation: 0,
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < items.length; i++) ...[
|
||||||
|
if (i > 0)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
indent: 16,
|
||||||
|
color: colors.outlineVariant.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
_TemplateTile(
|
||||||
|
item: items[i],
|
||||||
|
template:
|
||||||
|
meta.recurringTemplateById(items[i].templateId),
|
||||||
|
transactions: bank.transactions,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -491,6 +621,315 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _LookAheadBalanceChart extends StatelessWidget {
|
||||||
|
const _LookAheadBalanceChart({
|
||||||
|
required this.points,
|
||||||
|
required this.period,
|
||||||
|
required this.currency,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<_BalancePoint> points;
|
||||||
|
final _PeriodMode period;
|
||||||
|
final String currency;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colors = theme.colorScheme;
|
||||||
|
if (points.isEmpty) {
|
||||||
|
return Card(
|
||||||
|
color: colors.surfaceContainer,
|
||||||
|
elevation: 0,
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
'No projected balance for this period',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var minY = points.first.balance;
|
||||||
|
var maxY = points.first.balance;
|
||||||
|
for (final p in points) {
|
||||||
|
if (p.balance < minY) minY = p.balance;
|
||||||
|
if (p.balance > maxY) maxY = p.balance;
|
||||||
|
}
|
||||||
|
if (minY == maxY) {
|
||||||
|
minY -= 1;
|
||||||
|
maxY += 1;
|
||||||
|
}
|
||||||
|
final pad = (maxY - minY).abs() * 0.12;
|
||||||
|
final safeMin = minY - pad;
|
||||||
|
final safeMax = maxY + pad;
|
||||||
|
final interval = ((safeMax - safeMin) / 3).abs();
|
||||||
|
final safeInterval = interval <= 0 ? 1.0 : interval;
|
||||||
|
|
||||||
|
final spots = [
|
||||||
|
for (var i = 0; i < points.length; i++)
|
||||||
|
FlSpot(i.toDouble(), points[i].balance),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
color: colors.surfaceContainer,
|
||||||
|
elevation: 0,
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 14, 14, 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Projected balance',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
height: 220,
|
||||||
|
child: LineChart(
|
||||||
|
LineChartData(
|
||||||
|
minX: 0,
|
||||||
|
maxX: (points.length - 1).toDouble(),
|
||||||
|
minY: safeMin,
|
||||||
|
maxY: safeMax,
|
||||||
|
lineTouchData: LineTouchData(
|
||||||
|
handleBuiltInTouches: true,
|
||||||
|
touchTooltipData: LineTouchTooltipData(
|
||||||
|
getTooltipItems: (touched) {
|
||||||
|
return [
|
||||||
|
for (final spot in touched)
|
||||||
|
if (spot.x.round() >= 0 &&
|
||||||
|
spot.x.round() < points.length)
|
||||||
|
LineTooltipItem(
|
||||||
|
'${points[spot.x.round()].label}\n'
|
||||||
|
'${formatMoney(points[spot.x.round()].balance, currency)}',
|
||||||
|
theme.textTheme.labelMedium!.copyWith(
|
||||||
|
color: colors.onInverseSurface,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
titlesData: FlTitlesData(
|
||||||
|
topTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
rightTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
leftTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
reservedSize: 44,
|
||||||
|
interval: safeInterval,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
if ((value - safeMax).abs() < 0.01 ||
|
||||||
|
(value - safeMin).abs() < 0.01) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return Text(
|
||||||
|
_compactMoney(value),
|
||||||
|
style: theme.textTheme.labelSmall,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bottomTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
reservedSize: 28,
|
||||||
|
interval: 1,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
final index = value.round();
|
||||||
|
if (index < 0 || index >= points.length) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
if (period == _PeriodMode.yearly &&
|
||||||
|
points.length > 8 &&
|
||||||
|
index % 2 == 1) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Text(
|
||||||
|
points[index].label,
|
||||||
|
style: theme.textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
gridData: FlGridData(
|
||||||
|
show: true,
|
||||||
|
drawVerticalLine: false,
|
||||||
|
horizontalInterval: safeInterval,
|
||||||
|
getDrawingHorizontalLine: (value) => FlLine(
|
||||||
|
color: colors.outlineVariant.withValues(alpha: 0.35),
|
||||||
|
strokeWidth: 1,
|
||||||
|
dashArray: const [4, 4],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
borderData: FlBorderData(show: false),
|
||||||
|
lineBarsData: [
|
||||||
|
LineChartBarData(
|
||||||
|
spots: spots,
|
||||||
|
isCurved: true,
|
||||||
|
curveSmoothness: 0.18,
|
||||||
|
color: colors.primary,
|
||||||
|
barWidth: 3,
|
||||||
|
isStrokeCapRound: true,
|
||||||
|
dotData: FlDotData(
|
||||||
|
show: true,
|
||||||
|
getDotPainter: (spot, percent, bar, index) {
|
||||||
|
return FlDotCirclePainter(
|
||||||
|
radius: 3.5,
|
||||||
|
color: colors.primary,
|
||||||
|
strokeWidth: 0,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
belowBarData: BarAreaData(
|
||||||
|
show: true,
|
||||||
|
color: colors.primary.withValues(alpha: 0.14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
duration: Duration.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SavingsCard extends StatelessWidget {
|
||||||
|
const _SavingsCard({
|
||||||
|
required this.monthlySavings,
|
||||||
|
required this.currency,
|
||||||
|
});
|
||||||
|
|
||||||
|
final double monthlySavings;
|
||||||
|
final String currency;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colors = theme.colorScheme;
|
||||||
|
final yearly = monthlySavings * 12;
|
||||||
|
final positive = monthlySavings >= 0;
|
||||||
|
final tone = positive ? AppTheme.accent : AppTheme.expense;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
color: colors.surfaceContainer,
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: tone.withValues(alpha: 0.18),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(Icons.savings_outlined, color: tone, size: 22),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _SavingsMetric(
|
||||||
|
label: 'Monthly savings',
|
||||||
|
amount: formatMoney(monthlySavings, currency),
|
||||||
|
color: tone,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _SavingsMetric(
|
||||||
|
label: 'Yearly savings',
|
||||||
|
amount: formatMoney(yearly, currency),
|
||||||
|
color: tone,
|
||||||
|
alignEnd: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SavingsMetric extends StatelessWidget {
|
||||||
|
const _SavingsMetric({
|
||||||
|
required this.label,
|
||||||
|
required this.amount,
|
||||||
|
required this.color,
|
||||||
|
this.alignEnd = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String amount;
|
||||||
|
final Color color;
|
||||||
|
final bool alignEnd;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colors = theme.colorScheme;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
alignEnd ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label.toUpperCase(),
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
amount,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _compactMoney(double value) {
|
||||||
|
final abs = value.abs();
|
||||||
|
if (abs >= 1000000) return '${(value / 1000000).toStringAsFixed(1)}M';
|
||||||
|
if (abs >= 1000) return '${(value / 1000).toStringAsFixed(1)}k';
|
||||||
|
if (abs >= 100) return value.toStringAsFixed(0);
|
||||||
|
return value.toStringAsFixed(abs < 10 && abs > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
class _PeriodToolbar extends StatelessWidget {
|
class _PeriodToolbar extends StatelessWidget {
|
||||||
const _PeriodToolbar({
|
const _PeriodToolbar({
|
||||||
required this.period,
|
required this.period,
|
||||||
|
|||||||
+192
-135
@@ -957,10 +957,12 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
|||||||
final resolvedLabelIds = labelId != null
|
final resolvedLabelIds = labelId != null
|
||||||
? {labelId}
|
? {labelId}
|
||||||
: (labelIds ??
|
: (labelIds ??
|
||||||
{
|
(_metric == OverviewMetricMode.labels
|
||||||
// Open with the same labels currently shown on the chart.
|
? {
|
||||||
for (final id in _includedLabelIds) id,
|
// Open with the same labels currently shown on the chart.
|
||||||
});
|
for (final id in _includedLabelIds) id,
|
||||||
|
}
|
||||||
|
: <String>{}));
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
builder: (_) => TransactionsScreen(
|
builder: (_) => TransactionsScreen(
|
||||||
@@ -968,7 +970,8 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
|||||||
initialLabelIds: resolvedLabelIds,
|
initialLabelIds: resolvedLabelIds,
|
||||||
initialLabelMatch: LabelMatchMode.or,
|
initialLabelMatch: LabelMatchMode.or,
|
||||||
initialFlow: flow,
|
initialFlow: flow,
|
||||||
initialSearchInMonthOnly: !yearly && resolvedLabelIds.isNotEmpty,
|
// A specific bar/period should always scope to that month (or year).
|
||||||
|
initialSearchInMonthOnly: !yearly,
|
||||||
initialYear: yearly ? period.year : null,
|
initialYear: yearly ? period.year : null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1615,19 +1618,23 @@ class _ChartCard extends StatelessWidget {
|
|||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
shape: RoundedRectangleBorder(borderRadius: radius),
|
shape: RoundedRectangleBorder(borderRadius: radius),
|
||||||
// Whole card opens the chart filter; nested buttons/legend keep their own taps.
|
// Header/legend open the chart-wide filter; bars handle their own taps.
|
||||||
child: InkWell(
|
child: Padding(
|
||||||
onTap: onChartTap,
|
padding: EdgeInsets.fromLTRB(
|
||||||
borderRadius: radius,
|
compact ? 12 : 14,
|
||||||
child: Padding(
|
compact ? 10 : 12,
|
||||||
padding: EdgeInsets.fromLTRB(
|
compact ? 10 : 14,
|
||||||
compact ? 12 : 14,
|
compact ? 12 : 16,
|
||||||
compact ? 10 : 12,
|
),
|
||||||
compact ? 10 : 14,
|
child: Column(
|
||||||
compact ? 12 : 16,
|
children: [
|
||||||
),
|
Material(
|
||||||
child: Column(
|
color: Colors.transparent,
|
||||||
children: [
|
child: InkWell(
|
||||||
|
onTap: onChartTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
if (compact) ...[
|
if (compact) ...[
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -1774,6 +1781,10 @@ class _ChartCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: chartHeight,
|
height: chartHeight,
|
||||||
child: metric == OverviewMetricMode.flow
|
child: metric == OverviewMetricMode.flow
|
||||||
@@ -1800,7 +1811,6 @@ class _ChartCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1858,104 +1868,122 @@ class _FlowBars extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return GestureDetector(
|
return BarChart(
|
||||||
behavior: HitTestBehavior.opaque,
|
BarChartData(
|
||||||
onTap: onChartTap,
|
maxY: safeMax,
|
||||||
child: IgnorePointer(
|
groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10,
|
||||||
// fl_chart otherwise owns the gesture arena and empty taps go nowhere.
|
barTouchData: BarTouchData(
|
||||||
// Tap opens this chart's filters; legend / history still open columns.
|
handleBuiltInTouches: false,
|
||||||
child: BarChart(
|
touchTooltipData: BarTouchTooltipData(
|
||||||
BarChartData(
|
getTooltipItem: (group, groupIndex, rod, rodIndex) => null,
|
||||||
maxY: safeMax,
|
|
||||||
groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10,
|
|
||||||
barTouchData: const BarTouchData(enabled: false),
|
|
||||||
titlesData: FlTitlesData(
|
|
||||||
topTitles:
|
|
||||||
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
||||||
rightTitles:
|
|
||||||
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
||||||
leftTitles: AxisTitles(
|
|
||||||
sideTitles: SideTitles(
|
|
||||||
showTitles: true,
|
|
||||||
reservedSize: 42,
|
|
||||||
interval: safeMax / 3,
|
|
||||||
getTitlesWidget: (value, meta) {
|
|
||||||
if (value <= 0) {
|
|
||||||
return Text('0', style: theme.textTheme.labelSmall);
|
|
||||||
}
|
|
||||||
if ((value - safeMax).abs() < 0.01) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
return Text(
|
|
||||||
_compactMoney(value),
|
|
||||||
style: theme.textTheme.labelSmall,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
bottomTitles: AxisTitles(
|
|
||||||
sideTitles: SideTitles(
|
|
||||||
showTitles: true,
|
|
||||||
reservedSize: 28,
|
|
||||||
getTitlesWidget: (value, meta) {
|
|
||||||
final index = value.toInt();
|
|
||||||
if (index < 0 || index >= months.length) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
if (period == OverviewPeriodMode.monthly &&
|
|
||||||
index % 2 == 1 &&
|
|
||||||
months.length > 8) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 8),
|
|
||||||
child: Text(
|
|
||||||
_periodAxisLabel(months[index], period),
|
|
||||||
style: theme.textTheme.labelSmall,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
gridData: FlGridData(
|
|
||||||
show: true,
|
|
||||||
drawVerticalLine: false,
|
|
||||||
horizontalInterval: safeMax / 3,
|
|
||||||
getDrawingHorizontalLine: (value) => FlLine(
|
|
||||||
color: colors.outlineVariant.withValues(alpha: 0.35),
|
|
||||||
strokeWidth: 1,
|
|
||||||
dashArray: const [4, 4],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
borderData: FlBorderData(show: false),
|
|
||||||
barGroups: [
|
|
||||||
for (var i = 0; i < months.length; i++)
|
|
||||||
BarChartGroupData(
|
|
||||||
x: i,
|
|
||||||
barsSpace: 3,
|
|
||||||
barRods: [
|
|
||||||
BarChartRodData(
|
|
||||||
toY: monthly[_periodKey(months[i], period)]!.expense,
|
|
||||||
width: period == OverviewPeriodMode.yearly ? 14 : 9,
|
|
||||||
borderRadius:
|
|
||||||
const BorderRadius.vertical(top: Radius.circular(6)),
|
|
||||||
color: _kExpenseColor,
|
|
||||||
),
|
|
||||||
BarChartRodData(
|
|
||||||
toY: monthly[_periodKey(months[i], period)]!.income,
|
|
||||||
width: period == OverviewPeriodMode.yearly ? 14 : 9,
|
|
||||||
borderRadius:
|
|
||||||
const BorderRadius.vertical(top: Radius.circular(6)),
|
|
||||||
color: _kIncomeColor,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
duration: Duration.zero,
|
touchCallback: (event, response) {
|
||||||
|
if (event is! FlTapUpEvent) return;
|
||||||
|
final spot = response?.spot;
|
||||||
|
if (spot == null) {
|
||||||
|
onChartTap();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final index = spot.touchedBarGroupIndex;
|
||||||
|
if (index < 0 || index >= months.length) {
|
||||||
|
onChartTap();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final rod = spot.touchedRodDataIndex;
|
||||||
|
// Rod 0 = expense, rod 1 = income (see barGroups below).
|
||||||
|
final flow = rod == 0
|
||||||
|
? TxFlowFilter.expense
|
||||||
|
: rod == 1
|
||||||
|
? TxFlowFilter.income
|
||||||
|
: null;
|
||||||
|
onMonthTap(months[index], flow: flow);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
titlesData: FlTitlesData(
|
||||||
|
topTitles:
|
||||||
|
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||||
|
rightTitles:
|
||||||
|
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||||
|
leftTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
reservedSize: 42,
|
||||||
|
interval: safeMax / 3,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
if (value <= 0) {
|
||||||
|
return Text('0', style: theme.textTheme.labelSmall);
|
||||||
|
}
|
||||||
|
if ((value - safeMax).abs() < 0.01) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return Text(
|
||||||
|
_compactMoney(value),
|
||||||
|
style: theme.textTheme.labelSmall,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bottomTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
reservedSize: 28,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
final index = value.toInt();
|
||||||
|
if (index < 0 || index >= months.length) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
if (period == OverviewPeriodMode.monthly &&
|
||||||
|
index % 2 == 1 &&
|
||||||
|
months.length > 8) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Text(
|
||||||
|
_periodAxisLabel(months[index], period),
|
||||||
|
style: theme.textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
gridData: FlGridData(
|
||||||
|
show: true,
|
||||||
|
drawVerticalLine: false,
|
||||||
|
horizontalInterval: safeMax / 3,
|
||||||
|
getDrawingHorizontalLine: (value) => FlLine(
|
||||||
|
color: colors.outlineVariant.withValues(alpha: 0.35),
|
||||||
|
strokeWidth: 1,
|
||||||
|
dashArray: const [4, 4],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
borderData: FlBorderData(show: false),
|
||||||
|
barGroups: [
|
||||||
|
for (var i = 0; i < months.length; i++)
|
||||||
|
BarChartGroupData(
|
||||||
|
x: i,
|
||||||
|
barsSpace: 3,
|
||||||
|
barRods: [
|
||||||
|
BarChartRodData(
|
||||||
|
toY: monthly[_periodKey(months[i], period)]!.expense,
|
||||||
|
width: period == OverviewPeriodMode.yearly ? 14 : 9,
|
||||||
|
borderRadius:
|
||||||
|
const BorderRadius.vertical(top: Radius.circular(6)),
|
||||||
|
color: _kExpenseColor,
|
||||||
|
),
|
||||||
|
BarChartRodData(
|
||||||
|
toY: monthly[_periodKey(months[i], period)]!.income,
|
||||||
|
width: period == OverviewPeriodMode.yearly ? 14 : 9,
|
||||||
|
borderRadius:
|
||||||
|
const BorderRadius.vertical(top: Radius.circular(6)),
|
||||||
|
color: _kIncomeColor,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
duration: Duration.zero,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2165,16 +2193,47 @@ class _LabelMonthBars extends StatelessWidget {
|
|||||||
final axisInterval = ((safeMax - safeMin) / 3).abs();
|
final axisInterval = ((safeMax - safeMin) / 3).abs();
|
||||||
final interval = axisInterval <= 0 ? 1.0 : axisInterval;
|
final interval = axisInterval <= 0 ? 1.0 : axisInterval;
|
||||||
|
|
||||||
return GestureDetector(
|
return BarChart(
|
||||||
behavior: HitTestBehavior.opaque,
|
BarChartData(
|
||||||
onTap: onChartTap,
|
minY: safeMin,
|
||||||
child: IgnorePointer(
|
maxY: safeMax,
|
||||||
child: BarChart(
|
groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10,
|
||||||
BarChartData(
|
barTouchData: BarTouchData(
|
||||||
minY: safeMin,
|
handleBuiltInTouches: false,
|
||||||
maxY: safeMax,
|
touchTooltipData: BarTouchTooltipData(
|
||||||
groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10,
|
getTooltipItem: (group, groupIndex, rod, rodIndex) => null,
|
||||||
barTouchData: const BarTouchData(enabled: false),
|
),
|
||||||
|
touchCallback: (event, response) {
|
||||||
|
if (event is! FlTapUpEvent) return;
|
||||||
|
final spot = response?.spot;
|
||||||
|
if (spot == null) {
|
||||||
|
onChartTap();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final index = spot.touchedBarGroupIndex;
|
||||||
|
if (index < 0 || index >= months.length) {
|
||||||
|
onChartTap();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final month = months[index];
|
||||||
|
if (stacked) {
|
||||||
|
final stackIndex = spot.touchedStackItemIndex;
|
||||||
|
if (stackIndex >= 0 && stackIndex < topLabels.length) {
|
||||||
|
onMonthTap(month, labelId: topLabels[stackIndex].label.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// "Other" segment or whole column → that period with chart labels.
|
||||||
|
onMonthTap(month);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final rod = spot.touchedRodDataIndex;
|
||||||
|
if (rod >= 0 && rod < topLabels.length) {
|
||||||
|
onMonthTap(month, labelId: topLabels[rod].label.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onMonthTap(month);
|
||||||
|
},
|
||||||
|
),
|
||||||
titlesData: FlTitlesData(
|
titlesData: FlTitlesData(
|
||||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||||
rightTitles:
|
rightTitles:
|
||||||
@@ -2237,18 +2296,16 @@ class _LabelMonthBars extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
borderData: FlBorderData(show: false),
|
borderData: FlBorderData(show: false),
|
||||||
barGroups: [
|
barGroups: [
|
||||||
for (var i = 0; i < months.length; i++)
|
for (var i = 0; i < months.length; i++)
|
||||||
BarChartGroupData(
|
BarChartGroupData(
|
||||||
x: i,
|
x: i,
|
||||||
barsSpace: stacked ? 0 : 2,
|
barsSpace: stacked ? 0 : 2,
|
||||||
barRods: rodsFor(months[i]),
|
barRods: rodsFor(months[i]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
duration: Duration.zero,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
duration: Duration.zero,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
|||||||
await meta.setLabelsForMany(
|
await meta.setLabelsForMany(
|
||||||
targets,
|
targets,
|
||||||
_draftLabelIds.toList(growable: false),
|
_draftLabelIds.toList(growable: false),
|
||||||
|
matchPattern: toMatched ? _pattern : null,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ class AppTextField extends StatelessWidget {
|
|||||||
this.keyboardType,
|
this.keyboardType,
|
||||||
this.validator,
|
this.validator,
|
||||||
this.autofocus = false,
|
this.autofocus = false,
|
||||||
|
this.autofillHints,
|
||||||
|
this.obscureText = false,
|
||||||
|
this.enableSuggestions,
|
||||||
|
this.autocorrect,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
@@ -24,6 +28,10 @@ class AppTextField extends StatelessWidget {
|
|||||||
final TextInputType? keyboardType;
|
final TextInputType? keyboardType;
|
||||||
final String? Function(String?)? validator;
|
final String? Function(String?)? validator;
|
||||||
final bool autofocus;
|
final bool autofocus;
|
||||||
|
final Iterable<String>? autofillHints;
|
||||||
|
final bool obscureText;
|
||||||
|
final bool? enableSuggestions;
|
||||||
|
final bool? autocorrect;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -31,8 +39,12 @@ class AppTextField extends StatelessWidget {
|
|||||||
controller: controller,
|
controller: controller,
|
||||||
maxLines: maxLines,
|
maxLines: maxLines,
|
||||||
autofocus: autofocus,
|
autofocus: autofocus,
|
||||||
|
obscureText: obscureText,
|
||||||
textInputAction: textInputAction,
|
textInputAction: textInputAction,
|
||||||
keyboardType: keyboardType,
|
keyboardType: keyboardType,
|
||||||
|
autofillHints: autofillHints,
|
||||||
|
enableSuggestions: enableSuggestions ?? !obscureText,
|
||||||
|
autocorrect: autocorrect ?? !obscureText,
|
||||||
validator: validator,
|
validator: validator,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: label,
|
labelText: label,
|
||||||
|
|||||||
Reference in New Issue
Block a user