mirror of
https://github.com/koloideal/Casha.git
synced 2026-06-10 10:25:28 +03:00
stableee
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<AddTransactionScreen>
|
||||
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<AddTransactionScreen>
|
||||
? 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<AddTransactionScreen>
|
||||
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,
|
||||
|
||||
@@ -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<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) => '');
|
||||
|
||||
enum TransactionFilter { all, income, expense, transfer }
|
||||
@@ -273,6 +303,8 @@ final filteredTransactionsProvider = Provider<List<Transaction>>((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<List<Transaction>>((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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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<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 {
|
||||
@@ -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});
|
||||
|
||||
Reference in New Issue
Block a user