This commit is contained in:
2026-03-29 15:42:11 +03:00
parent 7c6089252d
commit 1f6d129fc2
4 changed files with 44 additions and 69 deletions
+13
View File
@@ -190,5 +190,18 @@ class AppStrings {
String get showConversionsOnCard => String get showConversionsOnCard =>
_ru ? 'Показывать на карточке' : 'Show on balance card'; _ru ? 'Показывать на карточке' : 'Show on balance card';
String get selectSourceAccount =>
_ru ? 'Выберите счёт отправителя' : 'Select source account';
String get selectDestAccount =>
_ru ? 'Выберите счёт получателя' : 'Select destination account';
String get accountsMustDiffer =>
_ru ? 'Счета должны отличаться' : 'Accounts must differ';
String get transferLabel => _ru ? 'Перевод' : 'Transfer';
String get transferTo => _ru ? 'на' : 'to';
String get transferFrom => _ru ? 'с' : 'from';
String get selectAccount => _ru ? 'Выберите счёт' : 'Select account';
String get accountPlaceholder => _ru ? 'Счёт' : 'Account';
String get saveError => _ru ? 'Ошибка сохранения' : 'Save error';
String get dateLocale => _ru ? 'ru_RU' : 'en_US'; String get dateLocale => _ru ? 'ru_RU' : 'en_US';
} }
+11 -52
View File
@@ -80,13 +80,11 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
_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') { if (widget.initial!.category == 'Transfer') {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final allTxs = ref.read(transactionsProvider).valueOrNull ?? []; final allTxs = ref.read(transactionsProvider).valueOrNull ?? [];
if (widget.initial!.type == TransactionType.expense) { if (widget.initial!.type == TransactionType.expense) {
// widget.initial IS the expense side — find income counterpart for To
final counterpart = allTxs.firstWhereOrNull( final counterpart = allTxs.firstWhereOrNull(
(t) => (t) =>
t.id != widget.initial!.id && t.id != widget.initial!.id &&
@@ -110,7 +108,6 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
_transferIncomeRecordId = counterpart?.id; _transferIncomeRecordId = counterpart?.id;
}); });
} else { } else {
// widget.initial IS the income side — find expense counterpart
final expenseRecord = allTxs.firstWhereOrNull( final expenseRecord = allTxs.firstWhereOrNull(
(t) => (t) =>
t.id != widget.initial!.id && t.id != widget.initial!.id &&
@@ -125,7 +122,6 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
t.note == widget.initial!.note, t.note == widget.initial!.note,
); );
if (expenseRecord != null) { if (expenseRecord != null) {
// Swap: From = expense account, To = this income account
ref ref
.read(addTransactionProvider(widget.initial).notifier) .read(addTransactionProvider(widget.initial).notifier)
.setAccountId(expenseRecord.accountId); .setAccountId(expenseRecord.accountId);
@@ -145,7 +141,6 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
final activeAccount = ref.read(activeAccountProvider); final activeAccount = ref.read(activeAccountProvider);
final curr = ref.read(currencyProvider); final curr = ref.read(currencyProvider);
// Use active account's currency if available, otherwise use global currency
final currencyCode = activeAccount?.currency ?? curr.code; final currencyCode = activeAccount?.currency ?? curr.code;
final currencySymbol = currencyMap[currencyCode]?.symbol ?? curr.symbol; final currencySymbol = currencyMap[currencyCode]?.symbol ?? curr.symbol;
@@ -153,7 +148,6 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
.read(addTransactionProvider(null).notifier) .read(addTransactionProvider(null).notifier)
.setCurrency(currencySymbol, currencyCode); .setCurrency(currencySymbol, currencyCode);
// Set the selected account if there's an active account
if (activeAccount != null) { if (activeAccount != null) {
ref ref
.read(addTransactionProvider(null).notifier) .read(addTransactionProvider(null).notifier)
@@ -191,7 +185,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
final parts = normalized.split('.'); final parts = normalized.split('.');
if (parts.length == 2 && parts[1].length > 2) return null; if (parts.length == 2 && parts[1].length > 2) return null;
return normalized; // valid, return normalized string return normalized;
} }
void _triggerError() { void _triggerError() {
@@ -200,6 +194,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
} }
Future<void> _submit() async { Future<void> _submit() async {
final s = ref.read(stringsProvider);
final raw = _amountController.text; final raw = _amountController.text;
final parsed = _validateAndParseAmount(raw); final parsed = _validateAndParseAmount(raw);
@@ -216,17 +211,17 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
bool hasError = false; bool hasError = false;
if (state.selectedAccountId == null) { if (state.selectedAccountId == null) {
setState(() => _fromAccountError = 'Please select a source account'); setState(() => _fromAccountError = s.selectSourceAccount);
hasError = true; hasError = true;
} else { } else {
setState(() => _fromAccountError = null); setState(() => _fromAccountError = null);
} }
if (state.toAccountId == null) { if (state.toAccountId == null) {
setState(() => _toAccountError = 'Please select a destination account'); setState(() => _toAccountError = s.selectDestAccount);
hasError = true; hasError = true;
} else if (state.toAccountId == state.selectedAccountId) { } else if (state.toAccountId == state.selectedAccountId) {
setState(() => _toAccountError = 'Source and destination must differ'); setState(() => _toAccountError = s.accountsMustDiffer);
hasError = true; hasError = true;
} else { } else {
setState(() => _toAccountError = null); setState(() => _toAccountError = null);
@@ -256,14 +251,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
: _noteController.text.trim(); : _noteController.text.trim();
try { try {
print('--- SUBMIT CLICKED ---');
print(
'Amount: $amount, Category: ${state.category}, Type: ${state.type.name}',
);
if (state.type == TransactionType.transfer) { if (state.type == TransactionType.transfer) {
// Handle transfer: create two transactions
// Get currency with fallback to global currency
final curr = ref.read(currencyProvider); final curr = ref.read(currencyProvider);
final currency = state.overrideCurrency.isNotEmpty final currency = state.overrideCurrency.isNotEmpty
? state.overrideCurrency ? state.overrideCurrency
@@ -273,8 +261,6 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
: curr.code; : curr.code;
if (state.isEditing) { if (state.isEditing) {
// Update both sides of the transfer pair
// Update the expense side
final updatedExpense = Transaction( final updatedExpense = Transaction(
id: _transferExpenseRecordId ?? widget.initial!.id, id: _transferExpenseRecordId ?? widget.initial!.id,
amount: amount, amount: amount,
@@ -284,22 +270,21 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
note: note, note: note,
currency: currency, currency: currency,
currencyCode: currencyCode, currencyCode: currencyCode,
accountId: state.selectedAccountId!, // from initState accountId: state.selectedAccountId!,
); );
await ref.read(transactionsProvider.notifier).update(updatedExpense); await ref.read(transactionsProvider.notifier).update(updatedExpense);
// Update the income side
if (_transferIncomeRecordId != null) { if (_transferIncomeRecordId != null) {
final updatedIncome = Transaction( final updatedIncome = Transaction(
id: _transferIncomeRecordId!, id: _transferIncomeRecordId!,
amount: amount, // updated amount amount: amount,
category: 'Transfer', category: 'Transfer',
type: TransactionType.income, type: TransactionType.income,
date: finalDateTime, date: finalDateTime,
note: note, note: note,
currency: currency, // updated currency currency: currency,
currencyCode: currencyCode, currencyCode: currencyCode,
accountId: state.toAccountId!, // from initState accountId: state.toAccountId!,
); );
await ref.read(transactionsProvider.notifier).update(updatedIncome); await ref.read(transactionsProvider.notifier).update(updatedIncome);
} }
@@ -332,12 +317,9 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
accountId: state.toAccountId!, accountId: state.toAccountId!,
); );
print('Creating transfer transactions...');
await ref.read(transactionsProvider.notifier).add(expense); await ref.read(transactionsProvider.notifier).add(expense);
await ref.read(transactionsProvider.notifier).add(income); await ref.read(transactionsProvider.notifier).add(income);
print('Transfer completed');
} else { } else {
// Handle regular income/expense
final activeAccount = ref.read(activeAccountProvider); final activeAccount = ref.read(activeAccountProvider);
final selectedId = ref final selectedId = ref
.read(addTransactionProvider(widget.initial)) .read(addTransactionProvider(widget.initial))
@@ -345,21 +327,13 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
int accountId; int accountId;
if (selectedId != null && selectedId != 0) { if (selectedId != null && selectedId != 0) {
print('Using selected account ID: $selectedId');
accountId = selectedId; accountId = selectedId;
} else if (activeAccount != null) { } else if (activeAccount != null) {
print(
'Using active account ID: ${activeAccount.id}, Name: ${activeAccount.name}',
);
accountId = activeAccount.id; accountId = activeAccount.id;
} else { } else {
print('No active account. Fetching main account...');
final mainAccount = await ref final mainAccount = await ref
.read(accountRepositoryProvider) .read(accountRepositoryProvider)
.getMain(); .getMain();
print(
'Main account fetched: ID=${mainAccount.id}, Name: ${mainAccount.name}',
);
accountId = mainAccount.id; accountId = mainAccount.id;
} }
@@ -375,42 +349,27 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
accountId: accountId, accountId: accountId,
); );
print('Transaction object created: ID=${tx.id}, AccId=${tx.accountId}');
print('Calling provider to save...');
if (state.isEditing) { if (state.isEditing) {
await ref.read(transactionsProvider.notifier).update(tx); await ref.read(transactionsProvider.notifier).update(tx);
print('Update completed');
} else { } else {
final res = await ref.read(transactionsProvider.notifier).add(tx); final res = await ref.read(transactionsProvider.notifier).add(tx);
print(
'Add completed. Result: ${res.isSuccess ? "SUCCESS" : "FAILURE"}',
);
if (res.isFailure) { if (res.isFailure) {
print('!!! Provider returned failure: ${res.errorOrNull}');
throw Exception(res.errorOrNull); throw Exception(res.errorOrNull);
} }
} }
} }
print('Provider save completed successfully');
HapticService.medium(); HapticService.medium();
if (mounted) { if (mounted) {
print('Popping screen...');
context.pop(); context.pop();
} }
} catch (e, stack) { } catch (e) {
print('!!! SAVE CRASHED !!!');
print('Error: $e');
print('Stack trace:');
print(stack);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Save error: $e'), content: Text('${s.saveError}: $e'),
backgroundColor: Colors.red, backgroundColor: Colors.red,
duration: const Duration(seconds: 5), duration: const Duration(seconds: 5),
), ),
@@ -1,5 +1,6 @@
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 '../../../core/l10n/locale_provider.dart';
import '../../../shared/models/account.dart'; import '../../../shared/models/account.dart';
import '../../../shared/models/transaction.dart'; import '../../../shared/models/transaction.dart';
import '../../dashboard/provider.dart'; import '../../dashboard/provider.dart';
@@ -37,12 +38,12 @@ class AccountRow extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final s = ref.watch(stringsProvider);
final state = ref.watch(addTransactionProvider(initial)); final state = ref.watch(addTransactionProvider(initial));
final accountsAsync = ref.watch(accountsProvider); final accountsAsync = ref.watch(accountsProvider);
final accounts = accountsAsync.valueOrNull ?? []; final accounts = accountsAsync.valueOrNull ?? [];
final isTransfer = state.type == TransactionType.transfer; final isTransfer = state.type == TransactionType.transfer;
// Auto-select toAccount when only 2 accounts exist
if (isTransfer && accounts.length == 2 && state.selectedAccountId != null) { if (isTransfer && accounts.length == 2 && state.selectedAccountId != null) {
final otherId = accounts final otherId = accounts
.firstWhere( .firstWhere(
@@ -63,7 +64,7 @@ class AccountRow extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Account', s.accountPlaceholder,
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
@@ -95,6 +96,7 @@ class AccountRow extends ConsumerWidget {
indicatorKey: fromIndicatorKey, indicatorKey: fromIndicatorKey,
error: fromAccountError, error: fromAccountError,
isDark: isDark, isDark: isDark,
selectAccountText: s.selectAccount,
), ),
], ],
); );
@@ -109,6 +111,7 @@ class _SingleAccountSelector extends ConsumerWidget {
final GlobalKey indicatorKey; final GlobalKey indicatorKey;
final String? error; final String? error;
final bool isDark; final bool isDark;
final String selectAccountText;
const _SingleAccountSelector({ const _SingleAccountSelector({
required this.initial, required this.initial,
@@ -118,6 +121,7 @@ class _SingleAccountSelector extends ConsumerWidget {
required this.indicatorKey, required this.indicatorKey,
this.error, this.error,
required this.isDark, required this.isDark,
required this.selectAccountText,
}); });
@override @override
@@ -184,7 +188,7 @@ class _SingleAccountSelector extends ConsumerWidget {
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Text( child: Text(
displayAccount?.name ?? 'Select account', displayAccount?.name ?? selectAccountText,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: displayAccount != null color: displayAccount != null
? Theme.of(context).colorScheme.onSurface ? Theme.of(context).colorScheme.onSurface
@@ -253,6 +257,7 @@ class _TransferAccountRow extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final s = ref.watch(stringsProvider);
final state = ref.watch(addTransactionProvider(initial)); final state = ref.watch(addTransactionProvider(initial));
final activeAccount = ref.watch(activeAccountProvider); final activeAccount = ref.watch(activeAccountProvider);
@@ -275,8 +280,6 @@ class _TransferAccountRow extends ConsumerWidget {
), ),
); );
} else { } else {
// If no account is explicitly selected and we're on Total Balance
// creating a new transfer — show empty, force user to choose
if (activeAccount == null && initial == null) { if (activeAccount == null && initial == null) {
fromAccount = null; fromAccount = null;
} else { } else {
@@ -306,6 +309,7 @@ class _TransferAccountRow extends ConsumerWidget {
error: fromAccountError, error: fromAccountError,
isDark: isDark, isDark: isDark,
disabled: isFromAccountLocked, disabled: isFromAccountLocked,
selectText: s.selectAccount,
), ),
), ),
Padding( Padding(
@@ -328,6 +332,7 @@ class _TransferAccountRow extends ConsumerWidget {
error: toAccountError, error: toAccountError,
isDark: isDark, isDark: isDark,
disabled: autoSelectEnabled || isToAccountLocked, disabled: autoSelectEnabled || isToAccountLocked,
selectText: s.selectAccount,
), ),
), ),
], ],
@@ -344,6 +349,7 @@ class _AccountHalf extends StatelessWidget {
final String? error; final String? error;
final bool isDark; final bool isDark;
final bool disabled; final bool disabled;
final String selectText;
const _AccountHalf({ const _AccountHalf({
required this.account, required this.account,
@@ -354,6 +360,7 @@ class _AccountHalf extends StatelessWidget {
this.error, this.error,
required this.isDark, required this.isDark,
this.disabled = false, this.disabled = false,
required this.selectText,
}); });
@override @override
@@ -413,7 +420,7 @@ class _AccountHalf extends StatelessWidget {
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
account?.name ?? 'Select', account?.name ?? selectText,
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -274,10 +274,10 @@ class TransactionTile extends ConsumerWidget {
List<Account> accounts, List<Account> accounts,
Account? activeAccount, Account? activeAccount,
) { ) {
// Fallback if no counterpart final s = ref.watch(stringsProvider);
if (counterpart == null) { if (counterpart == null) {
return Text( return Text(
'Transfer', s.transferLabel,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
@@ -298,13 +298,12 @@ class TransactionTile extends ConsumerWidget {
final destAccountName = _accountName(accounts, destAccountId); final destAccountName = _accountName(accounts, destAccountId);
final onSurface = Theme.of(context).colorScheme.onSurface; final onSurface = Theme.of(context).colorScheme.onSurface;
// Total Balance view (activeAccount == null), showing expense side
if (activeAccount == null) { if (activeAccount == null) {
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
'Transfer', s.transferLabel,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: onSurface, color: onSurface,
@@ -321,14 +320,12 @@ class TransactionTile extends ConsumerWidget {
); );
} }
// Account view
if (isExpense) { if (isExpense) {
// Expense side: Transfer to <destAccountName>
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
'Transfer', s.transferLabel,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: onSurface, color: onSurface,
@@ -336,7 +333,7 @@ class TransactionTile extends ConsumerWidget {
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
'to', s.transferTo,
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: onSurface.withOpacity(0.45), color: onSurface.withOpacity(0.45),
@@ -348,12 +345,11 @@ class TransactionTile extends ConsumerWidget {
], ],
); );
} else { } else {
// Income side: Transfer from <sourceAccountName>
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
'Transfer', s.transferLabel,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: onSurface, color: onSurface,
@@ -361,7 +357,7 @@ class TransactionTile extends ConsumerWidget {
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
'from', s.transferFrom,
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: onSurface.withOpacity(0.45), color: onSurface.withOpacity(0.45),