4 Commits
Author SHA1 Message Date
davmar a93dbbe30d categorize multiple tags 2026-10-15 18:33:04 +02:00
davmar 22a535a6fd pass suggestions 2026-09-17 15:45:11 +02:00
davmar bd3bc5357e projected balance chart 2026-09-17 01:23:07 +02:00
davmar a23739d717 bar tap correct 2026-09-17 01:00:11 +02:00
6 changed files with 838 additions and 296 deletions
+23 -2
View File
@@ -521,10 +521,15 @@ class TxMetaController extends ChangeNotifier {
}
/// 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(
List<BankTransaction> txs,
List<String> labelIds,
) async {
List<String> labelIds, {
String? matchPattern,
}) async {
if (txs.isEmpty) return;
final expanded = expandWithAncestors([
@@ -545,6 +550,22 @@ class TxMetaController extends ChangeNotifier {
: current.copyWith(labelIds: unique);
}
_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();
}
+13 -1
View File
@@ -2,6 +2,7 @@
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pocketbase/pocketbase.dart';
import 'package:provider/provider.dart';
@@ -46,6 +47,8 @@ class _LoginScreenState extends State<LoginScreen> {
email: _emailController.text,
password: _passwordController.text,
);
// Lets iOS/Android password managers offer to save / confirm the login.
TextInput.finishAutofillContext();
if (!mounted) return;
if (!widget.asRoot) {
Navigator.of(context).pop(true);
@@ -96,7 +99,8 @@ class _LoginScreenState extends State<LoginScreen> {
),
),
const SizedBox(height: 24),
Form(
AutofillGroup(
child: Form(
key: _formKey,
child: Column(
children: [
@@ -106,6 +110,10 @@ class _LoginScreenState extends State<LoginScreen> {
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autofocus: true,
autofillHints: const [
AutofillHints.email,
AutofillHints.username,
],
validator: (value) {
final text = value?.trim() ?? '';
if (text.isEmpty) return 'Enter your email';
@@ -118,6 +126,9 @@ class _LoginScreenState extends State<LoginScreen> {
controller: _passwordController,
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password],
enableSuggestions: false,
autocorrect: false,
onFieldSubmitted: (_) => _submit(),
validator: (value) {
if (value == null || value.isEmpty) {
@@ -160,6 +171,7 @@ class _LoginScreenState extends State<LoginScreen> {
],
),
),
),
],
),
),
+465 -26
View File
@@ -3,6 +3,7 @@ library;
import 'dart:async';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -33,6 +34,18 @@ class _ItemsSnapshot {
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 {
const LookAheadScreen({super.key});
@@ -48,6 +61,7 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
_ItemsSnapshot? _snapshot;
int? _loadingKey;
int _computeGen = 0;
var _showChart = false;
@override
void initState() {
@@ -63,14 +77,15 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
/// Inclusive end date for the selected period.
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, _cursor.month + 1, 0);
}
String get _periodTitle {
if (_period == _PeriodMode.yearly) return '${_cursor.year}';
if (_showChart || _period == _PeriodMode.yearly) return '${_cursor.year}';
return formatMonthYear(_cursor);
}
@@ -79,28 +94,32 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
return DateTime(t.year + maxYears, t.month);
}
int _maxYear(int maxYears) => _today.year + maxYears;
bool get _navByYear => _showChart || _period == _PeriodMode.yearly;
bool _canGoBack() {
if (_period == _PeriodMode.yearly) {
return _cursor.year > _today.year;
}
if (_navByYear) return _cursor.year > _today.year;
return monthStart(_cursor).isAfter(_minMonth);
}
bool _canGoForward(int maxYears) {
if (_period == _PeriodMode.yearly) {
return _cursor.year < _today.year + maxYears;
}
if (_navByYear) return _cursor.year < _maxYear(maxYears);
return monthStart(_cursor).isBefore(_maxMonthFor(maxYears));
}
void _clampCursor(int maxYears) {
if (_period == _PeriodMode.yearly) {
if (_cursor.year > _today.year + maxYears) {
_cursor = DateTime(_today.year + maxYears);
if (_navByYear) {
if (_cursor.year > _maxYear(maxYears)) {
_cursor = DateTime(_maxYear(maxYears));
}
if (_cursor.year < _today.year) {
_cursor = DateTime(_today.year);
}
} else {
final maxMonth = _maxMonthFor(maxYears);
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;
setState(() {
_period = mode;
if (mode == _PeriodMode.yearly) {
if (mode == _PeriodMode.yearly || _showChart) {
_cursor = DateTime(_cursor.year);
if (_cursor.year < _today.year) _cursor = DateTime(_today.year);
if (_cursor.year > _today.year + maxYears) {
_cursor = DateTime(_today.year + maxYears);
}
_clampCursor(maxYears);
} else {
final month = DateTime(_cursor.year, _today.month);
_cursor = month.isBefore(_minMonth) ? _minMonth : monthStart(month);
final maxMonth = _maxMonthFor(maxYears);
if (_cursor.isAfter(maxMonth)) _cursor = maxMonth;
_clampCursor(maxYears);
}
});
}
void _go(int delta, int maxYears) {
setState(() {
if (_period == _PeriodMode.yearly) {
if (_navByYear) {
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;
}
_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) =>
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.
void _ensureItemsLoaded({
required int cacheKey,
@@ -261,7 +347,16 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
return Scaffold(
appBar: AppBar(
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…'),
);
@@ -303,7 +398,16 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
return Scaffold(
appBar: AppBar(
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(
child: ListView(
@@ -321,10 +425,8 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
canGoForward: _canGoForward(maxYears),
onBack: () => _go(-1, maxYears),
onForward: () => _go(1, maxYears),
backTooltip:
_period == _PeriodMode.yearly ? 'Previous year' : 'Previous month',
forwardTooltip:
_period == _PeriodMode.yearly ? 'Next year' : 'Next month',
backTooltip: _navByYear ? 'Previous year' : 'Previous month',
forwardTooltip: _navByYear ? 'Next year' : 'Next month',
),
const SizedBox(height: 12),
Material(
@@ -408,6 +510,28 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
),
],
),
if (_showChart) ...[
const SizedBox(height: 16),
_LookAheadBalanceChart(
points: _balanceSeries(
items: items,
currentBalance: currentAmount,
maxYears: maxYears,
),
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: [
@@ -449,6 +573,11 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
],
),
const SizedBox(height: 10),
_SavingsCard(
monthlySavings: monthlyIncome - monthlyExpense,
currency: currency,
),
const SizedBox(height: 10),
if (items.isEmpty)
const EmptyState(
icon: Icons.event_repeat_outlined,
@@ -485,12 +614,322 @@ 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 {
const _PeriodToolbar({
required this.period,
+83 -26
View File
@@ -957,10 +957,12 @@ class _OverviewScreenState extends State<OverviewScreen> {
final resolvedLabelIds = labelId != null
? {labelId}
: (labelIds ??
{
(_metric == OverviewMetricMode.labels
? {
// Open with the same labels currently shown on the chart.
for (final id in _includedLabelIds) id,
});
}
: <String>{}));
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => TransactionsScreen(
@@ -968,7 +970,8 @@ class _OverviewScreenState extends State<OverviewScreen> {
initialLabelIds: resolvedLabelIds,
initialLabelMatch: LabelMatchMode.or,
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,
),
),
@@ -1615,10 +1618,7 @@ class _ChartCard extends StatelessWidget {
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: radius),
// Whole card opens the chart filter; nested buttons/legend keep their own taps.
child: InkWell(
onTap: onChartTap,
borderRadius: radius,
// Header/legend open the chart-wide filter; bars handle their own taps.
child: Padding(
padding: EdgeInsets.fromLTRB(
compact ? 12 : 14,
@@ -1626,6 +1626,13 @@ class _ChartCard extends StatelessWidget {
compact ? 10 : 14,
compact ? 12 : 16,
),
child: Column(
children: [
Material(
color: Colors.transparent,
child: InkWell(
onTap: onChartTap,
borderRadius: BorderRadius.circular(12),
child: Column(
children: [
if (compact) ...[
@@ -1774,6 +1781,10 @@ class _ChartCard extends StatelessWidget {
const SizedBox(height: 8),
],
],
],
),
),
),
SizedBox(
height: chartHeight,
child: metric == OverviewMetricMode.flow
@@ -1800,7 +1811,6 @@ class _ChartCard extends StatelessWidget {
],
),
),
),
);
}
}
@@ -1858,17 +1868,37 @@ class _FlowBars extends StatelessWidget {
);
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onChartTap,
child: IgnorePointer(
// fl_chart otherwise owns the gesture arena and empty taps go nowhere.
// Tap opens this chart's filters; legend / history still open columns.
child: BarChart(
return BarChart(
BarChartData(
maxY: safeMax,
groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10,
barTouchData: const BarTouchData(enabled: false),
barTouchData: BarTouchData(
handleBuiltInTouches: false,
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) => null,
),
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)),
@@ -1954,8 +1984,6 @@ class _FlowBars extends StatelessWidget {
],
),
duration: Duration.zero,
),
),
);
}
}
@@ -2165,16 +2193,47 @@ class _LabelMonthBars extends StatelessWidget {
final axisInterval = ((safeMax - safeMin) / 3).abs();
final interval = axisInterval <= 0 ? 1.0 : axisInterval;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onChartTap,
child: IgnorePointer(
child: BarChart(
return BarChart(
BarChartData(
minY: safeMin,
maxY: safeMax,
groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10,
barTouchData: const BarTouchData(enabled: false),
barTouchData: BarTouchData(
handleBuiltInTouches: false,
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) => null,
),
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(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles:
@@ -2247,8 +2306,6 @@ class _LabelMonthBars extends StatelessWidget {
],
),
duration: Duration.zero,
),
),
);
}
}
@@ -188,6 +188,7 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
await meta.setLabelsForMany(
targets,
_draftLabelIds.toList(growable: false),
matchPattern: toMatched ? _pattern : null,
);
if (!mounted) return;
setState(() {
+12
View File
@@ -14,6 +14,10 @@ class AppTextField extends StatelessWidget {
this.keyboardType,
this.validator,
this.autofocus = false,
this.autofillHints,
this.obscureText = false,
this.enableSuggestions,
this.autocorrect,
});
final TextEditingController controller;
@@ -24,6 +28,10 @@ class AppTextField extends StatelessWidget {
final TextInputType? keyboardType;
final String? Function(String?)? validator;
final bool autofocus;
final Iterable<String>? autofillHints;
final bool obscureText;
final bool? enableSuggestions;
final bool? autocorrect;
@override
Widget build(BuildContext context) {
@@ -31,8 +39,12 @@ class AppTextField extends StatelessWidget {
controller: controller,
maxLines: maxLines,
autofocus: autofocus,
obscureText: obscureText,
textInputAction: textInputAction,
keyboardType: keyboardType,
autofillHints: autofillHints,
enableSuggestions: enableSuggestions ?? !obscureText,
autocorrect: autocorrect ?? !obscureText,
validator: validator,
decoration: InputDecoration(
labelText: label,