diff --git a/lib/features/add_transaction/provider.dart b/lib/features/add_transaction/provider.dart index 9a0a940..c54b4a5 100644 --- a/lib/features/add_transaction/provider.dart +++ b/lib/features/add_transaction/provider.dart @@ -30,10 +30,15 @@ class AddTransactionState { }); factory AddTransactionState.fromTransaction(Transaction tx) { + // Override type to transfer when category is 'Transfer' + final resolvedType = (tx.category == 'Transfer') + ? TransactionType.transfer + : tx.type; + return AddTransactionState( amount: tx.amount, category: tx.category, - type: tx.type, + type: resolvedType, date: tx.date, note: tx.note ?? '', editingId: tx.id, diff --git a/lib/features/add_transaction/screen.dart b/lib/features/add_transaction/screen.dart index 7d11806..7119442 100644 --- a/lib/features/add_transaction/screen.dart +++ b/lib/features/add_transaction/screen.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -75,6 +76,30 @@ class _AddTransactionScreenState extends ConsumerState if (widget.initial != null) { _amountController.text = widget.initial!.amount.toString(); _noteController.text = widget.initial!.note ?? ''; + + // Pre-populate toAccountId when opening Transfer for edit + if (widget.initial!.category == 'Transfer') { + WidgetsBinding.instance.addPostFrameCallback((_) { + final allTxs = ref.read(transactionsProvider).valueOrNull ?? []; + final counterpart = allTxs.firstWhereOrNull( + (t) => + t.category == 'Transfer' && + t.type == TransactionType.income && + t.amount == widget.initial!.amount && + t.date.year == widget.initial!.date.year && + t.date.month == widget.initial!.date.month && + t.date.day == widget.initial!.date.day && + t.date.hour == widget.initial!.date.hour && + t.date.minute == widget.initial!.date.minute && + t.note == widget.initial!.note, + ); + if (counterpart != null) { + ref + .read(addTransactionProvider(widget.initial).notifier) + .setToAccountId(counterpart.accountId); + } + }); + } } else { WidgetsBinding.instance.addPostFrameCallback((_) { final activeAccount = ref.read(activeAccountProvider); @@ -207,6 +232,58 @@ class _AddTransactionScreenState extends ConsumerState ? state.overrideCurrencyCode : curr.code; + if (state.isEditing) { + // Update both sides of the transfer pair + // Update the expense side (widget.initial = expense record) + final updatedExpense = Transaction( + id: widget.initial!.id, + amount: amount, + category: 'Transfer', + type: TransactionType.expense, + date: finalDateTime, + note: note, + currency: currency, + currencyCode: currencyCode, + accountId: widget.initial!.accountId, // locked, unchanged + ); + await ref.read(transactionsProvider.notifier).update(updatedExpense); + + // Find and update the income counterpart + final allTxs = ref.read(transactionsProvider).valueOrNull ?? []; + final counterpart = allTxs.firstWhereOrNull( + (t) => + t.category == 'Transfer' && + t.type == TransactionType.income && + t.accountId == state.toAccountId && + t.amount == + widget.initial!.amount && // match by original amount + t.date.year == widget.initial!.date.year && + t.date.month == widget.initial!.date.month && + t.date.day == widget.initial!.date.day && + t.date.hour == widget.initial!.date.hour && + t.date.minute == widget.initial!.date.minute && + t.note == widget.initial!.note, + ); + + if (counterpart != null) { + final updatedIncome = Transaction( + id: counterpart.id, + amount: amount, // updated amount + category: 'Transfer', + type: TransactionType.income, + date: finalDateTime, + note: note, + currency: currency, // updated currency + currencyCode: currencyCode, + accountId: counterpart.accountId, // locked, unchanged + ); + await ref.read(transactionsProvider.notifier).update(updatedIncome); + } + + if (mounted) context.pop(); + return; + } + final expense = Transaction( id: _uuid.v4(), amount: amount, @@ -378,8 +455,9 @@ class _AddTransactionScreenState extends ConsumerState final isDark = Theme.of(context).brightness == Brightness.dark; final isEditing = state.isEditing; final activeAccount = ref.watch(activeAccountProvider); - final isAccountLocked = activeAccount != null; final isTransfer = state.type == TransactionType.transfer; + final isEditingTransfer = isEditing && isTransfer; + final isAccountLocked = activeAccount != null || isEditingTransfer; return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, diff --git a/lib/features/dashboard/provider.dart b/lib/features/dashboard/provider.dart index 9f047c0..5fc13fe 100644 --- a/lib/features/dashboard/provider.dart +++ b/lib/features/dashboard/provider.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -112,6 +113,35 @@ class TransactionsNotifier } } +// Returns a map: transactionId -> paired Transaction (its counterpart) +final transferPairsProvider = Provider>((ref) { + final txs = ref.watch(transactionsProvider).valueOrNull ?? []; + final transfers = txs.where((t) => t.category == 'Transfer').toList(); + final Map pairs = {}; + + for (final tx in transfers) { + if (pairs.containsKey(tx.id)) continue; // already paired + // Find counterpart: opposite type, same amount, same date (minute precision), same note + final counterpart = transfers.firstWhereOrNull( + (other) => + other.id != tx.id && + other.type != tx.type && + other.amount == tx.amount && + other.date.year == tx.date.year && + other.date.month == tx.date.month && + other.date.day == tx.date.day && + other.date.hour == tx.date.hour && + other.date.minute == tx.date.minute && + other.note == tx.note, + ); + if (counterpart != null) { + pairs[tx.id] = counterpart; + pairs[counterpart.id] = tx; + } + } + return pairs; +}); + final searchQueryProvider = StateProvider((ref) => ''); enum TransactionFilter { all, income, expense, transfer } @@ -273,6 +303,8 @@ final filteredTransactionsProvider = Provider>((ref) { final query = ref.watch(searchQueryProvider).toLowerCase(); final typeFilter = ref.watch(transactionFilterProvider); final timeFilter = ref.watch(timeFilterProvider); + final activeAccount = ref.watch(activeAccountProvider); + final transferPairs = ref.watch(transferPairsProvider); var filtered = txs; @@ -308,6 +340,20 @@ final filteredTransactionsProvider = Provider>((ref) { } filtered.sort((a, b) => b.date.compareTo(a.date)); + + // Deduplicate transfers for Total Balance view + if (activeAccount == null) { + filtered = filtered.where((t) { + if (t.category != 'Transfer') return true; + // On Total Balance: show only expense side of complete pairs + // If income side has a known pair, hide it + if (t.type == TransactionType.income && transferPairs.containsKey(t.id)) { + return false; + } + return true; + }).toList(); + } + return filtered; }); diff --git a/lib/features/dashboard/widgets/transaction_tile.dart b/lib/features/dashboard/widgets/transaction_tile.dart index 98c1e87..5fc0d1e 100644 --- a/lib/features/dashboard/widgets/transaction_tile.dart +++ b/lib/features/dashboard/widgets/transaction_tile.dart @@ -7,6 +7,7 @@ import '../../../core/constants.dart'; import '../../../core/l10n/app_strings.dart'; import '../../../core/l10n/locale_provider.dart'; import '../../../shared/models/transaction.dart'; +import '../../../shared/models/account.dart'; import '../../../shared/providers/amount_format_provider.dart'; import '../../../shared/utils/currency_utils.dart'; import '../../../shared/widgets/byn_sign.dart'; @@ -68,6 +69,10 @@ class TransactionTile extends ConsumerWidget { accountLabel = '${accountLabel.substring(0, 10)}...'; } + // Transfer pairing logic + final pairs = ref.watch(transferPairsProvider); + final counterpart = pairs[transaction.id]; + return GestureDetector( onTap: () => context.push('/add', extra: transaction), child: Container( @@ -95,19 +100,29 @@ class TransactionTile extends ConsumerWidget { Row( children: [ Flexible( - child: Text( - isTransfer - ? 'Transfer' - : s.categoryLabel(transaction.category), - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith( - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurface, + child: isTransfer + ? _buildTransferLabel( + context, + ref, + counterpart, + accounts, + activeAccount, + ) + : Text( + s.categoryLabel(transaction.category), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of( + context, + ).colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, ), - overflow: TextOverflow.ellipsis, - ), ), - if (activeAccount == null && accountLabel.isNotEmpty) ...[ + if (!isTransfer && + activeAccount == null && + accountLabel.isNotEmpty) ...[ const SizedBox(width: 6), _AccountTag(label: accountLabel), ], @@ -148,35 +163,60 @@ class TransactionTile extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( - isTransfer ? '' : (isIncome ? '+ ' : '- '), + _getAmountPrefix( + isTransfer, + isIncome, + activeAccount, + ), style: Theme.of(context).textTheme.bodyMedium ?.copyWith( - color: color, + color: _getAmountColor( + context, + isTransfer, + isIncome, + activeAccount, + color, + ), fontWeight: FontWeight.w700, ), ), - BynSign(fontSize: 14, color: color), + BynSign( + fontSize: 14, + color: _getAmountColor( + context, + isTransfer, + isIncome, + activeAccount, + color, + ), + ), const SizedBox(width: 2), Text( formatAmount('', transaction.amount, fmt), style: Theme.of(context).textTheme.bodyMedium ?.copyWith( - color: color, + color: _getAmountColor( + context, + isTransfer, + isIncome, + activeAccount, + color, + ), fontWeight: FontWeight.w700, ), ), ], ) : Text( - isTransfer - ? formatAmount( - transaction.currency, - transaction.amount, - fmt, - ) - : '${isIncome ? '+ ' : '- '}${formatAmount(transaction.currency, transaction.amount, fmt)}', + '${_getAmountPrefix(isTransfer, isIncome, activeAccount)}${formatAmount(transaction.currency, transaction.amount, fmt)}', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: color, + color: _getAmountColor( + context, + isTransfer, + isIncome, + activeAccount, + color, + ), fontWeight: FontWeight.w700, ), ), @@ -226,6 +266,151 @@ class TransactionTile extends ConsumerWidget { ), ); } + + Widget _buildTransferLabel( + BuildContext context, + WidgetRef ref, + Transaction? counterpart, + List accounts, + Account? activeAccount, + ) { + // Fallback if no counterpart + if (counterpart == null) { + return Text( + 'Transfer', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + ); + } + + final isExpense = transaction.type == TransactionType.expense; + final sourceAccountId = isExpense + ? transaction.accountId + : counterpart.accountId; + final destAccountId = isExpense + ? counterpart.accountId + : transaction.accountId; + + final sourceAccountName = _accountName(accounts, sourceAccountId); + final destAccountName = _accountName(accounts, destAccountId); + final onSurface = Theme.of(context).colorScheme.onSurface; + + // Total Balance view (activeAccount == null), showing expense side + if (activeAccount == null) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Transfer', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: onSurface, + ), + ), + const SizedBox(width: 6), + Flexible( + child: _TransferChip( + label: '$sourceAccountName → $destAccountName', + icon: Icons.swap_horiz_rounded, + ), + ), + ], + ); + } + + // Account view + if (isExpense) { + // Expense side: Transfer to + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Transfer', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: onSurface, + ), + ), + const SizedBox(width: 6), + Text( + 'to', + style: TextStyle( + fontSize: 11, + color: onSurface.withOpacity(0.45), + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(width: 4), + Flexible(child: _TransferChip(label: destAccountName, icon: null)), + ], + ); + } else { + // Income side: Transfer from + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Transfer', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: onSurface, + ), + ), + const SizedBox(width: 6), + Text( + 'from', + style: TextStyle( + fontSize: 11, + color: onSurface.withOpacity(0.45), + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(width: 4), + Flexible(child: _TransferChip(label: sourceAccountName, icon: null)), + ], + ); + } + } + + String _accountName(List accounts, int? id) { + if (id == null) return '?'; + return accounts.firstWhereOrNull((a) => a.id == id)?.name ?? '?'; + } + + String _getAmountPrefix( + bool isTransfer, + bool isIncome, + Account? activeAccount, + ) { + // Total Balance view with Transfer expense: no prefix + if (isTransfer && activeAccount == null && !isIncome) { + return ''; + } + // All other cases: show + or − + return isIncome ? '+ ' : '\u2212 '; + } + + Color _getAmountColor( + BuildContext context, + bool isTransfer, + bool isIncome, + Account? activeAccount, + Color defaultColor, + ) { + // Total Balance view with Transfer expense: neutral color + if (isTransfer && activeAccount == null && !isIncome) { + return Theme.of(context).colorScheme.onSurface.withOpacity(0.8); + } + // Transfer in account view or Total Balance income: use income/expense colors + if (isTransfer) { + return isIncome ? AppColors.income : AppColors.expense; + } + // Non-transfer: use default color + return defaultColor; + } } class _AccountTag extends StatelessWidget { @@ -266,6 +451,48 @@ class _AccountTag extends StatelessWidget { } } +class _TransferChip extends StatelessWidget { + final String label; + final IconData? icon; + const _TransferChip({required this.label, this.icon}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFF7C6DED).withOpacity(0.12), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: const Color(0xFF7C6DED).withOpacity(0.35), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 11, color: const Color(0xFF7C6DED)), + const SizedBox(width: 4), + ], + Flexible( + child: Text( + label, + style: const TextStyle( + fontSize: 11, + color: Color(0xFF7C6DED), + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ], + ), + ); + } +} + class EmptyState extends StatelessWidget { final AppStrings strings; const EmptyState({super.key, required this.strings});