From 123c7d0eb41817ea966da451f2cfb6ba8cd7dfdf Mon Sep 17 00:00:00 2001 From: kolo Date: Fri, 27 Mar 2026 12:16:37 +0300 Subject: [PATCH] update --- lib/core/l10n/app_strings.dart | 1 + lib/features/add_transaction/provider.dart | 16 +- lib/features/add_transaction/screen.dart | 386 ++++++++++++---- .../add_transaction/widgets/account_row.dart | 425 ++++++++++++++++++ .../add_transaction/widgets/type_toggle.dart | 149 ++++-- lib/features/dashboard/provider.dart | 21 +- .../dashboard/widgets/filter_chips.dart | 103 +++-- .../dashboard/widgets/transaction_tile.dart | 56 ++- lib/shared/models/transaction.dart | 93 ++-- 9 files changed, 991 insertions(+), 259 deletions(-) create mode 100644 lib/features/add_transaction/widgets/account_row.dart diff --git a/lib/core/l10n/app_strings.dart b/lib/core/l10n/app_strings.dart index 9b39b9c..3157152 100644 --- a/lib/core/l10n/app_strings.dart +++ b/lib/core/l10n/app_strings.dart @@ -19,6 +19,7 @@ class AppStrings { String get filterAll => _ru ? 'Все' : 'All'; String get filterIncome => _ru ? 'Доход' : 'Income'; String get filterExpense => _ru ? 'Расход' : 'Expense'; + String get filterTransfer => _ru ? 'Перевод' : 'Transfer'; String get filterAllTime => _ru ? 'Всё время' : 'All Time'; String get filterMonth => _ru ? 'Месяц' : 'Month'; String get income => _ru ? 'Доход' : 'Income'; diff --git a/lib/features/add_transaction/provider.dart b/lib/features/add_transaction/provider.dart index c1fddc0..9a0a940 100644 --- a/lib/features/add_transaction/provider.dart +++ b/lib/features/add_transaction/provider.dart @@ -13,6 +13,7 @@ class AddTransactionState { final String overrideCurrency; final String overrideCurrencyCode; final int? selectedAccountId; + final int? toAccountId; const AddTransactionState({ this.amount, @@ -25,6 +26,7 @@ class AddTransactionState { this.overrideCurrency = '\$', this.overrideCurrencyCode = 'USD', this.selectedAccountId, + this.toAccountId, }); factory AddTransactionState.fromTransaction(Transaction tx) { @@ -56,6 +58,7 @@ class AddTransactionState { String? overrideCurrency, String? overrideCurrencyCode, int? selectedAccountId, + int? toAccountId, }) => AddTransactionState( amount: amount ?? this.amount, category: category ?? this.category, @@ -67,6 +70,7 @@ class AddTransactionState { overrideCurrency: overrideCurrency ?? this.overrideCurrency, overrideCurrencyCode: overrideCurrencyCode ?? this.overrideCurrencyCode, selectedAccountId: selectedAccountId ?? this.selectedAccountId, + toAccountId: toAccountId ?? this.toAccountId, ); bool get isEditing => editingId != null; @@ -85,8 +89,12 @@ class AddTransactionNotifier extends StateNotifier { void setCategory(String v) => state = state.copyWith(category: v); void setType(TransactionType v) { - final newCategory = AppCategories.forType(v).first; - state = state.copyWith(type: v, category: newCategory); + if (v == TransactionType.transfer) { + state = state.copyWith(type: v, category: 'Transfer', toAccountId: null); + } else { + final newCategory = AppCategories.forType(v).first; + state = state.copyWith(type: v, category: newCategory, toAccountId: null); + } } void setDate(DateTime v) => state = state.copyWith(date: v); @@ -104,6 +112,10 @@ class AddTransactionNotifier extends StateNotifier { void setAccountId(int id) => state = state.copyWith(selectedAccountId: id); + void setToAccountId(int? id) => state = state.copyWith(toAccountId: id); + + bool get isTransfer => state.type == TransactionType.transfer; + void reset() => state = AddTransactionState.empty(); } diff --git a/lib/features/add_transaction/screen.dart b/lib/features/add_transaction/screen.dart index e29dac1..3e0b874 100644 --- a/lib/features/add_transaction/screen.dart +++ b/lib/features/add_transaction/screen.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:uuid/uuid.dart'; @@ -10,6 +9,7 @@ import '../../shared/models/transaction.dart'; import '../dashboard/provider.dart'; import '../settings/provider.dart'; import 'provider.dart'; +import 'widgets/account_row.dart'; import 'widgets/account_selector.dart'; import 'widgets/amount_input.dart'; import 'widgets/category_picker.dart'; @@ -37,13 +37,17 @@ class _AddTransactionScreenState extends ConsumerState final _formKey = GlobalKey(); final _amountController = TextEditingController(); final _noteController = TextEditingController(); - final _accountIndicatorKey = GlobalKey(); + final _fromAccountIndicatorKey = GlobalKey(); + final _toAccountIndicatorKey = GlobalKey(); late AnimationController _shakeController; late Animation _borderColorAnimation; bool _showError = false; late DateTime _selectedDate; late TimeOfDay _selectedTime; - bool _showAccountDropdown = false; + bool _showFromAccountDropdown = false; + bool _showToAccountDropdown = false; + String? _toAccountError; + String? _fromAccountError; @override void initState() { @@ -82,6 +86,13 @@ class _AddTransactionScreenState extends ConsumerState ref .read(addTransactionProvider(null).notifier) .setCurrency(currencySymbol, currencyCode); + + // Set the selected account if there's an active account + if (activeAccount != null) { + ref + .read(addTransactionProvider(null).notifier) + .setAccountId(activeAccount.id); + } }); } } @@ -134,6 +145,30 @@ class _AddTransactionScreenState extends ConsumerState final amount = double.parse(parsed); final state = ref.read(addTransactionProvider(widget.initial)); + // Validate transfer + if (state.type == TransactionType.transfer) { + bool hasError = false; + + if (state.selectedAccountId == null) { + setState(() => _fromAccountError = 'Please select a source account'); + hasError = true; + } else { + setState(() => _fromAccountError = null); + } + + if (state.toAccountId == null) { + setState(() => _toAccountError = 'Please select a destination account'); + hasError = true; + } else if (state.toAccountId == state.selectedAccountId) { + setState(() => _toAccountError = 'Source and destination must differ'); + hasError = true; + } else { + setState(() => _toAccountError = null); + } + + if (hasError) return; + } + final finalDateTime = DateTime( _selectedDate.year, _selectedDate.month, @@ -160,56 +195,100 @@ class _AddTransactionScreenState extends ConsumerState 'Amount: $amount, Category: ${state.category}, Type: ${state.type.name}', ); - final activeAccount = ref.read(activeAccountProvider); - final selectedId = ref - .read(addTransactionProvider(widget.initial)) - .selectedAccountId; - int accountId; + if (state.type == TransactionType.transfer) { + // Handle transfer: create two transactions + // Get currency with fallback to global currency + final curr = ref.read(currencyProvider); + final currency = state.overrideCurrency.isNotEmpty + ? state.overrideCurrency + : curr.symbol; + final currencyCode = state.overrideCurrencyCode.isNotEmpty + ? state.overrideCurrencyCode + : curr.code; - if (selectedId != null && selectedId != 0) { - print('Using selected account ID: $selectedId'); - accountId = selectedId; - } else if (activeAccount != null) { - print( - 'Using active account ID: ${activeAccount.id}, Name: ${activeAccount.name}', + final expense = Transaction( + id: _uuid.v4(), + amount: amount, + category: 'Transfer', + type: TransactionType.expense, + date: finalDateTime, + note: note, + currency: currency, + currencyCode: currencyCode, + accountId: state.selectedAccountId!, ); - accountId = activeAccount.id; + + final income = Transaction( + id: _uuid.v4(), + amount: amount, + category: 'Transfer', + type: TransactionType.income, + date: finalDateTime, + note: note, + currency: currency, + currencyCode: currencyCode, + accountId: state.toAccountId!, + ); + + print('Creating transfer transactions...'); + await ref.read(transactionsProvider.notifier).add(expense); + await ref.read(transactionsProvider.notifier).add(income); + print('Transfer completed'); } else { - print('No active account. Fetching main account...'); - final mainAccount = await ref.read(accountRepositoryProvider).getMain(); - print( - 'Main account fetched: ID=${mainAccount.id}, Name: ${mainAccount.name}', - ); - accountId = mainAccount.id; - } + // Handle regular income/expense + final activeAccount = ref.read(activeAccountProvider); + final selectedId = ref + .read(addTransactionProvider(widget.initial)) + .selectedAccountId; + int accountId; - final tx = Transaction( - id: state.editingId ?? _uuid.v4(), - amount: amount, - category: state.category, - type: state.type, - date: finalDateTime, - note: note, - currency: state.overrideCurrency, - currencyCode: state.overrideCurrencyCode, - accountId: accountId, - ); + if (selectedId != null && selectedId != 0) { + print('Using selected account ID: $selectedId'); + accountId = selectedId; + } else if (activeAccount != null) { + print( + 'Using active account ID: ${activeAccount.id}, Name: ${activeAccount.name}', + ); + accountId = activeAccount.id; + } else { + print('No active account. Fetching main account...'); + final mainAccount = await ref + .read(accountRepositoryProvider) + .getMain(); + print( + 'Main account fetched: ID=${mainAccount.id}, Name: ${mainAccount.name}', + ); + accountId = mainAccount.id; + } - print('Transaction object created: ID=${tx.id}, AccId=${tx.accountId}'); - print('Calling provider to save...'); - - if (state.isEditing) { - await ref.read(transactionsProvider.notifier).update(tx); - print('Update completed'); - } else { - final res = await ref.read(transactionsProvider.notifier).add(tx); - print( - 'Add completed. Result: ${res.isSuccess ? "SUCCESS" : "FAILURE"}', + final tx = Transaction( + id: state.editingId ?? _uuid.v4(), + amount: amount, + category: state.category, + type: state.type, + date: finalDateTime, + note: note, + currency: state.overrideCurrency, + currencyCode: state.overrideCurrencyCode, + accountId: accountId, ); - if (res.isFailure) { - print('!!! Provider returned failure: ${res.errorOrNull}'); - throw Exception(res.errorOrNull); + print('Transaction object created: ID=${tx.id}, AccId=${tx.accountId}'); + print('Calling provider to save...'); + + if (state.isEditing) { + await ref.read(transactionsProvider.notifier).update(tx); + print('Update completed'); + } else { + final res = await ref.read(transactionsProvider.notifier).add(tx); + print( + 'Add completed. Result: ${res.isSuccess ? "SUCCESS" : "FAILURE"}', + ); + + if (res.isFailure) { + print('!!! Provider returned failure: ${res.errorOrNull}'); + throw Exception(res.errorOrNull); + } } } @@ -349,37 +428,32 @@ class _AddTransactionScreenState extends ConsumerState ListView( padding: const EdgeInsets.all(20), children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AccountSelector( - initial: widget.initial, - showDropdown: _showAccountDropdown, - onToggleDropdown: () => setState( - () => _showAccountDropdown = - !_showAccountDropdown, - ), - indicatorKey: _accountIndicatorKey, - ), - ], - ), - ), - const SizedBox(width: 12), - Expanded( - child: TypeToggle( - selected: state.type, - onChanged: (type) => ref - .read( - addTransactionProvider(widget.initial).notifier, - ) - .setType(type), - isDark: isDark, - ), - ), - ], + TypeToggle( + selected: state.type, + onChanged: (type) => ref + .read(addTransactionProvider(widget.initial).notifier) + .setType(type), + isDark: isDark, + ), + const SizedBox(height: 16), + + AccountRow( + initial: widget.initial, + showFromDropdown: _showFromAccountDropdown, + showToDropdown: _showToAccountDropdown, + onToggleFromDropdown: () => setState(() { + _showFromAccountDropdown = !_showFromAccountDropdown; + _fromAccountError = null; + }), + onToggleToDropdown: () => setState(() { + _showToAccountDropdown = !_showToAccountDropdown; + _toAccountError = null; + }), + fromIndicatorKey: _fromAccountIndicatorKey, + toIndicatorKey: _toAccountIndicatorKey, + fromAccountError: _fromAccountError, + toAccountError: _toAccountError, + isDark: isDark, ), const SizedBox(height: 24), @@ -419,16 +493,18 @@ class _AddTransactionScreenState extends ConsumerState ), const SizedBox(height: 20), - SectionLabel(s.category), - const SizedBox(height: 8), - CategoryPicker( - categories: categories, - selected: state.category, - onChanged: (c) => ref - .read(addTransactionProvider(widget.initial).notifier) - .setCategory(c), - ), - const SizedBox(height: 20), + if (state.type != TransactionType.transfer) ...[ + SectionLabel(s.category), + const SizedBox(height: 8), + CategoryPicker( + categories: categories, + selected: state.category, + onChanged: (c) => ref + .read(addTransactionProvider(widget.initial).notifier) + .setCategory(c), + ), + const SizedBox(height: 20), + ], Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -477,18 +553,33 @@ class _AddTransactionScreenState extends ConsumerState ), ], ), - if (_showAccountDropdown) + if (_showFromAccountDropdown) Positioned.fill( child: GestureDetector( - onTap: () => setState(() => _showAccountDropdown = false), + onTap: () => + setState(() => _showFromAccountDropdown = false), behavior: HitTestBehavior.translucent, child: const SizedBox.expand(), ), ), - if (_showAccountDropdown) + if (_showFromAccountDropdown) AccountDropdownOverlay( initial: widget.initial, - onClose: () => setState(() => _showAccountDropdown = false), + onClose: () => + setState(() => _showFromAccountDropdown = false), + ), + if (_showToAccountDropdown) + Positioned.fill( + child: GestureDetector( + onTap: () => setState(() => _showToAccountDropdown = false), + behavior: HitTestBehavior.translucent, + child: const SizedBox.expand(), + ), + ), + if (_showToAccountDropdown) + _ToAccountDropdownOverlay( + initial: widget.initial, + onClose: () => setState(() => _showToAccountDropdown = false), ), ], ), @@ -497,3 +588,116 @@ class _AddTransactionScreenState extends ConsumerState ); } } + +class _ToAccountDropdownOverlay extends ConsumerWidget { + final Transaction? initial; + final VoidCallback onClose; + + const _ToAccountDropdownOverlay({ + required this.initial, + required this.onClose, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final accountsAsync = ref.watch(accountsProvider); + final selectedAccountId = ref + .read(addTransactionProvider(initial)) + .selectedAccountId; + final toAccountId = ref.read(addTransactionProvider(initial)).toAccountId; + + return Positioned( + top: 340, + left: 20, + right: 20, + child: Material( + elevation: 8, + borderRadius: BorderRadius.circular(12), + color: Colors.transparent, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFF7C6DED).withOpacity(0.3), + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: accountsAsync.when( + data: (accounts) { + final filteredAccounts = accounts + .where((a) => a.id != selectedAccountId) + .toList(); + + return Column( + mainAxisSize: MainAxisSize.min, + children: filteredAccounts.map((account) { + final isSelected = account.id == toAccountId; + return InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () { + ref + .read(addTransactionProvider(initial).notifier) + .setToAccountId(account.id); + onClose(); + HapticService.light(); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + child: Row( + children: [ + Icon( + Icons.account_balance_wallet_rounded, + size: 16, + color: isSelected + ? const Color(0xFF7C6DED) + : Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.5), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + account.name, + style: TextStyle( + fontSize: 14, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w400, + color: isSelected + ? const Color(0xFF7C6DED) + : Theme.of(context).colorScheme.onSurface, + ), + ), + ), + if (isSelected) + const Icon( + Icons.check_rounded, + size: 16, + color: Color(0xFF7C6DED), + ), + ], + ), + ), + ); + }).toList(), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + ), + ), + ), + ); + } +} diff --git a/lib/features/add_transaction/widgets/account_row.dart b/lib/features/add_transaction/widgets/account_row.dart new file mode 100644 index 0000000..6d471fc --- /dev/null +++ b/lib/features/add_transaction/widgets/account_row.dart @@ -0,0 +1,425 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../shared/models/account.dart'; +import '../../../shared/models/transaction.dart'; +import '../../dashboard/provider.dart'; +import '../provider.dart'; + +class AccountRow extends ConsumerWidget { + final Transaction? initial; + final bool showFromDropdown; + final bool showToDropdown; + final VoidCallback onToggleFromDropdown; + final VoidCallback onToggleToDropdown; + final GlobalKey fromIndicatorKey; + final GlobalKey toIndicatorKey; + final String? fromAccountError; + final String? toAccountError; + final bool isDark; + + const AccountRow({ + super.key, + required this.initial, + required this.showFromDropdown, + required this.showToDropdown, + required this.onToggleFromDropdown, + required this.onToggleToDropdown, + required this.fromIndicatorKey, + required this.toIndicatorKey, + this.fromAccountError, + this.toAccountError, + required this.isDark, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(addTransactionProvider(initial)); + final accountsAsync = ref.watch(accountsProvider); + final accounts = accountsAsync.valueOrNull ?? []; + final isTransfer = state.type == TransactionType.transfer; + + // Auto-select toAccount when only 2 accounts exist + if (isTransfer && accounts.length == 2 && state.selectedAccountId != null) { + final otherId = accounts + .firstWhere( + (a) => a.id != state.selectedAccountId, + orElse: () => accounts.first, + ) + .id; + if (state.toAccountId != otherId) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ref + .read(addTransactionProvider(initial).notifier) + .setToAccountId(otherId); + }); + } + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Account', + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + ), + ), + const SizedBox(height: 8), + if (isTransfer) + _TransferAccountRow( + initial: initial, + accounts: accounts, + showFromDropdown: showFromDropdown, + showToDropdown: showToDropdown, + onToggleFromDropdown: onToggleFromDropdown, + onToggleToDropdown: onToggleToDropdown, + fromIndicatorKey: fromIndicatorKey, + toIndicatorKey: toIndicatorKey, + fromAccountError: fromAccountError, + toAccountError: toAccountError, + isDark: isDark, + ) + else + _SingleAccountSelector( + initial: initial, + accounts: accounts, + showDropdown: showFromDropdown, + onToggleDropdown: onToggleFromDropdown, + indicatorKey: fromIndicatorKey, + error: fromAccountError, + isDark: isDark, + ), + ], + ); + } +} + +class _SingleAccountSelector extends ConsumerWidget { + final Transaction? initial; + final List accounts; + final bool showDropdown; + final VoidCallback onToggleDropdown; + final GlobalKey indicatorKey; + final String? error; + final bool isDark; + + const _SingleAccountSelector({ + required this.initial, + required this.accounts, + required this.showDropdown, + required this.onToggleDropdown, + required this.indicatorKey, + this.error, + required this.isDark, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(addTransactionProvider(initial)); + final activeAccount = ref.watch(activeAccountProvider); + + final selectedAccountId = state.selectedAccountId; + final Account? displayAccount; + + if (selectedAccountId != null) { + displayAccount = accounts.firstWhere( + (a) => a.id == selectedAccountId, + orElse: () => accounts.isNotEmpty + ? accounts.first + : Account( + id: 0, + name: '', + currency: 'USD', + isMain: false, + sortOrder: 0, + createdAt: DateTime.now(), + ), + ); + } else { + displayAccount = + activeAccount ?? (accounts.isNotEmpty ? accounts.first : null); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: onToggleDropdown, + child: Container( + key: indicatorKey, + height: 56, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + color: error != null + ? const Color(0xFFE05C6B).withOpacity(0.1) + : Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: error != null + ? const Color(0xFFE05C6B) + : isDark + ? Colors.white.withOpacity(0.1) + : const Color(0xFFDDDDEE), + width: error != null ? 1.5 : 1, + ), + ), + child: Row( + children: [ + Icon( + Icons.account_balance_wallet_rounded, + size: 18, + color: error != null + ? const Color(0xFFE05C6B) + : Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.6), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + displayAccount?.name ?? 'Select account', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: displayAccount != null + ? Theme.of(context).colorScheme.onSurface + : Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.4), + fontWeight: FontWeight.w500, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Icon( + showDropdown ? Icons.arrow_drop_up : Icons.arrow_drop_down, + size: 20, + color: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.6), + ), + ], + ), + ), + ), + if (error != null) ...[ + const SizedBox(height: 6), + Text( + error!, + style: const TextStyle(fontSize: 12, color: Color(0xFFE05C6B)), + ), + ], + ], + ); + } +} + +class _TransferAccountRow extends ConsumerWidget { + final Transaction? initial; + final List accounts; + final bool showFromDropdown; + final bool showToDropdown; + final VoidCallback onToggleFromDropdown; + final VoidCallback onToggleToDropdown; + final GlobalKey fromIndicatorKey; + final GlobalKey toIndicatorKey; + final String? fromAccountError; + final String? toAccountError; + final bool isDark; + + const _TransferAccountRow({ + required this.initial, + required this.accounts, + required this.showFromDropdown, + required this.showToDropdown, + required this.onToggleFromDropdown, + required this.onToggleToDropdown, + required this.fromIndicatorKey, + required this.toIndicatorKey, + this.fromAccountError, + this.toAccountError, + required this.isDark, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(addTransactionProvider(initial)); + final activeAccount = ref.watch(activeAccountProvider); + + final selectedAccountId = state.selectedAccountId; + final toAccountId = state.toAccountId; + + final Account? fromAccount; + if (selectedAccountId != null) { + fromAccount = accounts.firstWhere( + (a) => a.id == selectedAccountId, + orElse: () => accounts.isNotEmpty + ? accounts.first + : Account( + id: 0, + name: '', + currency: 'USD', + isMain: false, + sortOrder: 0, + createdAt: DateTime.now(), + ), + ); + } else { + fromAccount = + activeAccount ?? (accounts.isNotEmpty ? accounts.first : null); + } + + final Account? toAccount = toAccountId != null && accounts.isNotEmpty + ? accounts.firstWhere( + (a) => a.id == toAccountId, + orElse: () => accounts.first, + ) + : null; + + final autoSelectEnabled = accounts.length == 2; + + return Row( + children: [ + Expanded( + child: _AccountHalf( + account: fromAccount, + label: 'From', + showDropdown: showFromDropdown, + onToggle: onToggleFromDropdown, + indicatorKey: fromIndicatorKey, + error: fromAccountError, + isDark: isDark, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Icon( + Icons.swap_horiz_rounded, + size: 24, + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.4), + ), + ), + Expanded( + child: _AccountHalf( + account: toAccount, + label: 'To', + showDropdown: showToDropdown, + onToggle: autoSelectEnabled ? null : onToggleToDropdown, + indicatorKey: toIndicatorKey, + error: toAccountError, + isDark: isDark, + disabled: autoSelectEnabled, + ), + ), + ], + ); + } +} + +class _AccountHalf extends StatelessWidget { + final Account? account; + final String label; + final bool showDropdown; + final VoidCallback? onToggle; + final GlobalKey indicatorKey; + final String? error; + final bool isDark; + final bool disabled; + + const _AccountHalf({ + required this.account, + required this.label, + required this.showDropdown, + required this.onToggle, + required this.indicatorKey, + this.error, + required this.isDark, + this.disabled = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: disabled ? null : onToggle, + child: Container( + key: indicatorKey, + height: 56, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: error != null + ? const Color(0xFFE05C6B).withOpacity(0.1) + : disabled + ? Theme.of(context).colorScheme.surface.withOpacity(0.5) + : Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: error != null + ? const Color(0xFFE05C6B) + : isDark + ? Colors.white.withOpacity(disabled ? 0.05 : 0.1) + : const Color(0xFFDDDDEE), + width: error != null ? 1.5 : 1, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + label, + style: TextStyle( + fontSize: 10, + color: Theme.of(context).colorScheme.onSurface + .withOpacity(disabled ? 0.3 : 0.5), + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + if (onToggle != null) + Icon( + showDropdown + ? Icons.arrow_drop_up + : Icons.arrow_drop_down, + size: 16, + color: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.5), + ), + ], + ), + const SizedBox(height: 2), + Text( + account?.name ?? 'Select', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: account != null + ? Theme.of(context).colorScheme.onSurface.withOpacity( + disabled ? 0.5 : 1.0, + ) + : Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.4), + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + if (error != null) ...[ + const SizedBox(height: 4), + Text( + error!, + style: const TextStyle(fontSize: 11, color: Color(0xFFE05C6B)), + ), + ], + ], + ); + } +} diff --git a/lib/features/add_transaction/widgets/type_toggle.dart b/lib/features/add_transaction/widgets/type_toggle.dart index d884b2e..86971f6 100644 --- a/lib/features/add_transaction/widgets/type_toggle.dart +++ b/lib/features/add_transaction/widgets/type_toggle.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/constants.dart'; import '../../../shared/models/transaction.dart'; +import '../../dashboard/provider.dart'; -class TypeToggle extends StatelessWidget { +class TypeToggle extends ConsumerWidget { final TransactionType selected; final ValueChanged onChanged; final bool isDark; @@ -15,7 +17,11 @@ class TypeToggle extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final accountsAsync = ref.watch(accountsProvider); + final accounts = accountsAsync.valueOrNull ?? []; + final transferDisabled = accounts.length <= 1; + return Container( height: 56, decoration: BoxDecoration( @@ -28,53 +34,36 @@ class TypeToggle extends StatelessWidget { child: Row( children: [ Expanded( - child: GestureDetector( + child: _TypeOption( + icon: Icons.arrow_downward_rounded, + label: 'Income', + color: AppColors.income, + isSelected: selected == TransactionType.income, onTap: () => onChanged(TransactionType.income), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - decoration: BoxDecoration( - color: selected == TransactionType.income - ? AppColors.income.withOpacity(0.15) - : Colors.transparent, - borderRadius: BorderRadius.circular(11), - ), - child: Center( - child: Icon( - Icons.arrow_downward_rounded, - color: selected == TransactionType.income - ? AppColors.income - : Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.4), - size: 20, - ), - ), - ), + isDark: isDark, ), ), Expanded( - child: GestureDetector( + child: _TypeOption( + icon: Icons.arrow_upward_rounded, + label: 'Expense', + color: AppColors.expense, + isSelected: selected == TransactionType.expense, onTap: () => onChanged(TransactionType.expense), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - decoration: BoxDecoration( - color: selected == TransactionType.expense - ? AppColors.expense.withOpacity(0.15) - : Colors.transparent, - borderRadius: BorderRadius.circular(11), - ), - child: Center( - child: Icon( - Icons.arrow_upward_rounded, - color: selected == TransactionType.expense - ? AppColors.expense - : Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.4), - size: 20, - ), - ), - ), + isDark: isDark, + ), + ), + Expanded( + child: _TypeOption( + icon: Icons.swap_horiz_rounded, + label: 'Transfer', + color: Colors.blueAccent, + isSelected: selected == TransactionType.transfer, + onTap: transferDisabled + ? null + : () => onChanged(TransactionType.transfer), + isDark: isDark, + disabled: transferDisabled, ), ), ], @@ -82,3 +71,75 @@ class TypeToggle extends StatelessWidget { ); } } + +class _TypeOption extends StatelessWidget { + final IconData icon; + final String label; + final Color color; + final bool isSelected; + final VoidCallback? onTap; + final bool isDark; + final bool disabled; + + const _TypeOption({ + required this.icon, + required this.label, + required this.color, + required this.isSelected, + required this.onTap, + required this.isDark, + this.disabled = false, + }); + + @override + Widget build(BuildContext context) { + final effectiveColor = disabled + ? Theme.of(context).colorScheme.onSurface.withOpacity(0.2) + : color; + + return GestureDetector( + onTap: disabled ? null : onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + color: isSelected && !disabled + ? effectiveColor.withOpacity(0.15) + : Colors.transparent, + borderRadius: BorderRadius.circular(11), + border: isSelected && !disabled + ? Border.all(color: effectiveColor, width: 1.5) + : null, + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + color: isSelected && !disabled + ? effectiveColor + : Theme.of( + context, + ).colorScheme.onSurface.withOpacity(disabled ? 0.2 : 0.4), + size: 20, + ), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected && !disabled + ? effectiveColor + : Theme.of(context).colorScheme.onSurface.withOpacity( + disabled ? 0.2 : 0.5, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/dashboard/provider.dart b/lib/features/dashboard/provider.dart index 5d4234d..9f047c0 100644 --- a/lib/features/dashboard/provider.dart +++ b/lib/features/dashboard/provider.dart @@ -114,7 +114,7 @@ class TransactionsNotifier final searchQueryProvider = StateProvider((ref) => ''); -enum TransactionFilter { all, income, expense } +enum TransactionFilter { all, income, expense, transfer } enum TimeFilter { allTime, lastMonth } @@ -146,7 +146,7 @@ final globalTotalBalanceProvider = Provider((ref) { final exchangeService = ref.watch(exchangeRateServiceProvider); final targetCurrency = ref.watch(currencyProvider).code; - return txs.fold(0.0, (sum, t) { + return txs.where((t) => t.category != 'Transfer').fold(0.0, (sum, t) { final converted = exchangeService.convert( t.amount, t.currencyCode, @@ -173,7 +173,7 @@ final totalBalanceProvider = Provider((ref) { final exchangeService = ref.watch(exchangeRateServiceProvider); - return txs.fold(0.0, (sum, t) { + return txs.where((t) => t.category != 'Transfer').fold(0.0, (sum, t) { final converted = exchangeService.convert( t.amount, t.currencyCode, @@ -186,7 +186,9 @@ final totalBalanceProvider = Provider((ref) { final totalIncomeProvider = Provider((ref) { // Watch the filtered transactions directly final txs = ref.watch(accountFilteredTransactionsProvider); - final filtered = txs.where((t) => t.type == TransactionType.income); + final filtered = txs.where( + (t) => t.type == TransactionType.income && t.category != 'Transfer', + ); // Watch the dependencies that change on swipe! final index = ref.watch(activeAccountIndexProvider); @@ -212,7 +214,9 @@ final totalIncomeProvider = Provider((ref) { final totalExpenseProvider = Provider((ref) { final txs = ref.watch(accountFilteredTransactionsProvider); - final filtered = txs.where((t) => t.type == TransactionType.expense); + final filtered = txs.where( + (t) => t.type == TransactionType.expense && t.category != 'Transfer', + ); final index = ref.watch(activeAccountIndexProvider); final accountsAsync = ref.watch(accountsProvider); @@ -291,6 +295,8 @@ final filteredTransactionsProvider = Provider>((ref) { filtered = filtered .where((t) => t.type == TransactionType.expense) .toList(); + } else if (typeFilter == TransactionFilter.transfer) { + filtered = filtered.where((t) => t.category == 'Transfer').toList(); } if (query.isNotEmpty) { @@ -402,8 +408,9 @@ class CardColorsNotifier extends StateNotifier { Future _load() async { final currentGeneration = ++_loadGeneration; - final (c1, c2, lightG, darkG) = - await CardColorService.load(accountId: accountId); + final (c1, c2, lightG, darkG) = await CardColorService.load( + accountId: accountId, + ); if (currentGeneration != _loadGeneration) return; // stale state = CardColors(c1, c2, lightG, darkG); } diff --git a/lib/features/dashboard/widgets/filter_chips.dart b/lib/features/dashboard/widgets/filter_chips.dart index 6e5fc9b..ade5e26 100644 --- a/lib/features/dashboard/widgets/filter_chips.dart +++ b/lib/features/dashboard/widgets/filter_chips.dart @@ -15,49 +15,66 @@ class FilterChips extends ConsumerWidget { final timeFilter = ref.watch(timeFilterProvider); final isDark = Theme.of(context).brightness == Brightness.dark; - return Row( - children: [ - _FilterChip( - label: strings.filterAllTime, - isSelected: timeFilter == TimeFilter.allTime, - onTap: () => ref.read(timeFilterProvider.notifier).state = TimeFilter.allTime, - ), - const SizedBox(width: 6), - _FilterChip( - label: strings.filterMonth, - isSelected: timeFilter == TimeFilter.lastMonth, - onTap: () => ref.read(timeFilterProvider.notifier).state = TimeFilter.lastMonth, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Container( - width: 1, - height: 20, - color: Theme.of(context).colorScheme.onSurface.withOpacity( - isDark ? 0.15 : 0.2, + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + child: Row( + children: [ + _FilterChip( + label: strings.filterAllTime, + isSelected: timeFilter == TimeFilter.allTime, + onTap: () => ref.read(timeFilterProvider.notifier).state = + TimeFilter.allTime, + ), + const SizedBox(width: 6), + _FilterChip( + label: strings.filterMonth, + isSelected: timeFilter == TimeFilter.lastMonth, + onTap: () => ref.read(timeFilterProvider.notifier).state = + TimeFilter.lastMonth, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Container( + width: 1, + height: 20, + color: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(isDark ? 0.15 : 0.2), ), ), - ), - _FilterChip( - label: strings.filterAll, - isSelected: typeFilter == TransactionFilter.all, - onTap: () => ref.read(transactionFilterProvider.notifier).state = TransactionFilter.all, - ), - const SizedBox(width: 6), - _FilterChip( - label: strings.filterIncome, - isSelected: typeFilter == TransactionFilter.income, - color: AppColors.income, - onTap: () => ref.read(transactionFilterProvider.notifier).state = TransactionFilter.income, - ), - const SizedBox(width: 6), - _FilterChip( - label: strings.filterExpense, - isSelected: typeFilter == TransactionFilter.expense, - color: AppColors.expense, - onTap: () => ref.read(transactionFilterProvider.notifier).state = TransactionFilter.expense, - ), - ], + _FilterChip( + label: strings.filterAll, + isSelected: typeFilter == TransactionFilter.all, + onTap: () => ref.read(transactionFilterProvider.notifier).state = + TransactionFilter.all, + ), + const SizedBox(width: 6), + _FilterChip( + label: strings.filterIncome, + isSelected: typeFilter == TransactionFilter.income, + color: AppColors.income, + onTap: () => ref.read(transactionFilterProvider.notifier).state = + TransactionFilter.income, + ), + const SizedBox(width: 6), + _FilterChip( + label: strings.filterExpense, + isSelected: typeFilter == TransactionFilter.expense, + color: AppColors.expense, + onTap: () => ref.read(transactionFilterProvider.notifier).state = + TransactionFilter.expense, + ), + const SizedBox(width: 6), + _FilterChip( + label: strings.filterTransfer, + isSelected: typeFilter == TransactionFilter.transfer, + color: Colors.blueAccent, + onTap: () => ref.read(transactionFilterProvider.notifier).state = + TransactionFilter.transfer, + ), + ], + ), ); } } @@ -96,8 +113,8 @@ class _FilterChip extends StatelessWidget { border: isSelected ? Border.all(color: chipColor, width: 1.5) : isDark - ? null - : Border.all(color: const Color(0xFFDDDDEE), width: 1), + ? null + : Border.all(color: const Color(0xFFDDDDEE), width: 1), ), child: Text( label, diff --git a/lib/features/dashboard/widgets/transaction_tile.dart b/lib/features/dashboard/widgets/transaction_tile.dart index e05a189..98c1e87 100644 --- a/lib/features/dashboard/widgets/transaction_tile.dart +++ b/lib/features/dashboard/widgets/transaction_tile.dart @@ -29,19 +29,23 @@ class TransactionTile extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final s = ref.watch(stringsProvider); final fmt = ref.watch(amountFormatProvider); + final isTransfer = transaction.category == 'Transfer'; final isIncome = transaction.type == TransactionType.income; - final color = isIncome ? AppColors.income : AppColors.expense; - final catColor = - AppCategories.colors[transaction.category] ?? AppColors.accent; - final catIcon = - AppCategories.icons[transaction.category] ?? Icons.category_rounded; + final color = isTransfer + ? const Color(0xFF7C6DED) + : (isIncome ? AppColors.income : AppColors.expense); + final catColor = isTransfer + ? const Color(0xFF7C6DED) + : (AppCategories.colors[transaction.category] ?? AppColors.accent); + final catIcon = isTransfer + ? Icons.swap_horiz_rounded + : (AppCategories.icons[transaction.category] ?? Icons.category_rounded); // Check if we're on Total Balance page final activeAccount = ref.watch(activeAccountProvider); - final displayCurrency = activeAccount?.currency ?? - ref.watch(currencyProvider).code; - final showConverted = - transaction.currencyCode != displayCurrency; + final displayCurrency = + activeAccount?.currency ?? ref.watch(currencyProvider).code; + final showConverted = transaction.currencyCode != displayCurrency; final exchangeService = ref.watch(exchangeRateServiceProvider); final convertedAmount = showConverted ? exchangeService.convert( @@ -50,8 +54,7 @@ class TransactionTile extends ConsumerWidget { displayCurrency, ) : 0.0; - final displaySymbol = - currencyMap[displayCurrency]?.symbol ?? ''; + final displaySymbol = currencyMap[displayCurrency]?.symbol ?? ''; // Look up the account name by matching transaction.accountId final accounts = ref.watch(accountsProvider).valueOrNull ?? []; @@ -93,7 +96,9 @@ class TransactionTile extends ConsumerWidget { children: [ Flexible( child: Text( - s.categoryLabel(transaction.category), + isTransfer + ? 'Transfer' + : s.categoryLabel(transaction.category), style: Theme.of(context).textTheme.bodyMedium ?.copyWith( fontWeight: FontWeight.w600, @@ -143,12 +148,12 @@ class TransactionTile extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( - isIncome ? '+ ' : '- ', + isTransfer ? '' : (isIncome ? '+ ' : '- '), style: Theme.of(context).textTheme.bodyMedium ?.copyWith( - color: color, - fontWeight: FontWeight.w700, - ), + color: color, + fontWeight: FontWeight.w700, + ), ), BynSign(fontSize: 14, color: color), const SizedBox(width: 2), @@ -156,14 +161,20 @@ class TransactionTile extends ConsumerWidget { formatAmount('', transaction.amount, fmt), style: Theme.of(context).textTheme.bodyMedium ?.copyWith( - color: color, - fontWeight: FontWeight.w700, - ), + color: color, + fontWeight: FontWeight.w700, + ), ), ], ) : Text( - '${isIncome ? '+ ' : '- '}${formatAmount(transaction.currency, transaction.amount, fmt)}', + isTransfer + ? formatAmount( + transaction.currency, + transaction.amount, + fmt, + ) + : '${isIncome ? '+ ' : '- '}${formatAmount(transaction.currency, transaction.amount, fmt)}', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: color, fontWeight: FontWeight.w700, @@ -184,10 +195,7 @@ class TransactionTile extends ConsumerWidget { height: 1.3, ), ), - BynSign( - fontSize: 11, - color: color.withOpacity(0.5), - ), + BynSign(fontSize: 11, color: color.withOpacity(0.5)), const SizedBox(width: 2), Text( formatAmount('', convertedAmount, fmt), diff --git a/lib/shared/models/transaction.dart b/lib/shared/models/transaction.dart index 503dea4..afbab0e 100644 --- a/lib/shared/models/transaction.dart +++ b/lib/shared/models/transaction.dart @@ -1,6 +1,6 @@ import 'package:flutter/foundation.dart'; -enum TransactionType { income, expense } +enum TransactionType { income, expense, transfer } enum RecurrenceType { none, daily, weekly, monthly } @@ -33,41 +33,39 @@ class Transaction { }); factory Transaction.fromJson(Map json) => Transaction( - id: json['id'] as String, - amount: (json['amount'] as num).toDouble(), - category: json['category'] as String, - type: TransactionType.values.firstWhere( - (e) => e.name == json['type'], - ), - date: DateTime.parse(json['date'] as String), - note: json['note'] as String?, - recurrence: json['recurrence'] != null - ? RecurrenceType.values.firstWhere( - (e) => e.name == json['recurrence'], - orElse: () => RecurrenceType.none, - ) - : RecurrenceType.none, - lastOccurrence: json['lastOccurrence'] != null - ? DateTime.parse(json['lastOccurrence'] as String) - : null, - currency: json['currency'] as String? ?? '\$', - currencyCode: json['currencyCode'] as String? ?? 'USD', - accountId: json['accountId'] as int, - ); + id: json['id'] as String, + amount: (json['amount'] as num).toDouble(), + category: json['category'] as String, + type: TransactionType.values.firstWhere((e) => e.name == json['type']), + date: DateTime.parse(json['date'] as String), + note: json['note'] as String?, + recurrence: json['recurrence'] != null + ? RecurrenceType.values.firstWhere( + (e) => e.name == json['recurrence'], + orElse: () => RecurrenceType.none, + ) + : RecurrenceType.none, + lastOccurrence: json['lastOccurrence'] != null + ? DateTime.parse(json['lastOccurrence'] as String) + : null, + currency: json['currency'] as String? ?? '\$', + currencyCode: json['currencyCode'] as String? ?? 'USD', + accountId: json['accountId'] as int, + ); Map toJson() => { - 'id': id, - 'amount': amount, - 'category': category, - 'type': type.name, - 'date': date.toIso8601String(), - 'note': note, - 'recurrence': recurrence.name, - 'lastOccurrence': lastOccurrence?.toIso8601String(), - 'currency': currency, - 'currencyCode': currencyCode, - 'accountId': accountId, - }; + 'id': id, + 'amount': amount, + 'category': category, + 'type': type.name, + 'date': date.toIso8601String(), + 'note': note, + 'recurrence': recurrence.name, + 'lastOccurrence': lastOccurrence?.toIso8601String(), + 'currency': currency, + 'currencyCode': currencyCode, + 'accountId': accountId, + }; Transaction copyWith({ String? id, @@ -81,18 +79,17 @@ class Transaction { String? currency, String? currencyCode, int? accountId, - }) => - Transaction( - id: id ?? this.id, - amount: amount ?? this.amount, - category: category ?? this.category, - type: type ?? this.type, - date: date ?? this.date, - note: note ?? this.note, - recurrence: recurrence ?? this.recurrence, - lastOccurrence: lastOccurrence ?? this.lastOccurrence, - currency: currency ?? this.currency, - currencyCode: currencyCode ?? this.currencyCode, - accountId: accountId ?? this.accountId, - ); + }) => Transaction( + id: id ?? this.id, + amount: amount ?? this.amount, + category: category ?? this.category, + type: type ?? this.type, + date: date ?? this.date, + note: note ?? this.note, + recurrence: recurrence ?? this.recurrence, + lastOccurrence: lastOccurrence ?? this.lastOccurrence, + currency: currency ?? this.currency, + currencyCode: currencyCode ?? this.currencyCode, + accountId: accountId ?? this.accountId, + ); }