sync transactions with pocketbase

This commit is contained in:
davmar
2026-09-09 22:24:16 +02:00
parent c6d1fdb19e
commit 96627a830e
9 changed files with 1104 additions and 324 deletions
+14 -1
View File
@@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
import 'providers/auth_provider.dart';
import 'providers/bank_provider.dart';
import 'providers/sync_provider.dart';
import 'providers/theme_provider.dart';
import 'providers/tx_meta_provider.dart';
import 'screens/splash_screen.dart';
@@ -17,6 +18,7 @@ import 'utils/app_config.dart';
import 'utils/app_theme.dart';
import 'utils/constants.dart';
import 'widgets/skip_build_when_covered.dart';
import 'widgets/sync_feedback_listener.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -45,12 +47,16 @@ class MyApp extends StatefulWidget {
class _MyAppState extends State<MyApp> {
late final BankController _bank;
late final TxMetaController _meta;
late final SyncController _sync;
late final AppLinks _appLinks;
StreamSubscription<Uri>? _linkSub;
@override
void initState() {
super.initState();
_meta = TxMetaController(widget.storage);
_sync = SyncController(auth: widget.auth, meta: _meta);
_bank = BankController(storage: widget.storage)..refresh();
_appLinks = AppLinks();
_listenForBankRedirects();
@@ -79,6 +85,7 @@ class _MyAppState extends State<MyApp> {
@override
void dispose() {
_linkSub?.cancel();
_sync.dispose();
_bank.dispose();
super.dispose();
}
@@ -89,7 +96,8 @@ class _MyAppState extends State<MyApp> {
providers: [
ChangeNotifierProvider<AuthController>.value(value: widget.auth),
ChangeNotifierProvider(create: (_) => ThemeController(widget.storage)),
ChangeNotifierProvider(create: (_) => TxMetaController(widget.storage)),
ChangeNotifierProvider<TxMetaController>.value(value: _meta),
ChangeNotifierProvider<SyncController>.value(value: _sync),
ChangeNotifierProvider<BankController>.value(value: _bank),
],
child: const _MaterialRoot(),
@@ -111,6 +119,11 @@ class _MaterialRoot extends StatelessWidget {
darkTheme: AppTheme.dark(),
themeMode: themeMode,
navigatorObservers: [appRouteObserver],
builder: (context, child) {
return SyncFeedbackListener(
child: child ?? const SizedBox.shrink(),
);
},
home: const SplashScreen(),
);
}
+60 -1
View File
@@ -25,6 +25,9 @@ class TxMetaController extends ChangeNotifier {
final StorageService _storage;
/// Invoked after a successful local persist (used by [SyncController]).
VoidCallback? onPersisted;
Map<String, TxMeta> _byId = {};
Map<String, List<String>> _labelsByMerchant = {};
Map<String, String> _notesByMerchant = {};
@@ -32,6 +35,61 @@ class TxMetaController extends ChangeNotifier {
List<TxLabel> _customLabels = [];
Map<String, String> _labelParents = {};
/// Snapshot of the in-memory annotation store.
TxMetaStoreData exportStore() {
return TxMetaStoreData(
byId: Map.of(_byId),
labelsByMerchant: {
for (final e in _labelsByMerchant.entries)
e.key: List<String>.of(e.value),
},
notesByMerchant: Map.of(_notesByMerchant),
exactTemplates: Map.of(_exactTemplates),
customLabels: List.of(_customLabels),
labelParents: Map.of(_labelParents),
);
}
/// Rows in the local annotation map (may include empty stubs).
int get storedAnnotationCount => _byId.length;
/// Annotations that are pushed to PocketBase (label/notes/flags).
int get syncableAnnotationCount {
var n = 0;
for (final meta in _byId.values) {
if (meta.hasLabel ||
meta.hasNotes ||
meta.recurring ||
meta.internalTransfer) {
n++;
}
}
return n;
}
int get merchantRuleCount => {
..._labelsByMerchant.keys,
..._notesByMerchant.keys,
}.length;
int get customLabelCount => _customLabels.length;
/// Replace local store (e.g. after merge) and persist without re-triggering push.
Future<void> replaceStore(TxMetaStoreData store) async {
_byId = Map.of(store.byId);
_labelsByMerchant = {
for (final e in store.labelsByMerchant.entries)
e.key: List<String>.of(e.value),
};
_notesByMerchant = Map.of(store.notesByMerchant);
_exactTemplates = Map.of(store.exactTemplates);
_customLabels = List.of(store.customLabels);
_labelParents = Map.of(store.labelParents);
_invalidateLabelCache();
notifyListeners();
await _writeStore(notifySync: false);
}
/// Built-in + custom labels available for picking.
List<TxLabel> get allLabels => [...kBuiltinLabels, ..._customLabels];
@@ -759,7 +817,7 @@ class TxMetaController extends ChangeNotifier {
unawaited(_writeStore());
}
Future<void> _writeStore() async {
Future<void> _writeStore({bool notifySync = true}) async {
// Let the busy indicator / label checkbox paint before JSON work.
await Future<void>.delayed(Duration.zero);
final snapshot = TxMetaStoreData(
@@ -778,6 +836,7 @@ class TxMetaController extends ChangeNotifier {
);
try {
await _storage.saveTxMetaStore(snapshot);
if (notifySync) onPersisted?.call();
} catch (error, stackTrace) {
debugPrint('TxMetaController persist failed: $error\n$stackTrace');
}
+72 -5
View File
@@ -1,10 +1,13 @@
/// Signed-in account details and sign-out.
/// Signed-in account details, sync status, and sign-out.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
import '../providers/bank_provider.dart';
import '../providers/sync_provider.dart';
import '../providers/tx_meta_provider.dart';
import '../utils/constants.dart';
import '../widgets/responsive_body.dart';
@@ -14,9 +17,23 @@ class AccountScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthController>();
final sync = context.watch<SyncController>();
final meta = context.watch<TxMetaController>();
final bank = context.watch<BankController>();
final theme = Theme.of(context);
final colors = theme.colorScheme;
final lastSync = sync.lastSyncedAt;
final syncSubtitle = sync.isSyncing
? (sync.statusDetail.isEmpty
? 'Syncing… ${(sync.syncProgress * 100).round()}%'
: '${sync.statusDetail} · ${(sync.syncProgress * 100).round()}%')
: sync.lastError != null
? 'Last error: ${sync.lastError}'
: lastSync == null
? 'Not synced yet'
: 'Last sync ${_formatTime(lastSync)}';
return Scaffold(
appBar: AppBar(title: const Text('Account')),
body: ResponsiveBody(
@@ -63,12 +80,55 @@ class AccountScreen extends StatelessWidget {
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.phone_android_outlined),
title: const Text('Local data'),
subtitle: const Text(
'Labels, notes, and bank cache remain on this device.',
leading: sync.isSyncing
? SizedBox(
width: 24,
height: 24,
child: Padding(
padding: const EdgeInsets.all(2),
child: CircularProgressIndicator(
strokeWidth: 2,
value: sync.syncProgress <= 0
? null
: sync.syncProgress.clamp(0.02, 1.0),
),
),
)
: Icon(
sync.lastError == null
? Icons.sync
: Icons.sync_problem_outlined,
),
title: const Text('Cloud sync'),
subtitle: Text(syncSubtitle),
trailing: TextButton(
onPressed: sync.isSyncing ? null : () => sync.syncNow(),
child: const Text('Sync now'),
),
),
if (sync.lastSyncSummary.isNotEmpty) ...[
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.fact_check_outlined),
title: const Text('Last sync detail'),
subtitle: Text(sync.lastSyncSummary),
),
],
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.phone_android_outlined),
title: const Text('What syncs'),
subtitle: Text(
'Bank cache stays on this device '
'(${bank.transactions.length} transactions).\n'
'Cloud gets labels/notes only: '
'${meta.syncableAnnotationCount} annotated txs, '
'${meta.merchantRuleCount} merchant rules, '
'${meta.customLabelCount} custom labels.\n'
'PocketBase tx_meta ≈ annotated count, not bank total.',
),
isThreeLine: true,
),
],
),
),
@@ -87,4 +147,11 @@ class AccountScreen extends StatelessWidget {
),
);
}
static String _formatTime(DateTime time) {
final local = time.toLocal();
final h = local.hour.toString().padLeft(2, '0');
final m = local.minute.toString().padLeft(2, '0');
return '$h:$m';
}
}
+396 -183
View File
@@ -45,6 +45,12 @@ class _OverviewScreenState extends State<OverviewScreen> {
/// Labels chart flow filter (defaults to expenses / labeled spend).
var _labelFlow = TxFlowFilter.expense;
/// Parallel (default) vs stacked bars on the Labels chart.
var _labelBarStyle = LabelChartBarStyle.parallel;
/// When flow is All: flip signs so expenses plot upward.
var _labelInvertChart = false;
/// Selected calendar year for Monthly mode (`DateTime(year)`).
late DateTime _cursor;
@@ -129,11 +135,18 @@ class _OverviewScreenState extends State<OverviewScreen> {
allowMove: true,
includeFlow: true,
initialFlow: _labelFlow,
includeBarStyle: true,
initialBarStyle: _labelBarStyle,
includeInvertChart: true,
initialInvertChart: _labelInvertChart,
);
if (result == null || !mounted) return;
setState(() {
_labelFlow = result.flow;
_labelBarStyle = result.barStyle;
_labelInvertChart = result.flow == TxFlowFilter.all &&
result.invertExpensePerspective;
final allIds = available.map((l) => l.id).toSet();
if (result.labelIds.isEmpty ||
(result.labelIds.length == allIds.length &&
@@ -221,6 +234,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
_period,
includedLabelIds: _includedLabelIds,
flow: _labelFlow,
invertExpensePerspective: _labelInvertChart,
);
labelSpend = labels.spend;
labelAmountByPeriod = labels.amountByPeriod;
@@ -243,21 +257,28 @@ class _OverviewScreenState extends State<OverviewScreen> {
),
const SizedBox(height: 12),
],
_ChartToolbar(
period: _period,
metric: _metric,
onPeriodChanged: _setPeriod,
onMetricChanged: (value) => setState(() => _metric = value),
),
const SizedBox(height: 10),
_ChartCard(
period: _period,
metric: _metric,
periodTitle: _periodTitle,
canGoBack: _canGoBack,
canGoForward: _canGoForward,
onPeriodChanged: _setPeriod,
onMetricChanged: (value) => setState(() => _metric = value),
onBack: () => _go(-1),
onForward: () => _go(1),
months: periods,
monthly: monthly,
labelSpend: labelSpend,
labelAmountByMonth: labelAmountByPeriod,
labelBarStyle: _labelBarStyle,
currency: currency,
onPickLabels: () => _pickChartLabels(context),
includedLabelCount: _includedLabelIds.isEmpty &&
_labelFlow == TxFlowFilter.expense
? null
@@ -265,7 +286,6 @@ class _OverviewScreenState extends State<OverviewScreen> {
? meta.allLabels.length
: _includedLabelIds.length),
availableLabelCount: meta.allLabels.length,
onPickLabels: () => _pickChartLabels(context),
onLabelTap: (label) {
Navigator.of(context).push(
MaterialPageRoute<void>(
@@ -313,7 +333,9 @@ class _OverviewScreenState extends State<OverviewScreen> {
label: switch (_labelFlow) {
TxFlowFilter.income => 'Labeled income · $_periodTitle',
TxFlowFilter.expense => 'Labeled spend · $_periodTitle',
TxFlowFilter.all => 'Labeled activity · $_periodTitle',
TxFlowFilter.all => _labelInvertChart
? 'Labeled net (expense view) · $_periodTitle'
: 'Labeled net · $_periodTitle',
},
),
],
@@ -351,6 +373,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
monthly: monthly,
labelSpend: labelSpend,
labelAmountByPeriod: labelAmountByPeriod,
labelFlow: _labelFlow,
currency: currency,
onOpenPeriod: (period) => _openPeriod(context, period),
onOpenLabel: (period, label) =>
@@ -367,6 +390,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
required Map<DateTime, _MonthBucket> monthly,
required List<_LabelSpend> labelSpend,
required Map<DateTime, Map<String, double>> labelAmountByPeriod,
required TxFlowFilter labelFlow,
required String currency,
required void Function(DateTime period) onOpenPeriod,
required void Function(DateTime period, TxLabel label) onOpenLabel,
@@ -390,7 +414,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
final bucket = labelAmountByPeriod[key] ?? const {};
final rows = <_LabelSpend>[
for (final row in labelSpend)
if ((bucket[row.label.id] ?? 0) > 0)
if ((bucket[row.label.id] ?? 0) != 0)
_LabelSpend(
label: row.label,
amount: bucket[row.label.id]!,
@@ -402,6 +426,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
month: period,
title: title,
rows: rows,
flow: labelFlow,
currency: currency,
onOpenPeriod: () => onOpenPeriod(period),
onOpenLabel: (label) => onOpenLabel(period, label),
@@ -528,6 +553,10 @@ class _LabelAggregates {
}
/// One pass over labeled amounts: totals, per-period series, and history.
///
/// Amounts are summed with sign (refunds offset spend). For [TxFlowFilter.all]
/// the signed net is kept (can be negative). With [invertExpensePerspective],
/// signs flip so spending plots upward. For In/Out, bars use absolute nets.
_LabelAggregates _labelAggregates(
List<BankTransaction> txs,
TxMetaController meta,
@@ -535,16 +564,19 @@ _LabelAggregates _labelAggregates(
_PeriodMode mode, {
Set<String> includedLabelIds = const {},
TxFlowFilter flow = TxFlowFilter.expense,
bool invertExpensePerspective = false,
}) {
final keys = {for (final p in periods) _periodKey(p, mode)};
final allByLabel = <String, double>{};
final byPeriod = <DateTime, double>{
final byPeriodSigned = <DateTime, double>{
for (final key in keys) key: 0.0,
};
final amountByPeriod = <DateTime, Map<String, double>>{
final amountByPeriodSigned = <DateTime, Map<String, double>>{
for (final key in keys) key: {},
};
final filtering = includedLabelIds.isNotEmpty;
final keepSigned = flow == TxFlowFilter.all;
final invert = keepSigned && invertExpensePerspective;
for (final tx in txs) {
if (meta.metaFor(tx).internalTransfer) continue;
@@ -556,7 +588,7 @@ _LabelAggregates _labelAggregates(
final ids = meta.labelIdsFor(tx);
if (ids.isEmpty) continue;
final amount = tx.amount.abs();
final amount = tx.amount;
for (final id in ids) {
allByLabel[id] = (allByLabel[id] ?? 0) + amount;
@@ -566,19 +598,27 @@ _LabelAggregates _labelAggregates(
!filtering || ids.any(includedLabelIds.contains);
if (!matchesFilter) continue;
byPeriod[key] = byPeriod[key]! + amount;
final bucket = amountByPeriod[key]!;
byPeriodSigned[key] = byPeriodSigned[key]! + amount;
final bucket = amountByPeriodSigned[key]!;
for (final id in ids) {
if (filtering && !includedLabelIds.contains(id)) continue;
bucket[id] = (bucket[id] ?? 0) + amount;
}
}
double displayAmount(double signed) {
if (!keepSigned) return signed.abs();
return invert ? -signed : signed;
}
final allSpend = <_LabelSpend>[
for (final entry in allByLabel.entries)
if (meta.labelById(entry.key) != null)
_LabelSpend(label: meta.labelById(entry.key)!, amount: entry.value),
]..sort((a, b) => b.amount.compareTo(a.amount));
if (meta.labelById(entry.key) != null && entry.value != 0)
_LabelSpend(
label: meta.labelById(entry.key)!,
amount: displayAmount(entry.value),
),
]..sort((a, b) => b.amount.abs().compareTo(a.amount.abs()));
final spend = !filtering
? allSpend
@@ -587,6 +627,19 @@ _LabelAggregates _labelAggregates(
if (includedLabelIds.contains(row.label.id)) row,
];
final byPeriod = <DateTime, double>{
for (final e in byPeriodSigned.entries)
if (e.value != 0) e.key: displayAmount(e.value),
};
final amountByPeriod = <DateTime, Map<String, double>>{
for (final e in amountByPeriodSigned.entries)
e.key: {
for (final labelEntry in e.value.entries)
if (labelEntry.value != 0)
labelEntry.key: displayAmount(labelEntry.value),
},
};
return _LabelAggregates(
spend: spend,
allSpend: allSpend,
@@ -614,11 +667,79 @@ String _shortMonth(DateTime month) {
}
String _compactMoney(double value) {
if (value >= 1000) {
final k = value / 1000;
return k >= 10 ? '${k.round()}k' : '${k.toStringAsFixed(1)}k';
final abs = value.abs();
final sign = value < 0 ? '-' : '';
if (abs >= 1000) {
final k = abs / 1000;
return sign + (k >= 10 ? '${k.round()}k' : '${k.toStringAsFixed(1)}k');
}
return '$sign${abs.round()}';
}
class _ChartToolbar extends StatelessWidget {
const _ChartToolbar({
required this.period,
required this.metric,
required this.onPeriodChanged,
required this.onMetricChanged,
});
final _PeriodMode period;
final _MetricMode metric;
final ValueChanged<_PeriodMode> onPeriodChanged;
final ValueChanged<_MetricMode> onMetricChanged;
static const _segmentStyle = ButtonStyle(
visualDensity: VisualDensity(horizontal: -4, vertical: -3),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 10)),
minimumSize: WidgetStatePropertyAll(Size(0, 32)),
);
@override
Widget build(BuildContext context) {
return Row(
children: [
SegmentedButton<_PeriodMode>(
showSelectedIcon: false,
style: _segmentStyle,
segments: const [
ButtonSegment(
value: _PeriodMode.monthly,
label: Text('Month'),
),
ButtonSegment(
value: _PeriodMode.yearly,
label: Text('Year'),
),
],
selected: {period},
onSelectionChanged: (selection) {
onPeriodChanged(selection.first);
},
),
const Spacer(),
SegmentedButton<_MetricMode>(
showSelectedIcon: false,
style: _segmentStyle,
segments: const [
ButtonSegment(
value: _MetricMode.flow,
label: Text('Flow'),
),
ButtonSegment(
value: _MetricMode.labels,
label: Text('Labels'),
),
],
selected: {metric},
onSelectionChanged: (selection) {
onMetricChanged(selection.first);
},
),
],
);
}
return value.round().toString();
}
class _ChartCard extends StatelessWidget {
@@ -628,20 +749,19 @@ class _ChartCard extends StatelessWidget {
required this.periodTitle,
required this.canGoBack,
required this.canGoForward,
required this.onPeriodChanged,
required this.onMetricChanged,
required this.onBack,
required this.onForward,
required this.months,
required this.monthly,
required this.labelSpend,
required this.labelAmountByMonth,
required this.labelBarStyle,
required this.currency,
required this.onLabelTap,
required this.onMonthTap,
required this.onPickLabels,
required this.includedLabelCount,
required this.availableLabelCount,
required this.onLabelTap,
required this.onMonthTap,
});
final _PeriodMode period;
@@ -649,25 +769,59 @@ class _ChartCard extends StatelessWidget {
final String periodTitle;
final bool canGoBack;
final bool canGoForward;
final ValueChanged<_PeriodMode> onPeriodChanged;
final ValueChanged<_MetricMode> onMetricChanged;
final VoidCallback onBack;
final VoidCallback onForward;
final List<DateTime> months;
final Map<DateTime, _MonthBucket> monthly;
final List<_LabelSpend> labelSpend;
final Map<DateTime, Map<String, double>> labelAmountByMonth;
final LabelChartBarStyle labelBarStyle;
final String currency;
final void Function(TxLabel label) onLabelTap;
final void Function(DateTime month) onMonthTap;
final VoidCallback onPickLabels;
final int? includedLabelCount;
final int availableLabelCount;
final void Function(TxLabel label) onLabelTap;
final void Function(DateTime month) onMonthTap;
Widget _filterButton(ColorScheme colors) {
return IconButton(
tooltip: includedLabelCount == null
? 'Filter labels ($availableLabelCount)'
: 'Filter labels ($includedLabelCount/$availableLabelCount)',
onPressed: onPickLabels,
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: includedLabelCount == null
? colors.surfaceContainerHigh
: colors.primary.withValues(alpha: 0.22),
foregroundColor: includedLabelCount == null
? colors.onSurfaceVariant
: colors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
icon: Badge(
isLabelVisible: includedLabelCount != null,
smallSize: 6,
child: const Icon(Icons.filter_list, size: 20),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final showFilter = metric == _MetricMode.labels;
return Card(
color: colors.surfaceContainer,
@@ -676,105 +830,9 @@ class _ChartCard extends StatelessWidget {
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 16),
padding: const EdgeInsets.fromLTRB(14, 12, 14, 16),
child: Column(
children: [
Row(
children: [
SegmentedButton<_PeriodMode>(
showSelectedIcon: false,
style: const ButtonStyle(
visualDensity: VisualDensity(
horizontal: -4,
vertical: -3,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 10),
),
minimumSize: WidgetStatePropertyAll(Size(0, 32)),
),
segments: const [
ButtonSegment(
value: _PeriodMode.monthly,
label: Text('Month'),
),
ButtonSegment(
value: _PeriodMode.yearly,
label: Text('Year'),
),
],
selected: {period},
onSelectionChanged: (selection) {
onPeriodChanged(selection.first);
},
),
if (metric == _MetricMode.labels) ...[
const SizedBox(width: 4),
IconButton(
tooltip: includedLabelCount == null
? 'Filter labels ($availableLabelCount)'
: 'Filter labels ($includedLabelCount/$availableLabelCount)',
onPressed: onPickLabels,
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: includedLabelCount == null
? colors.surfaceContainerHigh
: colors.primary.withValues(alpha: 0.22),
foregroundColor: includedLabelCount == null
? colors.onSurfaceVariant
: colors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
icon: Badge(
isLabelVisible: includedLabelCount != null,
smallSize: 6,
child: const Icon(Icons.filter_list, size: 18),
),
),
],
const Spacer(),
SegmentedButton<_MetricMode>(
showSelectedIcon: false,
style: const ButtonStyle(
visualDensity: VisualDensity(
horizontal: -4,
vertical: -3,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 10),
),
minimumSize: WidgetStatePropertyAll(Size(0, 32)),
),
segments: const [
ButtonSegment(
value: _MetricMode.flow,
label: Text('Flow'),
),
ButtonSegment(
value: _MetricMode.labels,
label: Text('Labels'),
),
],
selected: {metric},
onSelectionChanged: (selection) {
onMetricChanged(selection.first);
},
),
],
),
const SizedBox(height: 8),
if (period == _PeriodMode.monthly)
Row(
children: [
@@ -815,18 +873,31 @@ class _ChartCard extends StatelessWidget {
padding: EdgeInsets.zero,
icon: const Icon(Icons.chevron_right, size: 22),
),
if (showFilter) ...[
const SizedBox(width: 2),
_filterButton(colors),
],
],
)
else
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
periodTitle,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
Row(
children: [
// Balance the filter so the title stays centered.
SizedBox(width: showFilter ? 36 : 0),
Expanded(
child: Text(
periodTitle,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
),
if (showFilter)
_filterButton(colors)
else
const SizedBox(width: 0),
],
),
if (metric == _MetricMode.flow) ...[
const Row(
@@ -879,6 +950,7 @@ class _ChartCard extends StatelessWidget {
amountByMonth: labelAmountByMonth,
currency: currency,
period: period,
barStyle: labelBarStyle,
onMonthTap: onMonthTap,
),
),
@@ -1045,6 +1117,7 @@ class _LabelMonthBars extends StatelessWidget {
required this.amountByMonth,
required this.currency,
required this.period,
required this.barStyle,
required this.onMonthTap,
});
@@ -1055,6 +1128,7 @@ class _LabelMonthBars extends StatelessWidget {
final Map<DateTime, Map<String, double>> amountByMonth;
final String currency;
final _PeriodMode period;
final LabelChartBarStyle barStyle;
final void Function(DateTime month) onMonthTap;
@override
@@ -1064,7 +1138,7 @@ class _LabelMonthBars extends StatelessWidget {
if (labelSpend.isEmpty) {
return Center(
child: Text(
'No labeled expenses in this period.\nTag transactions to see them here.',
'No labeled activity in this period.\nTag transactions to see them here.',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
@@ -1074,6 +1148,7 @@ class _LabelMonthBars extends StatelessWidget {
final topLabels = labelSpend.take(_maxSeriesLabels).toList(growable: false);
final topIds = {for (final row in topLabels) row.label.id};
final otherColor = colors.outline;
final stacked = barStyle == LabelChartBarStyle.stacked;
double otherFor(DateTime month) {
final bucket =
@@ -1085,41 +1160,144 @@ class _LabelMonthBars extends StatelessWidget {
return other;
}
final hasOther = months.any((m) => otherFor(m) > 0);
final hasOther = months.any((m) => otherFor(m) != 0);
final seriesCount = topLabels.length + (hasOther ? 1 : 0);
var maxY = 0.0;
var minY = 0.0;
for (final month in months) {
final bucket =
amountByMonth[_periodKey(month, period)] ?? const {};
for (final row in topLabels) {
final value = bucket[row.label.id] ?? 0;
if (value > maxY) maxY = value;
if (stacked) {
var pos = 0.0;
var neg = 0.0;
void accumulate(double value) {
if (value > 0) {
pos += value;
} else if (value < 0) {
neg += value;
}
}
for (final row in topLabels) {
accumulate(bucket[row.label.id] ?? 0);
}
accumulate(otherFor(month));
if (pos > maxY) maxY = pos;
if (neg < minY) minY = neg;
} else {
for (final row in topLabels) {
final value = bucket[row.label.id] ?? 0;
if (value > maxY) maxY = value;
if (value < minY) minY = value;
}
final other = otherFor(month);
if (other > maxY) maxY = other;
if (other < minY) minY = other;
}
final other = otherFor(month);
if (other > maxY) maxY = other;
}
final safeMax = maxY <= 0 ? 1.0 : maxY * 1.15;
if (maxY <= 0) {
if (maxY == 0 && minY == 0) {
return Center(
child: Text(
'No labeled expenses in this period.\nTag transactions to see them here.',
'No labeled activity in this period.\nTag transactions to see them here.',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
);
}
final span = (maxY - minY).abs();
final pad = span <= 0 ? 1.0 : span * 0.15;
final safeMax = maxY + (maxY == 0 && minY < 0 ? 0 : pad);
final safeMin = minY - (minY == 0 && maxY > 0 ? 0 : pad);
final rodWidth = period == _PeriodMode.yearly
? (seriesCount <= 3 ? 12.0 : 8.0)
: (seriesCount <= 3
? 7.0
: seriesCount <= 5
? 5.0
: 4.0);
final rodWidth = stacked
? (period == _PeriodMode.yearly ? 18.0 : 14.0)
: period == _PeriodMode.yearly
? (seriesCount <= 3 ? 12.0 : 8.0)
: (seriesCount <= 3
? 7.0
: seriesCount <= 5
? 5.0
: 4.0);
List<BarChartRodData> rodsFor(DateTime month) {
final bucket =
amountByMonth[_periodKey(month, period)] ?? const {};
if (stacked) {
final stackItems = <BarChartRodStackItem>[];
var pos = 0.0;
var neg = 0.0;
void addSegment(double value, Color color) {
if (value > 0) {
stackItems.add(BarChartRodStackItem(pos, pos + value, color));
pos += value;
} else if (value < 0) {
stackItems.add(BarChartRodStackItem(neg + value, neg, color));
neg += value;
}
}
for (final row in topLabels) {
addSegment(bucket[row.label.id] ?? 0, row.label.color);
}
addSegment(otherFor(month), otherColor);
if (stackItems.isEmpty) {
return [
BarChartRodData(
toY: 0,
width: rodWidth,
color: colors.surfaceContainerHighest,
),
];
}
return [
BarChartRodData(
fromY: neg,
toY: pos,
width: rodWidth,
borderRadius: BorderRadius.vertical(
top: pos > 0 ? const Radius.circular(4) : Radius.zero,
bottom: neg < 0 ? const Radius.circular(4) : Radius.zero,
),
color: colors.surfaceContainerHighest,
rodStackItems: stackItems,
),
];
}
BorderRadius rodRadius(double value) {
if (value >= 0) {
return const BorderRadius.vertical(top: Radius.circular(4));
}
return const BorderRadius.vertical(bottom: Radius.circular(4));
}
return [
for (final row in topLabels)
BarChartRodData(
toY: bucket[row.label.id] ?? 0,
width: rodWidth,
borderRadius: rodRadius(bucket[row.label.id] ?? 0),
color: row.label.color,
),
if (hasOther)
BarChartRodData(
toY: otherFor(month),
width: rodWidth,
borderRadius: rodRadius(otherFor(month)),
color: otherColor,
),
];
}
final axisInterval = ((safeMax - safeMin) / 3).abs();
final interval = axisInterval <= 0 ? 1.0 : axisInterval;
return BarChart(
BarChartData(
minY: safeMin,
maxY: safeMax,
groupsSpace: period == _PeriodMode.yearly ? 16 : 10,
barTouchData: BarTouchData(
@@ -1132,6 +1310,32 @@ class _LabelMonthBars extends StatelessWidget {
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) {
final month = months[group.x.toInt()];
if (stacked && rod.rodStackItems.isNotEmpty) {
final bucket =
amountByMonth[_periodKey(month, period)] ?? const {};
final buffer = StringBuffer(
'${_periodAxisLabel(month, period)}\n',
);
var total = 0.0;
for (final row in topLabels) {
final amount = bucket[row.label.id] ?? 0;
if (amount == 0) continue;
total += amount;
buffer.writeln(
'${row.label.name}: ${formatMoney(amount, currency)}',
);
}
final other = otherFor(month);
if (other != 0) {
total += other;
buffer.writeln('Other: ${formatMoney(other, currency)}');
}
buffer.write('Total: ${formatMoney(total, currency)}');
return BarTooltipItem(
buffer.toString(),
TextStyle(color: colors.onInverseSurface),
);
}
final name = rodIndex < topLabels.length
? topLabels[rodIndex].label.name
: 'Other';
@@ -1152,12 +1356,13 @@ class _LabelMonthBars extends StatelessWidget {
sideTitles: SideTitles(
showTitles: true,
reservedSize: 42,
interval: safeMax / 3,
interval: interval,
getTitlesWidget: (value, meta) {
if (value <= 0) {
if (value.abs() < 0.01) {
return Text('0', style: theme.textTheme.labelSmall);
}
if ((value - safeMax).abs() < 0.01) {
if ((value - safeMax).abs() < 0.01 ||
(value - safeMin).abs() < 0.01) {
return const SizedBox.shrink();
}
return Text(
@@ -1195,11 +1400,13 @@ class _LabelMonthBars extends StatelessWidget {
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: safeMax / 3,
horizontalInterval: interval,
getDrawingHorizontalLine: (value) => FlLine(
color: colors.outlineVariant.withValues(alpha: 0.35),
strokeWidth: 1,
dashArray: const [4, 4],
color: value.abs() < 0.01
? colors.outline.withValues(alpha: 0.55)
: colors.outlineVariant.withValues(alpha: 0.35),
strokeWidth: value.abs() < 0.01 ? 1.2 : 1,
dashArray: value.abs() < 0.01 ? null : const [4, 4],
),
),
borderData: FlBorderData(show: false),
@@ -1207,27 +1414,8 @@ class _LabelMonthBars extends StatelessWidget {
for (var i = 0; i < months.length; i++)
BarChartGroupData(
x: i,
barsSpace: 2,
barRods: [
for (final row in topLabels)
BarChartRodData(
toY: amountByMonth[_periodKey(months[i], period)]
?[row.label.id] ??
0,
width: rodWidth,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(4)),
color: row.label.color,
),
if (hasOther)
BarChartRodData(
toY: otherFor(months[i]),
width: rodWidth,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(4)),
color: otherColor,
),
],
barsSpace: stacked ? 0 : 2,
barRods: rodsFor(months[i]),
),
],
),
@@ -1423,6 +1611,7 @@ class _LabelHistoryCard extends StatelessWidget {
required this.month,
required this.title,
required this.rows,
required this.flow,
required this.currency,
required this.onOpenPeriod,
required this.onOpenLabel,
@@ -1431,10 +1620,28 @@ class _LabelHistoryCard extends StatelessWidget {
final DateTime month;
final String title;
final List<_LabelSpend> rows;
final TxFlowFilter flow;
final String currency;
final VoidCallback onOpenPeriod;
final void Function(TxLabel label) onOpenLabel;
/// Display amount + color for history rows.
///
/// Keeps the original `-amount` sign convention; for All, color follows the
/// displayed value (positive → green, negative → red).
(double display, Color color) _styleFor(double amount) {
switch (flow) {
case TxFlowFilter.expense:
case TxFlowFilter.income:
return (-amount.abs(), _kExpenseColor);
case TxFlowFilter.all:
final display = -amount;
if (display > 0) return (display, _kIncomeColor);
if (display < 0) return (display, _kExpenseColor);
return (0, _kExpenseColor);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -1442,6 +1649,7 @@ class _LabelHistoryCard extends StatelessWidget {
final isYear = title.length == 4 && int.tryParse(title) != null;
final periodLabel = isYear ? title : formatMonthYear(month);
final total = rows.fold<double>(0, (sum, row) => sum + row.amount);
final totalStyle = _styleFor(total);
return Card(
color: colors.surfaceContainer,
@@ -1483,9 +1691,9 @@ class _LabelHistoryCard extends StatelessWidget {
),
),
Text(
formatMoney(-total, currency),
formatMoney(totalStyle.$1, currency),
style: theme.textTheme.titleSmall?.copyWith(
color: _kExpenseColor,
color: totalStyle.$2,
fontWeight: FontWeight.w700,
),
),
@@ -1524,12 +1732,17 @@ class _LabelHistoryCard extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
),
Text(
formatMoney(-rows[i].amount, currency),
style: theme.textTheme.bodyMedium?.copyWith(
color: _kExpenseColor,
fontWeight: FontWeight.w600,
),
Builder(
builder: (context) {
final style = _styleFor(rows[i].amount);
return Text(
formatMoney(style.$1, currency),
style: theme.textTheme.bodyMedium?.copyWith(
color: style.$2,
fontWeight: FontWeight.w600,
),
);
},
),
],
),
+103 -9
View File
@@ -5,16 +5,28 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
import '../providers/sync_provider.dart';
import '../screens/account_screen.dart';
import '../screens/login_screen.dart';
import '../screens/settings_screen.dart';
Widget authAppBarAction(BuildContext context) {
final auth = context.watch<AuthController>();
final sync = context.watch<SyncController>();
final showRing = sync.showProgressRing;
final progress = sync.syncProgress;
final success = sync.progressSuccess;
final syncActive = sync.isSyncing || showRing;
if (auth.isLoggedIn) {
final ringColor = success
? const Color(0xFF16A34A)
: Theme.of(context).colorScheme.primary;
return PopupMenuButton<_AccountMenuAction>(
tooltip: auth.displayName,
tooltip: sync.isSyncing
? 'Syncing… ${(progress * 100).round()}%'
: auth.displayName,
offset: const Offset(0, 48),
onSelected: (action) async {
switch (action) {
@@ -28,6 +40,9 @@ Widget authAppBarAction(BuildContext context) {
},
itemBuilder: (context) {
final email = auth.email;
// Capture whether sync was active when the menu opened; the tile
// itself keeps listening so the % can update while the menu is open.
final showSyncRow = syncActive;
return [
if (email != null)
PopupMenuItem<_AccountMenuAction>(
@@ -37,6 +52,11 @@ Widget authAppBarAction(BuildContext context) {
style: Theme.of(context).textTheme.bodySmall,
),
),
if (showSyncRow)
const PopupMenuItem<_AccountMenuAction>(
enabled: false,
child: _SyncProgressMenuTile(),
),
const PopupMenuItem<_AccountMenuAction>(
value: _AccountMenuAction.account,
child: ListTile(
@@ -57,14 +77,38 @@ Widget authAppBarAction(BuildContext context) {
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: CircleAvatar(
radius: 16,
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Text(
auth.initials,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
child: SizedBox(
width: 32,
height: 32,
child: Stack(
alignment: Alignment.center,
children: [
CircleAvatar(
radius: 16,
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Text(
auth.initials,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color:
Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
if (showRing)
SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
value: progress <= 0 ? null : progress.clamp(0.02, 1.0),
strokeWidth: 2.5,
color: ringColor,
backgroundColor: Theme.of(context)
.colorScheme
.outlineVariant
.withValues(alpha: 0.45),
),
),
],
),
),
),
@@ -82,6 +126,56 @@ Widget authAppBarAction(BuildContext context) {
);
}
/// Live sync status row inside the account popup (watches [SyncController]).
class _SyncProgressMenuTile extends StatelessWidget {
const _SyncProgressMenuTile();
@override
Widget build(BuildContext context) {
final sync = context.watch<SyncController>();
final theme = Theme.of(context);
final colors = theme.colorScheme;
final pct = (sync.syncProgress * 100).round().clamp(0, 100);
final detail = sync.statusDetail.trim();
if (sync.progressSuccess) {
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.check_circle_outline, color: colors.primary),
title: Text(
'Sync complete',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurface,
),
),
);
}
return ListTile(
contentPadding: EdgeInsets.zero,
leading: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
value: sync.syncProgress <= 0
? null
: sync.syncProgress.clamp(0.02, 1.0),
strokeWidth: 2.5,
),
),
title: Text(
detail.isEmpty ? 'Sync in progress ($pct%)' : detail,
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurface,
),
),
subtitle: detail.isEmpty
? null
: Text('Sync in progress ($pct%)'),
);
}
}
enum _AccountMenuAction { account, logout }
List<Widget> bankAppBarActions({
@@ -96,7 +190,6 @@ List<Widget> bankAppBarActions({
onPressed: refreshing ? null : onRefresh,
icon: const Icon(Icons.refresh),
),
authAppBarAction(context),
IconButton(
tooltip: 'Settings',
onPressed: () {
@@ -106,5 +199,6 @@ List<Widget> bankAppBarActions({
},
icon: const Icon(Icons.settings_outlined),
),
authAppBarAction(context),
];
}
+214 -107
View File
@@ -35,15 +35,30 @@ class TransactionFilterSelection {
query.trim().isNotEmpty;
}
/// How label series are drawn on the overview chart.
enum LabelChartBarStyle {
/// Side-by-side bars per label (default).
parallel,
/// Single column with stacked label segments.
stacked,
}
/// Result of the simple label multi-select dialog.
class LabelFilterSelection {
const LabelFilterSelection({
required this.labelIds,
this.flow = TxFlowFilter.all,
this.barStyle = LabelChartBarStyle.parallel,
this.invertExpensePerspective = false,
});
final Set<String> labelIds;
final TxFlowFilter flow;
final LabelChartBarStyle barStyle;
/// When [flow] is All: flip signs so expenses plot upward (spend-positive).
final bool invertExpensePerspective;
}
/// Returns the selection, or `null` if cancelled.
@@ -58,6 +73,10 @@ Future<LabelFilterSelection?> showLabelFilterDialog(
bool allowCreate = false,
bool includeFlow = false,
TxFlowFilter initialFlow = TxFlowFilter.all,
bool includeBarStyle = false,
LabelChartBarStyle initialBarStyle = LabelChartBarStyle.parallel,
bool includeInvertChart = false,
bool initialInvertChart = false,
}) {
return showDialog<LabelFilterSelection>(
context: context,
@@ -72,6 +91,10 @@ Future<LabelFilterSelection?> showLabelFilterDialog(
allowCreate: allowCreate,
includeFlow: includeFlow,
initialFlow: initialFlow,
includeBarStyle: includeBarStyle,
initialBarStyle: initialBarStyle,
includeInvertChart: includeInvertChart,
initialInvertChart: initialInvertChart,
);
},
);
@@ -126,6 +149,10 @@ class _SimpleLabelFilterDialog extends StatefulWidget {
required this.allowCreate,
required this.includeFlow,
required this.initialFlow,
required this.includeBarStyle,
required this.initialBarStyle,
required this.includeInvertChart,
required this.initialInvertChart,
});
final List<TxLabel> available;
@@ -137,6 +164,10 @@ class _SimpleLabelFilterDialog extends StatefulWidget {
final bool allowCreate;
final bool includeFlow;
final TxFlowFilter initialFlow;
final bool includeBarStyle;
final LabelChartBarStyle initialBarStyle;
final bool includeInvertChart;
final bool initialInvertChart;
@override
State<_SimpleLabelFilterDialog> createState() =>
@@ -146,6 +177,8 @@ class _SimpleLabelFilterDialog extends StatefulWidget {
class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
late final Set<String> _selected;
late TxFlowFilter _flow;
late LabelChartBarStyle _barStyle;
late bool _invertChart;
late final TextEditingController _labelSearchController;
@override
@@ -153,6 +186,8 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
super.initState();
_selected = Set<String>.of(widget.initiallySelected);
_flow = widget.initialFlow;
_barStyle = widget.initialBarStyle;
_invertChart = widget.initialInvertChart;
_labelSearchController = TextEditingController();
}
@@ -208,8 +243,17 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
kUncategorizedFilterLabel.name.toLowerCase().contains(needle));
final media = MediaQuery.of(context);
final maxContentHeight =
((media.size.height - media.viewInsets.bottom) * 0.55)
.clamp(240.0, 560.0);
((media.size.height - media.viewInsets.bottom) * 0.72)
.clamp(320.0, 720.0);
final visibleIds = <String>[
for (final e in entries) e.label.id,
if (showUncategorized) kUncategorizedFilterId,
];
final selectedVisible = visibleIds.where(_selected.contains).length;
final allVisibleSelected =
visibleIds.isNotEmpty && selectedVisible == visibleIds.length;
final noneVisibleSelected = selectedVisible == 0;
return AlertDialog(
title: Text(widget.title),
@@ -219,72 +263,152 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
widget.subtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
if (widget.includeFlow) ...[
const SizedBox(height: 12),
Text(
'Income / expense',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
_RectToggleBar<TxFlowFilter>(
value: _flow,
onChanged: (value) => setState(() => _flow = value),
options: const [
_RectToggleOption(
value: TxFlowFilter.all,
label: 'All',
),
_RectToggleOption(
value: TxFlowFilter.income,
label: 'In',
icon: Icons.south_west,
),
_RectToggleOption(
value: TxFlowFilter.expense,
label: 'Out',
icon: Icons.north_east,
),
],
),
],
const SizedBox(height: 10),
TextField(
controller: _labelSearchController,
decoration: InputDecoration(
isDense: true,
hintText: 'Filter labels',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: needle.isEmpty
? null
: IconButton(
onPressed: () {
_labelSearchController.clear();
setState(() {});
},
icon: const Icon(Icons.clear, size: 18),
ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxContentHeight * 0.42),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
widget.subtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
onChanged: (_) => setState(() {}),
),
if (widget.allowCreate) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: _createLabel,
icon: const Icon(Icons.add, size: 18),
label: const Text('New label'),
),
if (widget.includeFlow) ...[
const SizedBox(height: 12),
Text(
'Income / expense',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
_RectToggleBar<TxFlowFilter>(
value: _flow,
onChanged: (value) => setState(() => _flow = value),
options: const [
_RectToggleOption(
value: TxFlowFilter.all,
label: 'All',
),
_RectToggleOption(
value: TxFlowFilter.income,
label: 'In',
icon: Icons.south_west,
),
_RectToggleOption(
value: TxFlowFilter.expense,
label: 'Out',
icon: Icons.north_east,
),
],
),
if (widget.includeInvertChart &&
_flow == TxFlowFilter.all) ...[
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
dense: true,
title: const Text('Invert chart'),
subtitle: const Text(
'Expense view: spending up, income/refunds down',
),
value: _invertChart,
onChanged: (value) =>
setState(() => _invertChart = value),
),
],
],
if (widget.includeBarStyle) ...[
const SizedBox(height: 12),
Text(
'Bar layout',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
_RectToggleBar<LabelChartBarStyle>(
value: _barStyle,
onChanged: (value) =>
setState(() => _barStyle = value),
options: const [
_RectToggleOption(
value: LabelChartBarStyle.parallel,
label: 'Parallel',
icon: Icons.view_week_outlined,
),
_RectToggleOption(
value: LabelChartBarStyle.stacked,
label: 'Stacked',
icon: Icons.layers_outlined,
),
],
),
],
if (widget.allowCreate) ...[
const SizedBox(height: 10),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: _createLabel,
icon: const Icon(Icons.add, size: 18),
label: const Text('New label'),
),
),
],
],
),
),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: TextField(
controller: _labelSearchController,
decoration: InputDecoration(
isDense: true,
hintText: 'Filter labels',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: needle.isEmpty
? null
: IconButton(
onPressed: () {
_labelSearchController.clear();
setState(() {});
},
icon: const Icon(Icons.clear, size: 18),
),
),
onChanged: (_) => setState(() {}),
),
),
Tooltip(
message: allVisibleSelected ? 'Deselect all' : 'Select all',
child: Checkbox(
tristate: true,
value: allVisibleSelected
? true
: noneVisibleSelected
? false
: null,
onChanged: visibleIds.isEmpty
? null
: (_) {
setState(() {
if (allVisibleSelected) {
_selected.removeAll(visibleIds);
} else {
_selected.addAll(visibleIds);
}
});
},
),
),
],
),
const SizedBox(height: 8),
Expanded(
child: _LabelChecklist(
@@ -302,50 +426,33 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
actions: [
SizedBox(
width: double.maxFinite,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
child: Row(
children: [
Row(
children: [
Expanded(
child: TextButton(
onPressed: () {
setState(() {
_selected
..clear()
..addAll(entries.map((e) => e.label.id));
if (showUncategorized) {
_selected.add(kUncategorizedFilterId);
}
});
},
child: const Text('All'),
),
),
Expanded(
child: TextButton(
onPressed: () => setState(() => _selected.clear()),
child: const Text('None'),
),
),
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
),
],
),
const SizedBox(height: 4),
FilledButton(
onPressed: () => Navigator.pop(
context,
LabelFilterSelection(
labelIds: Set<String>.of(_selected),
flow: widget.includeFlow ? _flow : TxFlowFilter.all,
),
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
),
const SizedBox(width: 8),
Expanded(
flex: 2,
child: FilledButton(
onPressed: () => Navigator.pop(
context,
LabelFilterSelection(
labelIds: Set<String>.of(_selected),
flow: widget.includeFlow ? _flow : TxFlowFilter.all,
barStyle: widget.includeBarStyle
? _barStyle
: LabelChartBarStyle.parallel,
invertExpensePerspective: widget.includeInvertChart &&
_flow == TxFlowFilter.all &&
_invertChart,
),
),
child: const Text('Apply'),
),
child: const Text('Apply'),
),
],
),