This commit is contained in:
2026-03-29 14:16:17 +03:00
parent 2fc1d3e200
commit 31dc972e52
4 changed files with 381 additions and 25 deletions
+6 -1
View File
@@ -30,10 +30,15 @@ class AddTransactionState {
}); });
factory AddTransactionState.fromTransaction(Transaction tx) { factory AddTransactionState.fromTransaction(Transaction tx) {
// Override type to transfer when category is 'Transfer'
final resolvedType = (tx.category == 'Transfer')
? TransactionType.transfer
: tx.type;
return AddTransactionState( return AddTransactionState(
amount: tx.amount, amount: tx.amount,
category: tx.category, category: tx.category,
type: tx.type, type: resolvedType,
date: tx.date, date: tx.date,
note: tx.note ?? '', note: tx.note ?? '',
editingId: tx.id, editingId: tx.id,
+79 -1
View File
@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -75,6 +76,30 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
if (widget.initial != null) { if (widget.initial != null) {
_amountController.text = widget.initial!.amount.toString(); _amountController.text = widget.initial!.amount.toString();
_noteController.text = widget.initial!.note ?? ''; _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 { } else {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final activeAccount = ref.read(activeAccountProvider); final activeAccount = ref.read(activeAccountProvider);
@@ -207,6 +232,58 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
? state.overrideCurrencyCode ? state.overrideCurrencyCode
: curr.code; : 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( final expense = Transaction(
id: _uuid.v4(), id: _uuid.v4(),
amount: amount, amount: amount,
@@ -378,8 +455,9 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
final isEditing = state.isEditing; final isEditing = state.isEditing;
final activeAccount = ref.watch(activeAccountProvider); final activeAccount = ref.watch(activeAccountProvider);
final isAccountLocked = activeAccount != null;
final isTransfer = state.type == TransactionType.transfer; final isTransfer = state.type == TransactionType.transfer;
final isEditingTransfer = isEditing && isTransfer;
final isAccountLocked = activeAccount != null || isEditingTransfer;
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor, backgroundColor: Theme.of(context).scaffoldBackgroundColor,
+46
View File
@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.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<Map<String, Transaction>>((ref) {
final txs = ref.watch(transactionsProvider).valueOrNull ?? [];
final transfers = txs.where((t) => t.category == 'Transfer').toList();
final Map<String, Transaction> 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<String>((ref) => ''); final searchQueryProvider = StateProvider<String>((ref) => '');
enum TransactionFilter { all, income, expense, transfer } enum TransactionFilter { all, income, expense, transfer }
@@ -273,6 +303,8 @@ final filteredTransactionsProvider = Provider<List<Transaction>>((ref) {
final query = ref.watch(searchQueryProvider).toLowerCase(); final query = ref.watch(searchQueryProvider).toLowerCase();
final typeFilter = ref.watch(transactionFilterProvider); final typeFilter = ref.watch(transactionFilterProvider);
final timeFilter = ref.watch(timeFilterProvider); final timeFilter = ref.watch(timeFilterProvider);
final activeAccount = ref.watch(activeAccountProvider);
final transferPairs = ref.watch(transferPairsProvider);
var filtered = txs; var filtered = txs;
@@ -308,6 +340,20 @@ final filteredTransactionsProvider = Provider<List<Transaction>>((ref) {
} }
filtered.sort((a, b) => b.date.compareTo(a.date)); 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; return filtered;
}); });
@@ -7,6 +7,7 @@ import '../../../core/constants.dart';
import '../../../core/l10n/app_strings.dart'; import '../../../core/l10n/app_strings.dart';
import '../../../core/l10n/locale_provider.dart'; import '../../../core/l10n/locale_provider.dart';
import '../../../shared/models/transaction.dart'; import '../../../shared/models/transaction.dart';
import '../../../shared/models/account.dart';
import '../../../shared/providers/amount_format_provider.dart'; import '../../../shared/providers/amount_format_provider.dart';
import '../../../shared/utils/currency_utils.dart'; import '../../../shared/utils/currency_utils.dart';
import '../../../shared/widgets/byn_sign.dart'; import '../../../shared/widgets/byn_sign.dart';
@@ -68,6 +69,10 @@ class TransactionTile extends ConsumerWidget {
accountLabel = '${accountLabel.substring(0, 10)}...'; accountLabel = '${accountLabel.substring(0, 10)}...';
} }
// Transfer pairing logic
final pairs = ref.watch(transferPairsProvider);
final counterpart = pairs[transaction.id];
return GestureDetector( return GestureDetector(
onTap: () => context.push('/add', extra: transaction), onTap: () => context.push('/add', extra: transaction),
child: Container( child: Container(
@@ -95,19 +100,29 @@ class TransactionTile extends ConsumerWidget {
Row( Row(
children: [ children: [
Flexible( Flexible(
child: Text( child: isTransfer
isTransfer ? _buildTransferLabel(
? 'Transfer' context,
: s.categoryLabel(transaction.category), ref,
counterpart,
accounts,
activeAccount,
)
: Text(
s.categoryLabel(transaction.category),
style: Theme.of(context).textTheme.bodyMedium style: Theme.of(context).textTheme.bodyMedium
?.copyWith( ?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface, 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), const SizedBox(width: 6),
_AccountTag(label: accountLabel), _AccountTag(label: accountLabel),
], ],
@@ -148,35 +163,60 @@ class TransactionTile extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Text( Text(
isTransfer ? '' : (isIncome ? '+ ' : '- '), _getAmountPrefix(
isTransfer,
isIncome,
activeAccount,
),
style: Theme.of(context).textTheme.bodyMedium style: Theme.of(context).textTheme.bodyMedium
?.copyWith( ?.copyWith(
color: color, color: _getAmountColor(
context,
isTransfer,
isIncome,
activeAccount,
color,
),
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
BynSign(fontSize: 14, color: color), BynSign(
fontSize: 14,
color: _getAmountColor(
context,
isTransfer,
isIncome,
activeAccount,
color,
),
),
const SizedBox(width: 2), const SizedBox(width: 2),
Text( Text(
formatAmount('', transaction.amount, fmt), formatAmount('', transaction.amount, fmt),
style: Theme.of(context).textTheme.bodyMedium style: Theme.of(context).textTheme.bodyMedium
?.copyWith( ?.copyWith(
color: color, color: _getAmountColor(
context,
isTransfer,
isIncome,
activeAccount,
color,
),
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
], ],
) )
: Text( : Text(
isTransfer '${_getAmountPrefix(isTransfer, isIncome, activeAccount)}${formatAmount(transaction.currency, transaction.amount, fmt)}',
? formatAmount(
transaction.currency,
transaction.amount,
fmt,
)
: '${isIncome ? '+ ' : '- '}${formatAmount(transaction.currency, transaction.amount, fmt)}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: color, color: _getAmountColor(
context,
isTransfer,
isIncome,
activeAccount,
color,
),
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
@@ -226,6 +266,151 @@ class TransactionTile extends ConsumerWidget {
), ),
); );
} }
Widget _buildTransferLabel(
BuildContext context,
WidgetRef ref,
Transaction? counterpart,
List<Account> 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 <destAccountName>
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 <sourceAccountName>
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<Account> 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 { 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 { class EmptyState extends StatelessWidget {
final AppStrings strings; final AppStrings strings;
const EmptyState({super.key, required this.strings}); const EmptyState({super.key, required this.strings});