This commit is contained in:
2026-03-27 12:16:37 +03:00
parent b7047c0ec7
commit 123c7d0eb4
9 changed files with 991 additions and 259 deletions
+1
View File
@@ -19,6 +19,7 @@ class AppStrings {
String get filterAll => _ru ? 'Все' : 'All'; String get filterAll => _ru ? 'Все' : 'All';
String get filterIncome => _ru ? 'Доход' : 'Income'; String get filterIncome => _ru ? 'Доход' : 'Income';
String get filterExpense => _ru ? 'Расход' : 'Expense'; String get filterExpense => _ru ? 'Расход' : 'Expense';
String get filterTransfer => _ru ? 'Перевод' : 'Transfer';
String get filterAllTime => _ru ? 'Всё время' : 'All Time'; String get filterAllTime => _ru ? 'Всё время' : 'All Time';
String get filterMonth => _ru ? 'Месяц' : 'Month'; String get filterMonth => _ru ? 'Месяц' : 'Month';
String get income => _ru ? 'Доход' : 'Income'; String get income => _ru ? 'Доход' : 'Income';
+13 -1
View File
@@ -13,6 +13,7 @@ class AddTransactionState {
final String overrideCurrency; final String overrideCurrency;
final String overrideCurrencyCode; final String overrideCurrencyCode;
final int? selectedAccountId; final int? selectedAccountId;
final int? toAccountId;
const AddTransactionState({ const AddTransactionState({
this.amount, this.amount,
@@ -25,6 +26,7 @@ class AddTransactionState {
this.overrideCurrency = '\$', this.overrideCurrency = '\$',
this.overrideCurrencyCode = 'USD', this.overrideCurrencyCode = 'USD',
this.selectedAccountId, this.selectedAccountId,
this.toAccountId,
}); });
factory AddTransactionState.fromTransaction(Transaction tx) { factory AddTransactionState.fromTransaction(Transaction tx) {
@@ -56,6 +58,7 @@ class AddTransactionState {
String? overrideCurrency, String? overrideCurrency,
String? overrideCurrencyCode, String? overrideCurrencyCode,
int? selectedAccountId, int? selectedAccountId,
int? toAccountId,
}) => AddTransactionState( }) => AddTransactionState(
amount: amount ?? this.amount, amount: amount ?? this.amount,
category: category ?? this.category, category: category ?? this.category,
@@ -67,6 +70,7 @@ class AddTransactionState {
overrideCurrency: overrideCurrency ?? this.overrideCurrency, overrideCurrency: overrideCurrency ?? this.overrideCurrency,
overrideCurrencyCode: overrideCurrencyCode ?? this.overrideCurrencyCode, overrideCurrencyCode: overrideCurrencyCode ?? this.overrideCurrencyCode,
selectedAccountId: selectedAccountId ?? this.selectedAccountId, selectedAccountId: selectedAccountId ?? this.selectedAccountId,
toAccountId: toAccountId ?? this.toAccountId,
); );
bool get isEditing => editingId != null; bool get isEditing => editingId != null;
@@ -85,8 +89,12 @@ class AddTransactionNotifier extends StateNotifier<AddTransactionState> {
void setCategory(String v) => state = state.copyWith(category: v); void setCategory(String v) => state = state.copyWith(category: v);
void setType(TransactionType v) { void setType(TransactionType v) {
if (v == TransactionType.transfer) {
state = state.copyWith(type: v, category: 'Transfer', toAccountId: null);
} else {
final newCategory = AppCategories.forType(v).first; final newCategory = AppCategories.forType(v).first;
state = state.copyWith(type: v, category: newCategory); state = state.copyWith(type: v, category: newCategory, toAccountId: null);
}
} }
void setDate(DateTime v) => state = state.copyWith(date: v); void setDate(DateTime v) => state = state.copyWith(date: v);
@@ -104,6 +112,10 @@ class AddTransactionNotifier extends StateNotifier<AddTransactionState> {
void setAccountId(int id) => state = state.copyWith(selectedAccountId: id); 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(); void reset() => state = AddTransactionState.empty();
} }
+238 -34
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
@@ -10,6 +9,7 @@ import '../../shared/models/transaction.dart';
import '../dashboard/provider.dart'; import '../dashboard/provider.dart';
import '../settings/provider.dart'; import '../settings/provider.dart';
import 'provider.dart'; import 'provider.dart';
import 'widgets/account_row.dart';
import 'widgets/account_selector.dart'; import 'widgets/account_selector.dart';
import 'widgets/amount_input.dart'; import 'widgets/amount_input.dart';
import 'widgets/category_picker.dart'; import 'widgets/category_picker.dart';
@@ -37,13 +37,17 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _amountController = TextEditingController(); final _amountController = TextEditingController();
final _noteController = TextEditingController(); final _noteController = TextEditingController();
final _accountIndicatorKey = GlobalKey(); final _fromAccountIndicatorKey = GlobalKey();
final _toAccountIndicatorKey = GlobalKey();
late AnimationController _shakeController; late AnimationController _shakeController;
late Animation<Color?> _borderColorAnimation; late Animation<Color?> _borderColorAnimation;
bool _showError = false; bool _showError = false;
late DateTime _selectedDate; late DateTime _selectedDate;
late TimeOfDay _selectedTime; late TimeOfDay _selectedTime;
bool _showAccountDropdown = false; bool _showFromAccountDropdown = false;
bool _showToAccountDropdown = false;
String? _toAccountError;
String? _fromAccountError;
@override @override
void initState() { void initState() {
@@ -82,6 +86,13 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
ref ref
.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) {
ref
.read(addTransactionProvider(null).notifier)
.setAccountId(activeAccount.id);
}
}); });
} }
} }
@@ -134,6 +145,30 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
final amount = double.parse(parsed); final amount = double.parse(parsed);
final state = ref.read(addTransactionProvider(widget.initial)); 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( final finalDateTime = DateTime(
_selectedDate.year, _selectedDate.year,
_selectedDate.month, _selectedDate.month,
@@ -160,6 +195,47 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
'Amount: $amount, Category: ${state.category}, Type: ${state.type.name}', 'Amount: $amount, Category: ${state.category}, Type: ${state.type.name}',
); );
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;
final expense = Transaction(
id: _uuid.v4(),
amount: amount,
category: 'Transfer',
type: TransactionType.expense,
date: finalDateTime,
note: note,
currency: currency,
currencyCode: currencyCode,
accountId: state.selectedAccountId!,
);
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 {
// 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))
@@ -176,7 +252,9 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
accountId = activeAccount.id; accountId = activeAccount.id;
} else { } else {
print('No active account. Fetching main account...'); print('No active account. Fetching main account...');
final mainAccount = await ref.read(accountRepositoryProvider).getMain(); final mainAccount = await ref
.read(accountRepositoryProvider)
.getMain();
print( print(
'Main account fetched: ID=${mainAccount.id}, Name: ${mainAccount.name}', 'Main account fetched: ID=${mainAccount.id}, Name: ${mainAccount.name}',
); );
@@ -212,6 +290,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
throw Exception(res.errorOrNull); throw Exception(res.errorOrNull);
} }
} }
}
print('Provider save completed successfully'); print('Provider save completed successfully');
HapticService.medium(); HapticService.medium();
@@ -349,37 +428,32 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
ListView( ListView(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
children: [ children: [
Row( TypeToggle(
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, selected: state.type,
onChanged: (type) => ref onChanged: (type) => ref
.read( .read(addTransactionProvider(widget.initial).notifier)
addTransactionProvider(widget.initial).notifier,
)
.setType(type), .setType(type),
isDark: isDark, 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), const SizedBox(height: 24),
@@ -419,6 +493,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
if (state.type != TransactionType.transfer) ...[
SectionLabel(s.category), SectionLabel(s.category),
const SizedBox(height: 8), const SizedBox(height: 8),
CategoryPicker( CategoryPicker(
@@ -429,6 +504,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
.setCategory(c), .setCategory(c),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
],
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -477,18 +553,33 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
), ),
], ],
), ),
if (_showAccountDropdown) if (_showFromAccountDropdown)
Positioned.fill( Positioned.fill(
child: GestureDetector( child: GestureDetector(
onTap: () => setState(() => _showAccountDropdown = false), onTap: () =>
setState(() => _showFromAccountDropdown = false),
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
child: const SizedBox.expand(), child: const SizedBox.expand(),
), ),
), ),
if (_showAccountDropdown) if (_showFromAccountDropdown)
AccountDropdownOverlay( AccountDropdownOverlay(
initial: widget.initial, 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<AddTransactionScreen>
); );
} }
} }
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(),
),
),
),
);
}
}
@@ -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<Account> 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<Account> 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)),
),
],
],
);
}
}
@@ -1,8 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/constants.dart'; import '../../../core/constants.dart';
import '../../../shared/models/transaction.dart'; import '../../../shared/models/transaction.dart';
import '../../dashboard/provider.dart';
class TypeToggle extends StatelessWidget { class TypeToggle extends ConsumerWidget {
final TransactionType selected; final TransactionType selected;
final ValueChanged<TransactionType> onChanged; final ValueChanged<TransactionType> onChanged;
final bool isDark; final bool isDark;
@@ -15,7 +17,11 @@ class TypeToggle extends StatelessWidget {
}); });
@override @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( return Container(
height: 56, height: 56,
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -28,53 +34,36 @@ class TypeToggle extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: GestureDetector( child: _TypeOption(
icon: Icons.arrow_downward_rounded,
label: 'Income',
color: AppColors.income,
isSelected: selected == TransactionType.income,
onTap: () => onChanged(TransactionType.income), onTap: () => onChanged(TransactionType.income),
child: AnimatedContainer( isDark: isDark,
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,
),
),
),
), ),
), ),
Expanded( Expanded(
child: GestureDetector( child: _TypeOption(
icon: Icons.arrow_upward_rounded,
label: 'Expense',
color: AppColors.expense,
isSelected: selected == TransactionType.expense,
onTap: () => onChanged(TransactionType.expense), onTap: () => onChanged(TransactionType.expense),
child: AnimatedContainer( isDark: isDark,
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,
),
), ),
), ),
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,
),
),
),
],
),
),
),
);
}
}
+14 -7
View File
@@ -114,7 +114,7 @@ class TransactionsNotifier
final searchQueryProvider = StateProvider<String>((ref) => ''); final searchQueryProvider = StateProvider<String>((ref) => '');
enum TransactionFilter { all, income, expense } enum TransactionFilter { all, income, expense, transfer }
enum TimeFilter { allTime, lastMonth } enum TimeFilter { allTime, lastMonth }
@@ -146,7 +146,7 @@ final globalTotalBalanceProvider = Provider<double>((ref) {
final exchangeService = ref.watch(exchangeRateServiceProvider); final exchangeService = ref.watch(exchangeRateServiceProvider);
final targetCurrency = ref.watch(currencyProvider).code; 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( final converted = exchangeService.convert(
t.amount, t.amount,
t.currencyCode, t.currencyCode,
@@ -173,7 +173,7 @@ final totalBalanceProvider = Provider<double>((ref) {
final exchangeService = ref.watch(exchangeRateServiceProvider); 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( final converted = exchangeService.convert(
t.amount, t.amount,
t.currencyCode, t.currencyCode,
@@ -186,7 +186,9 @@ final totalBalanceProvider = Provider<double>((ref) {
final totalIncomeProvider = Provider<double>((ref) { final totalIncomeProvider = Provider<double>((ref) {
// Watch the filtered transactions directly // Watch the filtered transactions directly
final txs = ref.watch(accountFilteredTransactionsProvider); 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! // Watch the dependencies that change on swipe!
final index = ref.watch(activeAccountIndexProvider); final index = ref.watch(activeAccountIndexProvider);
@@ -212,7 +214,9 @@ final totalIncomeProvider = Provider<double>((ref) {
final totalExpenseProvider = Provider<double>((ref) { final totalExpenseProvider = Provider<double>((ref) {
final txs = ref.watch(accountFilteredTransactionsProvider); 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 index = ref.watch(activeAccountIndexProvider);
final accountsAsync = ref.watch(accountsProvider); final accountsAsync = ref.watch(accountsProvider);
@@ -291,6 +295,8 @@ final filteredTransactionsProvider = Provider<List<Transaction>>((ref) {
filtered = filtered filtered = filtered
.where((t) => t.type == TransactionType.expense) .where((t) => t.type == TransactionType.expense)
.toList(); .toList();
} else if (typeFilter == TransactionFilter.transfer) {
filtered = filtered.where((t) => t.category == 'Transfer').toList();
} }
if (query.isNotEmpty) { if (query.isNotEmpty) {
@@ -402,8 +408,9 @@ class CardColorsNotifier extends StateNotifier<CardColors> {
Future<void> _load() async { Future<void> _load() async {
final currentGeneration = ++_loadGeneration; final currentGeneration = ++_loadGeneration;
final (c1, c2, lightG, darkG) = final (c1, c2, lightG, darkG) = await CardColorService.load(
await CardColorService.load(accountId: accountId); accountId: accountId,
);
if (currentGeneration != _loadGeneration) return; // stale if (currentGeneration != _loadGeneration) return; // stale
state = CardColors(c1, c2, lightG, darkG); state = CardColors(c1, c2, lightG, darkG);
} }
@@ -15,49 +15,66 @@ class FilterChips extends ConsumerWidget {
final timeFilter = ref.watch(timeFilterProvider); final timeFilter = ref.watch(timeFilterProvider);
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
return Row( return SingleChildScrollView(
scrollDirection: Axis.horizontal,
clipBehavior: Clip.none,
child: Row(
children: [ children: [
_FilterChip( _FilterChip(
label: strings.filterAllTime, label: strings.filterAllTime,
isSelected: timeFilter == TimeFilter.allTime, isSelected: timeFilter == TimeFilter.allTime,
onTap: () => ref.read(timeFilterProvider.notifier).state = TimeFilter.allTime, onTap: () => ref.read(timeFilterProvider.notifier).state =
TimeFilter.allTime,
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
_FilterChip( _FilterChip(
label: strings.filterMonth, label: strings.filterMonth,
isSelected: timeFilter == TimeFilter.lastMonth, isSelected: timeFilter == TimeFilter.lastMonth,
onTap: () => ref.read(timeFilterProvider.notifier).state = TimeFilter.lastMonth, onTap: () => ref.read(timeFilterProvider.notifier).state =
TimeFilter.lastMonth,
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Container( child: Container(
width: 1, width: 1,
height: 20, height: 20,
color: Theme.of(context).colorScheme.onSurface.withOpacity( color: Theme.of(
isDark ? 0.15 : 0.2, context,
), ).colorScheme.onSurface.withOpacity(isDark ? 0.15 : 0.2),
), ),
), ),
_FilterChip( _FilterChip(
label: strings.filterAll, label: strings.filterAll,
isSelected: typeFilter == TransactionFilter.all, isSelected: typeFilter == TransactionFilter.all,
onTap: () => ref.read(transactionFilterProvider.notifier).state = TransactionFilter.all, onTap: () => ref.read(transactionFilterProvider.notifier).state =
TransactionFilter.all,
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
_FilterChip( _FilterChip(
label: strings.filterIncome, label: strings.filterIncome,
isSelected: typeFilter == TransactionFilter.income, isSelected: typeFilter == TransactionFilter.income,
color: AppColors.income, color: AppColors.income,
onTap: () => ref.read(transactionFilterProvider.notifier).state = TransactionFilter.income, onTap: () => ref.read(transactionFilterProvider.notifier).state =
TransactionFilter.income,
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
_FilterChip( _FilterChip(
label: strings.filterExpense, label: strings.filterExpense,
isSelected: typeFilter == TransactionFilter.expense, isSelected: typeFilter == TransactionFilter.expense,
color: AppColors.expense, color: AppColors.expense,
onTap: () => ref.read(transactionFilterProvider.notifier).state = TransactionFilter.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,
), ),
], ],
),
); );
} }
} }
@@ -29,19 +29,23 @@ class TransactionTile extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final s = ref.watch(stringsProvider); final s = ref.watch(stringsProvider);
final fmt = ref.watch(amountFormatProvider); final fmt = ref.watch(amountFormatProvider);
final isTransfer = transaction.category == 'Transfer';
final isIncome = transaction.type == TransactionType.income; final isIncome = transaction.type == TransactionType.income;
final color = isIncome ? AppColors.income : AppColors.expense; final color = isTransfer
final catColor = ? const Color(0xFF7C6DED)
AppCategories.colors[transaction.category] ?? AppColors.accent; : (isIncome ? AppColors.income : AppColors.expense);
final catIcon = final catColor = isTransfer
AppCategories.icons[transaction.category] ?? Icons.category_rounded; ? 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 // Check if we're on Total Balance page
final activeAccount = ref.watch(activeAccountProvider); final activeAccount = ref.watch(activeAccountProvider);
final displayCurrency = activeAccount?.currency ?? final displayCurrency =
ref.watch(currencyProvider).code; activeAccount?.currency ?? ref.watch(currencyProvider).code;
final showConverted = final showConverted = transaction.currencyCode != displayCurrency;
transaction.currencyCode != displayCurrency;
final exchangeService = ref.watch(exchangeRateServiceProvider); final exchangeService = ref.watch(exchangeRateServiceProvider);
final convertedAmount = showConverted final convertedAmount = showConverted
? exchangeService.convert( ? exchangeService.convert(
@@ -50,8 +54,7 @@ class TransactionTile extends ConsumerWidget {
displayCurrency, displayCurrency,
) )
: 0.0; : 0.0;
final displaySymbol = final displaySymbol = currencyMap[displayCurrency]?.symbol ?? '';
currencyMap[displayCurrency]?.symbol ?? '';
// Look up the account name by matching transaction.accountId // Look up the account name by matching transaction.accountId
final accounts = ref.watch(accountsProvider).valueOrNull ?? []; final accounts = ref.watch(accountsProvider).valueOrNull ?? [];
@@ -93,7 +96,9 @@ class TransactionTile extends ConsumerWidget {
children: [ children: [
Flexible( Flexible(
child: Text( child: Text(
s.categoryLabel(transaction.category), isTransfer
? 'Transfer'
: s.categoryLabel(transaction.category),
style: Theme.of(context).textTheme.bodyMedium style: Theme.of(context).textTheme.bodyMedium
?.copyWith( ?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -143,7 +148,7 @@ class TransactionTile extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Text( Text(
isIncome ? '+ ' : '- ', isTransfer ? '' : (isIncome ? '+ ' : '- '),
style: Theme.of(context).textTheme.bodyMedium style: Theme.of(context).textTheme.bodyMedium
?.copyWith( ?.copyWith(
color: color, color: color,
@@ -163,7 +168,13 @@ class TransactionTile extends ConsumerWidget {
], ],
) )
: Text( : 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( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: color, color: color,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@@ -184,10 +195,7 @@ class TransactionTile extends ConsumerWidget {
height: 1.3, height: 1.3,
), ),
), ),
BynSign( BynSign(fontSize: 11, color: color.withOpacity(0.5)),
fontSize: 11,
color: color.withOpacity(0.5),
),
const SizedBox(width: 2), const SizedBox(width: 2),
Text( Text(
formatAmount('', convertedAmount, fmt), formatAmount('', convertedAmount, fmt),
+3 -6
View File
@@ -1,6 +1,6 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
enum TransactionType { income, expense } enum TransactionType { income, expense, transfer }
enum RecurrenceType { none, daily, weekly, monthly } enum RecurrenceType { none, daily, weekly, monthly }
@@ -36,9 +36,7 @@ class Transaction {
id: json['id'] as String, id: json['id'] as String,
amount: (json['amount'] as num).toDouble(), amount: (json['amount'] as num).toDouble(),
category: json['category'] as String, category: json['category'] as String,
type: TransactionType.values.firstWhere( type: TransactionType.values.firstWhere((e) => e.name == json['type']),
(e) => e.name == json['type'],
),
date: DateTime.parse(json['date'] as String), date: DateTime.parse(json['date'] as String),
note: json['note'] as String?, note: json['note'] as String?,
recurrence: json['recurrence'] != null recurrence: json['recurrence'] != null
@@ -81,8 +79,7 @@ class Transaction {
String? currency, String? currency,
String? currencyCode, String? currencyCode,
int? accountId, int? accountId,
}) => }) => Transaction(
Transaction(
id: id ?? this.id, id: id ?? this.id,
amount: amount ?? this.amount, amount: amount ?? this.amount,
category: category ?? this.category, category: category ?? this.category,