diff --git a/lib/models/bank.dart b/lib/models/bank.dart index dad715d..f749515 100644 --- a/lib/models/bank.dart +++ b/lib/models/bank.dart @@ -88,6 +88,7 @@ class BankTransaction { required this.amount, required this.currency, required this.pending, + this.isManual = false, }); final String date; @@ -96,6 +97,9 @@ class BankTransaction { final String currency; final bool pending; + /// Admin-created test row (editable / deletable); not from bank feed or CSV. + final bool isManual; + /// Stable local key used for per-occurrence annotations. String get id => '$date|$currency|$amount|$description'; @@ -109,6 +113,33 @@ class BankTransaction { /// Exact match for copying notes (same description + amount + currency). String get exactKey => '$descriptionKey|$amount|$currency'; + BankTransaction copyWith({ + String? date, + String? description, + double? amount, + String? currency, + bool? pending, + bool? isManual, + }) { + return BankTransaction( + date: date ?? this.date, + description: description ?? this.description, + amount: amount ?? this.amount, + currency: currency ?? this.currency, + pending: pending ?? this.pending, + isManual: isManual ?? this.isManual, + ); + } + + Map toJson() => { + 'date': date, + 'description': description, + 'amount': amount, + 'currency': currency, + 'pending': pending, + if (isManual) 'isManual': true, + }; + factory BankTransaction.fromJson(Map json) { final rawAmount = json['amount']; final amount = switch (rawAmount) { @@ -122,6 +153,9 @@ class BankTransaction { amount: amount, currency: json['currency'] as String? ?? 'EUR', pending: json['pending'] as bool? ?? false, + isManual: json['isManual'] as bool? ?? + json['is_manual'] as bool? ?? + false, ); } } diff --git a/lib/providers/bank_provider.dart b/lib/providers/bank_provider.dart index 7508b3b..2639953 100644 --- a/lib/providers/bank_provider.dart +++ b/lib/providers/bank_provider.dart @@ -30,9 +30,22 @@ class BankController extends ChangeNotifier { _remoteTransactions = cached.transactions; } _importedTransactions = _storage?.loadImportedTransactions() ?? const []; + _importedTransactions = [ + for (final tx in _importedTransactions) _upgradeLegacyManual(tx), + ]; + _remoteTransactions = [ + for (final tx in _remoteTransactions) _upgradeLegacyManual(tx), + ]; _invalidateMerged(); } + /// Older admin test rows used a ·#stamp suffix before [isManual] existed. + static BankTransaction _upgradeLegacyManual(BankTransaction tx) { + if (tx.isManual) return tx; + if (!RegExp(r' ·#\d+$').hasMatch(tx.description)) return tx; + return tx.copyWith(isManual: true); + } + final IngtrakApi _api; final StorageService? _storage; @@ -46,6 +59,51 @@ class BankController extends ChangeNotifier { BankBalance? _balance; List _remoteTransactions = const []; List _importedTransactions = const []; + List _pendingRemoteDeletes = const []; + + /// Local tx_ids removed/replaced that should be deleted on the next PB push. + List takePendingRemoteDeletes() { + final out = List.of(_pendingRemoteDeletes); + _pendingRemoteDeletes = const []; + return out; + } + + void requeueRemoteDeletes(Iterable txIds) { + for (final id in txIds) { + _queueRemoteDelete(id); + } + } + + void _queueRemoteDelete(String txId) { + if (txId.isEmpty) return; + if (_pendingRemoteDeletes.contains(txId)) return; + _pendingRemoteDeletes = [..._pendingRemoteDeletes, txId]; + } + + /// Keep [isManual] when a synced/API row replaces a matching imported test tx. + void _reapplyManualFlags(Set manualIds) { + if (manualIds.isEmpty) return; + BankTransaction stamp(BankTransaction tx) => + manualIds.contains(tx.id) && !tx.isManual + ? tx.copyWith(isManual: true) + : tx; + _remoteTransactions = [for (final tx in _remoteTransactions) stamp(tx)]; + _importedTransactions = [ + for (final tx in _importedTransactions) stamp(tx), + ]; + } + + Set _collectManualIds([ + Iterable extra = const [], + ]) => + { + for (final tx in [ + ..._remoteTransactions, + ..._importedTransactions, + ...extra, + ]) + if (tx.isManual) tx.id, + }; List? _mergedTransactions; bool _isLoading = false; @@ -170,7 +228,8 @@ class BankController extends ChangeNotifier { String currency = 'EUR', bool pending = false, }) async { - final desc = description.trim().isEmpty ? 'Test transaction' : description.trim(); + final desc = + description.trim().isEmpty ? 'Test transaction' : description.trim(); // Include a short unique suffix so identical drafts do not collide on id. final stamp = DateTime.now().millisecondsSinceEpoch % 100000; final tx = BankTransaction( @@ -182,6 +241,7 @@ class BankController extends ChangeNotifier { amount: amount, currency: currency.trim().isEmpty ? 'EUR' : currency.trim().toUpperCase(), pending: pending, + isManual: true, ); _importedTransactions = mergeTransactions(_importedTransactions, [tx]); @@ -195,6 +255,81 @@ class BankController extends ChangeNotifier { return tx; } + /// Replace an admin test transaction. Returns the updated row (id may change). + Future updateManualTransaction({ + required BankTransaction previous, + required DateTime date, + required String description, + required double amount, + String currency = 'EUR', + bool pending = false, + }) async { + if (!previous.isManual) { + throw StateError('Only admin test transactions can be edited'); + } + final desc = + description.trim().isEmpty ? 'Test transaction' : description.trim(); + final stamped = () { + if (RegExp(r' ·#\d+$').hasMatch(desc)) return desc; + final prevStamp = RegExp(r' ·#(\d+)$').firstMatch(previous.description); + final prevBase = + previous.description.replaceFirst(RegExp(r' ·#\d+$'), ''); + if (prevStamp != null && prevBase == desc) { + return '$desc ·#${prevStamp.group(1)}'; + } + return '$desc ·#${DateTime.now().millisecondsSinceEpoch % 100000}'; + }(); + final updated = BankTransaction( + date: + '${date.year.toString().padLeft(4, '0')}-' + '${date.month.toString().padLeft(2, '0')}-' + '${date.day.toString().padLeft(2, '0')}', + description: stamped, + amount: amount, + currency: currency.trim().isEmpty ? 'EUR' : currency.trim().toUpperCase(), + pending: pending, + isManual: true, + ); + + _importedTransactions = [ + for (final tx in _importedTransactions) + if (tx.id != previous.id) tx, + ]; + _remoteTransactions = [ + for (final tx in _remoteTransactions) + if (tx.id != previous.id) tx, + ]; + if (previous.id != updated.id) { + _queueRemoteDelete(previous.id); + } + _importedTransactions = mergeTransactions(_importedTransactions, [updated]); + _invalidateMerged(); + await _persistImported(); + await _persistCache(); + notifyListeners(); + return updated; + } + + /// Remove an admin test transaction from local caches. + Future deleteManualTransaction(BankTransaction tx) async { + if (!tx.isManual) { + throw StateError('Only admin test transactions can be deleted'); + } + _importedTransactions = [ + for (final row in _importedTransactions) + if (row.id != tx.id) row, + ]; + _remoteTransactions = [ + for (final row in _remoteTransactions) + if (row.id != tx.id) row, + ]; + _queueRemoteDelete(tx.id); + _invalidateMerged(); + await _persistImported(); + await _persistCache(); + notifyListeners(); + } + /// Union cloud txs into the local bank cache without dropping either side. /// /// Rows land in the API cache; CSV imports that match are deduped away. @@ -204,11 +339,16 @@ class BankController extends ChangeNotifier { }) async { if (fromCloud.isEmpty) return; - _remoteTransactions = mergeTransactions(_remoteTransactions, fromCloud); + final manuals = _collectManualIds(fromCloud.map(_upgradeLegacyManual)); + _remoteTransactions = mergeTransactions( + _remoteTransactions, + [for (final tx in fromCloud) _upgradeLegacyManual(tx)], + ); _importedTransactions = filterNewTransactions( _remoteTransactions, _importedTransactions, ); + _reapplyManualFlags(manuals); _invalidateMerged(); if (!_status.linked && _remoteTransactions.isNotEmpty) { @@ -288,6 +428,7 @@ class BankController extends ChangeNotifier { final apiTxs = results[1] as List; // Bank API is ~89 days. Prefer API rows for overlaps, but keep any // cloud/CSV history that the API window does not include. + final manuals = _collectManualIds(); _remoteTransactions = mergeTransactions(apiTxs, _remoteTransactions); if (!_status.linked) { _status = ConnectStatus( @@ -303,6 +444,7 @@ class BankController extends ChangeNotifier { _remoteTransactions, _importedTransactions, ); + _reapplyManualFlags(manuals); _invalidateMerged(); await _persistImported(); _error = null; diff --git a/lib/providers/sync_provider.dart b/lib/providers/sync_provider.dart index 93b2fb2..1afb6bd 100644 --- a/lib/providers/sync_provider.dart +++ b/lib/providers/sync_provider.dart @@ -408,6 +408,15 @@ class SyncController extends ChangeNotifier { } catch (error) { debugPrint('SyncController: app_prefs push skipped ($error)'); } + final pendingDeletes = _bank.takePendingRemoteDeletes(); + if (pendingDeletes.isNotEmpty) { + try { + await service.deleteTransactionsByTxIds(pendingDeletes); + } catch (error) { + debugPrint('SyncController: tx deletes skipped ($error)'); + _bank.requeueRemoteDeletes(pendingDeletes); + } + } final bankSummary = await service.pushTransactions( _bank.transactions, progressStart: 0.82, diff --git a/lib/providers/tx_meta_provider.dart b/lib/providers/tx_meta_provider.dart index 013070b..48d59eb 100644 --- a/lib/providers/tx_meta_provider.dart +++ b/lib/providers/tx_meta_provider.dart @@ -722,6 +722,25 @@ class TxMetaController extends ChangeNotifier { _schedulePersist(invalidateLabels: false); } + /// Move per-tx annotations when an admin test transaction's id changes. + Future rekeyTransaction(String fromId, String toId) async { + if (fromId == toId || fromId.isEmpty || toId.isEmpty) return; + final stored = _byId.remove(fromId); + if (stored != null) { + _byId[toId] = stored; + } + _labelIdsByTxCache = null; + _schedulePersist(invalidateLabels: true); + } + + /// Drop per-tx annotations for a deleted admin test transaction. + Future clearTransactionMeta(String txId) async { + if (txId.isEmpty) return; + if (_byId.remove(txId) == null) return; + _labelIdsByTxCache = null; + _schedulePersist(invalidateLabels: true); + } + Future setNotes( BankTransaction tx, String notes, { diff --git a/lib/screens/account_screen.dart b/lib/screens/account_screen.dart index 7277ad6..199c1c4 100644 --- a/lib/screens/account_screen.dart +++ b/lib/screens/account_screen.dart @@ -125,36 +125,59 @@ class _AccountScreenState extends State { ], ), ), - if (auth.isAdmin) ...[ - const SizedBox(height: 16), - Card( - child: ListTile( - leading: const Icon(Icons.add_card_outlined), - title: const Text('Create test transaction'), - subtitle: const Text( - 'Admin only · local + syncs to PocketBase', - ), - trailing: const Icon(Icons.chevron_right), - onTap: () async { - final tx = await showAdminCreateTransactionDialog(context); - if (tx == null || !context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Created “${tx.description}” · ' - '${tx.amount.toStringAsFixed(2)} ${tx.currency}', - ), - ), - ); - // Push so other devices / PB see it soon. - final sync = context.read(); - if (sync.canSync && !sync.isSyncing) { - unawaited(sync.syncNow()); - } - }, + const SizedBox(height: 16), + Card( + child: ListTile( + leading: Icon( + Icons.add_card_outlined, + color: auth.isAdmin ? null : colors.onSurfaceVariant, ), + title: const Text('Create test transaction'), + subtitle: Text( + auth.isAdmin + ? 'Admin · saved locally and synced to PocketBase' + : 'Admin only · enable is_admin on your usersapp user in PocketBase, then sign out and back in', + ), + trailing: auth.isAdmin + ? const Icon(Icons.chevron_right) + : Icon( + Icons.lock_outline, + color: colors.onSurfaceVariant, + ), + onTap: () async { + if (!auth.isAdmin) { + await auth.refreshSession(); + if (!context.mounted) return; + if (!context.read().isAdmin) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Not an admin yet. In PocketBase Admin → ' + 'usersapp → your user → set is_admin = true, ' + 'then sign out and sign in again.', + ), + ), + ); + return; + } + } + final tx = await showAdminCreateTransactionDialog(context); + if (tx == null || !context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Created “${tx.description}” · ' + '${tx.amount.toStringAsFixed(2)} ${tx.currency}', + ), + ), + ); + final sync = context.read(); + if (sync.canSync && !sync.isSyncing) { + unawaited(sync.syncNow()); + } + }, ), - ], + ), const SizedBox(height: 16), Card( child: ListTile( diff --git a/lib/screens/transaction_detail_screen.dart b/lib/screens/transaction_detail_screen.dart index 6fe794d..8f606f3 100644 --- a/lib/screens/transaction_detail_screen.dart +++ b/lib/screens/transaction_detail_screen.dart @@ -6,10 +6,12 @@ import 'package:provider/provider.dart'; import '../models/bank.dart'; import '../models/transaction_meta.dart'; +import '../providers/auth_provider.dart'; import '../providers/bank_provider.dart'; import '../providers/tx_meta_provider.dart'; import '../utils/app_theme.dart'; import '../utils/helpers.dart'; +import '../widgets/admin_create_transaction_dialog.dart'; import '../widgets/label_parent_picker.dart'; import '../widgets/recurring_template_sheet.dart'; import '../widgets/responsive_body.dart'; @@ -26,6 +28,7 @@ class TransactionDetailScreen extends StatefulWidget { } class _TransactionDetailScreenState extends State { + late BankTransaction _tx; late final TextEditingController _notesController; late final TextEditingController _labelSearchController; late SimilarMatchCriteria _matchCriteria; @@ -41,11 +44,12 @@ class _TransactionDetailScreenState extends State { @override void initState() { super.initState(); + _tx = widget.tx; final meta = context.read(); - final notes = meta.metaFor(widget.tx).notes; + final notes = meta.metaFor(_tx).notes; _notesController = TextEditingController(text: notes); _labelSearchController = TextEditingController(); - final tx = widget.tx; + final tx = _tx; _matchCriteria = SimilarMatchCriteria( pattern: defaultSimilarPattern(tx.description), flow: tx.amount < 0 @@ -63,7 +67,7 @@ class _TransactionDetailScreenState extends State { if (!mounted) return; final all = context.read().transactions; final count = context.read().countSimilar( - widget.tx, + _tx, all, criteria: _matchCriteria, ); @@ -88,14 +92,14 @@ class _TransactionDetailScreenState extends State { List _similarScope(TxMetaController meta) { final all = context.read().transactions; if (!_applyToSimilar) return const []; - return meta.similarTo(widget.tx, all, criteria: _matchCriteria); + return meta.similarTo(_tx, all, criteria: _matchCriteria); } Future _editMatchCriteria() async { final all = context.read().transactions; final result = await showSimilarMatchDialog( context, - prototype: widget.tx, + prototype: _tx, all: all, initial: _matchCriteria, ); @@ -138,7 +142,7 @@ class _TransactionDetailScreenState extends State { final similar = _similarScope(meta); final count = similar.isEmpty ? 1 : similar.length; await meta.setNotes( - widget.tx, + _tx, _notesController.text, applyToSimilar: similar, matchPattern: _pattern, @@ -180,7 +184,7 @@ class _TransactionDetailScreenState extends State { () async { final meta = context.read(); final similar = toMatched ? _similarScope(meta) : const []; - final targets = similar.isEmpty ? [widget.tx] : similar; + final targets = similar.isEmpty ? [_tx] : similar; await meta.setLabelsForMany( targets, _draftLabelIds.toList(growable: false), @@ -188,7 +192,7 @@ class _TransactionDetailScreenState extends State { if (!mounted) return; setState(() { _labelsDirty = false; - _draftLabelIds = meta.metaFor(widget.tx).labelIds.toSet(); + _draftLabelIds = meta.metaFor(_tx).labelIds.toSet(); }); final count = targets.length; ScaffoldMessenger.of(context).showSnackBar( @@ -291,9 +295,79 @@ class _TransactionDetailScreenState extends State { await context.read().removeLabel(label.id); } + Future _editManualTransaction() async { + final updated = await showAdminCreateTransactionDialog( + context, + existing: _tx, + ); + if (updated == null || !mounted) return; + final meta = context.read(); + if (updated.id != _tx.id) { + await meta.rekeyTransaction(_tx.id, updated.id); + } + setState(() { + _tx = updated; + _matchCriteria = SimilarMatchCriteria( + pattern: defaultSimilarPattern(updated.description), + flow: updated.amount < 0 + ? TxFlowFilter.expense + : updated.amount > 0 + ? TxFlowFilter.income + : TxFlowFilter.all, + ); + _draftLabelIds = meta.metaFor(updated).labelIds.toSet(); + _labelsDirty = false; + _similarReady = false; + _similarCount = 0; + }); + _refreshSimilarCount(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Test transaction updated')), + ); + } + + Future _deleteManualTransaction() async { + final ok = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Delete test transaction?'), + content: const Text( + 'This removes the transaction from this device. The remote copy is ' + 'deleted on the next PocketBase sync.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Delete'), + ), + ], + ); + }, + ); + if (ok != true || !mounted) return; + final id = _tx.id; + final bank = context.read(); + final meta = context.read(); + final messenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + await bank.deleteManualTransaction(_tx); + await meta.clearTransactionMeta(id); + if (!mounted) return; + navigator.pop(); + messenger.showSnackBar( + const SnackBar(content: Text('Test transaction deleted')), + ); + } + @override Widget build(BuildContext context) { - final tx = widget.tx; + final tx = _tx; final metaController = context.watch(); final meta = metaController.metaFor(tx); final similarCount = _similarCount; @@ -317,11 +391,37 @@ class _TransactionDetailScreenState extends State { if (keep.contains(entry.label.id)) entry, ]; }(); + final canEditManual = + context.watch().isAdmin && tx.isManual; return Scaffold( appBar: AppBar( title: const Text('Transaction'), actions: [ + if (canEditManual) + PopupMenuButton( + enabled: !_busy, + onSelected: (value) { + if (value == 'edit') { + _editManualTransaction(); + } else if (value == 'delete') { + _deleteManualTransaction(); + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'edit', + child: Text('Edit test transaction'), + ), + PopupMenuItem( + value: 'delete', + child: Text( + 'Delete', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ), if (_dirty) TextButton( onPressed: _busy ? null : _saveNotes, diff --git a/lib/screens/transactions_screen.dart b/lib/screens/transactions_screen.dart index 6eaa39a..3a09059 100644 --- a/lib/screens/transactions_screen.dart +++ b/lib/screens/transactions_screen.dart @@ -9,7 +9,6 @@ import 'package:provider/provider.dart'; import '../models/bank.dart'; import '../models/transaction_meta.dart'; import '../providers/bank_provider.dart'; -import '../providers/preferences_provider.dart'; import '../providers/tx_meta_provider.dart'; import '../utils/app_theme.dart'; import '../utils/connect_bank.dart'; @@ -17,7 +16,6 @@ import '../utils/helpers.dart'; import '../utils/navigation.dart'; import '../utils/tx_calendar.dart'; import '../widgets/empty_state.dart'; -import '../widgets/home_widgets_sheet.dart'; import '../widgets/label_filter_dialog.dart'; import '../widgets/loading_view.dart'; import '../widgets/responsive_body.dart'; @@ -277,28 +275,6 @@ class _TransactionsScreenState extends State { ); } - Future _saveAsHomeWidget() async { - final preferences = context.read(); - final created = await composeHomeFilterWidget( - context, - initialFilter: TransactionFilterSelection( - labelIds: Set.of(_labelFilters), - labelMatch: _labelMatch, - flow: _flowFilter, - amountRange: _amountRange, - query: _activeQuery, - monthOnly: _searchInMonthOnly, - ), - skipFilterDialog: true, - ); - if (created == null) return; - await preferences.addHomeFilterWidget(created); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Added “${created.name}” to Home')), - ); - } - @override Widget build(BuildContext context) { final bank = context.watch(); @@ -307,11 +283,6 @@ class _TransactionsScreenState extends State { appBar: AppBar( title: const Text('Transactions'), actions: [ - IconButton( - tooltip: 'Save as Home widget', - onPressed: _saveAsHomeWidget, - icon: const Icon(Icons.dashboard_customize_outlined), - ), ...bankAppBarActions( context: context, onRefresh: bank.refresh, diff --git a/lib/services/pb_sync_service.dart b/lib/services/pb_sync_service.dart index e902627..1d8f574 100644 --- a/lib/services/pb_sync_service.dart +++ b/lib/services/pb_sync_service.dart @@ -648,6 +648,7 @@ class PbSyncService { 8, ), 'pending': tx.pending, + 'is_manual': tx.isManual, }; final remote = existing[syncId]; if (remote == null) { @@ -699,6 +700,7 @@ class PbSyncService { ? 'EUR' : r.getStringValue('currency'), pending: r.getBoolValue('pending'), + isManual: r.getBoolValue('is_manual'), ); } @@ -715,7 +717,30 @@ class PbSyncService { (remoteAmount * 100).round() == (tx.amount * 100).round() && remote.getStringValue('currency').toUpperCase() == tx.currency.toUpperCase() && - remote.getBoolValue('pending') == tx.pending; + remote.getBoolValue('pending') == tx.pending && + remote.getBoolValue('is_manual') == tx.isManual; + } + + /// Deletes remote bank rows by local [BankTransaction.id] (tx_id field). + Future deleteTransactionsByTxIds(Iterable txIds) async { + final ids = { + for (final id in txIds) + if (_clip(id, 2000).isNotEmpty) _clip(id, 2000), + }; + if (ids.isEmpty) return 'Bank txs: nothing to delete'; + + final userId = _userId; + final filter = 'user="$userId"'; + final existing = await _fullList('transactions', filter); + final ops = <_BatchOp>[ + for (final r in existing) + if (ids.contains(r.getStringValue('tx_id'))) _BatchOp.delete(r.id), + ]; + if (ops.isEmpty) { + return 'Bank txs: 0 remote deletes (ids ${ids.length})'; + } + await _runWriteOps('transactions', ops); + return 'Bank txs: deleted ${ops.length} remote row(s)'; } Future _runOne(String collection, _BatchOp op) { diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index a4d0f8b..6204624 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -65,14 +65,7 @@ class BankCache { 'balanceType': balance!.balanceType, }, 'transactions': [ - for (final tx in transactions) - { - 'date': tx.date, - 'description': tx.description, - 'amount': tx.amount, - 'currency': tx.currency, - 'pending': tx.pending, - }, + for (final tx in transactions) tx.toJson(), ], }; @@ -258,16 +251,7 @@ class StorageService { } Future saveImportedTransactions(List txs) async { - final encoded = [ - for (final tx in txs) - { - 'date': tx.date, - 'description': tx.description, - 'amount': tx.amount, - 'currency': tx.currency, - 'pending': tx.pending, - }, - ]; + final encoded = [for (final tx in txs) tx.toJson()]; final json = await compute(jsonEncode, encoded); await _prefs.setString(StorageKeys.importedTransactions, json); } diff --git a/lib/utils/app_theme.dart b/lib/utils/app_theme.dart index 91ff37b..8a7822b 100644 --- a/lib/utils/app_theme.dart +++ b/lib/utils/app_theme.dart @@ -12,6 +12,22 @@ abstract final class AppTheme { /// Soft rose for expenses / money out. static const Color expense = Color(0xFFE07A7A); + /// Amber mid-stop for budget progress (between mint and rose). + static const Color budgetWarn = Color(0xFFE5A24A); + + /// Budget chart fill: green when low → amber → red as usage rises. + static Color budgetProgressFill(double progress) { + final t = progress.clamp(0.0, 1.0); + if (t <= 0.55) { + // Stay mostly mint through ~half the budget. + return Color.lerp(accent, budgetWarn, (t / 0.55) * 0.45)!; + } + if (t <= 0.85) { + return Color.lerp(budgetWarn, expense, (t - 0.55) / 0.30)!; + } + return Color.lerp(expense, const Color(0xFFD94C4C), (t - 0.85) / 0.15)!; + } + /// Near-black charcoal scaffold (dark). static const Color darkCanvas = Color(0xFF0B0D0C); diff --git a/lib/widgets/admin_create_transaction_dialog.dart b/lib/widgets/admin_create_transaction_dialog.dart index 6fb52f8..9fd7a3a 100644 --- a/lib/widgets/admin_create_transaction_dialog.dart +++ b/lib/widgets/admin_create_transaction_dialog.dart @@ -1,4 +1,4 @@ -/// Admin-only dialog to create a local test bank transaction. +/// Admin-only dialog to create or edit a local test bank transaction. library; import 'package:flutter/material.dart'; @@ -11,16 +11,22 @@ import '../providers/bank_provider.dart'; import '../utils/helpers.dart'; Future showAdminCreateTransactionDialog( - BuildContext context, -) { + BuildContext context, { + BankTransaction? existing, +}) { return showDialog( context: context, - builder: (context) => const _AdminCreateTransactionDialog(), + builder: (context) => _AdminCreateTransactionDialog(existing: existing), ); } +String _stripManualStamp(String description) => + description.replaceFirst(RegExp(r' ·#\d+$'), ''); + class _AdminCreateTransactionDialog extends StatefulWidget { - const _AdminCreateTransactionDialog(); + const _AdminCreateTransactionDialog({this.existing}); + + final BankTransaction? existing; @override State<_AdminCreateTransactionDialog> createState() => @@ -33,19 +39,40 @@ class _AdminCreateTransactionDialogState late final TextEditingController _description; late final TextEditingController _amount; late final TextEditingController _currency; - var _flow = TxFlowFilter.expense; - var _pending = false; + late TxFlowFilter _flow; + late bool _pending; var _saving = false; String? _error; + bool get _editing => widget.existing != null; + @override void initState() { super.initState(); - final now = DateTime.now(); - _date = DateTime(now.year, now.month, now.day); - _description = TextEditingController(text: 'Test merchant'); - _amount = TextEditingController(text: '12.50'); - _currency = TextEditingController(text: 'EUR'); + final existing = widget.existing; + if (existing != null) { + final parsed = DateTime.tryParse(existing.date) ?? DateTime.now(); + _date = DateTime(parsed.year, parsed.month, parsed.day); + _description = TextEditingController( + text: _stripManualStamp(existing.description), + ); + _amount = TextEditingController( + text: existing.amount.abs().toStringAsFixed(2), + ); + _currency = TextEditingController(text: existing.currency); + _flow = existing.amount >= 0 + ? TxFlowFilter.income + : TxFlowFilter.expense; + _pending = existing.pending; + } else { + final now = DateTime.now(); + _date = DateTime(now.year, now.month, now.day); + _description = TextEditingController(text: 'Test merchant'); + _amount = TextEditingController(text: '12.50'); + _currency = TextEditingController(text: 'EUR'); + _flow = TxFlowFilter.expense; + _pending = false; + } } @override @@ -81,13 +108,26 @@ class _AdminCreateTransactionDialogState }); try { final bank = context.read(); - final tx = await bank.addManualTransaction( - date: _date, - description: _description.text, - amount: signed, - currency: _currency.text, - pending: _pending, - ); + final existing = widget.existing; + final BankTransaction tx; + if (existing != null) { + tx = await bank.updateManualTransaction( + previous: existing, + date: _date, + description: _description.text, + amount: signed, + currency: _currency.text, + pending: _pending, + ); + } else { + tx = await bank.addManualTransaction( + date: _date, + description: _description.text, + amount: signed, + currency: _currency.text, + pending: _pending, + ); + } if (!mounted) return; Navigator.pop(context, tx); } catch (error) { @@ -103,14 +143,16 @@ class _AdminCreateTransactionDialogState Widget build(BuildContext context) { final theme = Theme.of(context); return AlertDialog( - title: const Text('Create test transaction'), + title: Text(_editing ? 'Edit test transaction' : 'Create test transaction'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - 'Saved on this device and included in the next PocketBase sync.', + _editing + ? 'Updates this device and removes the old row on the next PocketBase sync.' + : 'Saved on this device and included in the next PocketBase sync.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -218,7 +260,7 @@ class _AdminCreateTransactionDialogState height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Text('Create'), + : Text(_editing ? 'Save' : 'Create'), ), ], ); diff --git a/lib/widgets/home_filter_widget_card.dart b/lib/widgets/home_filter_widget_card.dart index aeb268d..148a15a 100644 --- a/lib/widgets/home_filter_widget_card.dart +++ b/lib/widgets/home_filter_widget_card.dart @@ -371,8 +371,11 @@ class _BudgetBody extends StatelessWidget { widget: widgetConfig, ); final progress = budget.progress.clamp(0.0, 1.0); - final over = budget.overBudget; - final fillColor = over ? colors.error : colors.primary; + final fillColor = budget.overBudget + ? AppTheme.expense + : AppTheme.budgetProgressFill( + budget.target <= 0 ? 0 : progress, + ); // Grayish unfilled track (not green-tinted). final trackColor = Color.alphaBlend( colors.onSurface.withValues(alpha: 0.14), diff --git a/pocketbase/pb_migrations/1740000010_ingtrak_transactions_is_manual.js b/pocketbase/pb_migrations/1740000010_ingtrak_transactions_is_manual.js new file mode 100644 index 0000000..06d0577 --- /dev/null +++ b/pocketbase/pb_migrations/1740000010_ingtrak_transactions_is_manual.js @@ -0,0 +1,28 @@ +/// PocketBase migration: `is_manual` on transactions (admin test rows). +/// +/// Apply on the server: +/// +/// sudo cp this_file /opt/pocketbase/pb_migrations/ +/// sudo chown pocketbase:pocketbase /opt/pocketbase/pb_migrations/*.js +/// sudo -u pocketbase /opt/pocketbase/pocketbase migrate up +/// sudo systemctl restart pocketbase +migrate((app) => { + const transactions = app.findCollectionByNameOrId("transactions"); + if (transactions.fields.getByName("is_manual")) return; + + transactions.fields.add(new Field({ + type: "bool", + name: "is_manual", + required: false, + })); + app.save(transactions); +}, (app) => { + try { + const transactions = app.findCollectionByNameOrId("transactions"); + const field = transactions.fields.getByName("is_manual"); + if (field) { + transactions.fields.removeById(field.id); + app.save(transactions); + } + } catch (_) {} +});