This commit is contained in:
2026-03-29 15:59:49 +03:00
parent 1f6d129fc2
commit bc8c272e63
21 changed files with 114 additions and 327 deletions
@@ -30,7 +30,6 @@ class AddTransactionState {
});
factory AddTransactionState.fromTransaction(Transaction tx) {
// Override type to transfer when category is 'Transfer'
final resolvedType = (tx.category == 'Transfer')
? TransactionType.transfer
: tx.type;
+1 -7
View File
@@ -206,7 +206,6 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
final amount = double.parse(parsed);
final state = ref.read(addTransactionProvider(widget.initial));
// Validate transfer
if (state.type == TransactionType.transfer) {
bool hasError = false;
@@ -471,14 +470,11 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
onPressed: () async {
Navigator.pop(ctx);
// Always delete the record we were given
await ref
.read(transactionsProvider.notifier)
.delete(widget.initial!.id);
// If this is a Transfer, also delete the counterpart
if (widget.initial!.category == 'Transfer') {
// Use the pre-populated IDs from initState if available
final counterpartId =
widget.initial!.type == TransactionType.expense
? _transferIncomeRecordId
@@ -489,7 +485,6 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
.read(transactionsProvider.notifier)
.delete(counterpartId);
} else {
// Fallback: search manually
final allTxs =
ref.read(transactionsProvider).valueOrNull ??
[];
@@ -893,10 +888,9 @@ class _ToAccountDropdownOverlay extends ConsumerWidget {
.selectedAccountId;
final toAccountId = ref.read(addTransactionProvider(initial)).toAccountId;
// Calculate position from trigger key
double top = 340;
double left = 20;
double triggerWidth = 200; // fallback width
double triggerWidth = 200;
if (triggerKey?.currentContext != null) {
final triggerBox =
@@ -134,10 +134,9 @@ class AccountDropdownOverlay extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final accountsAsync = ref.watch(accountsProvider);
// Calculate position from trigger key
double top = 76;
double left = 20;
double triggerWidth = 200; // fallback width
double triggerWidth = 200;
if (triggerKey?.currentContext != null) {
final triggerBox =
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import '../../../core/constants.dart';
import '../../../shared/models/transaction.dart';
class SubmitButton extends StatelessWidget {
+3 -31
View File
@@ -12,15 +12,6 @@ import '../../shared/models/account.dart';
import '../../shared/services/storage_service.dart';
import '../settings/provider.dart';
// BUG FOUND: lib/features/dashboard/provider.dart
// Description: CardColorsNotifier calls an async `_load()` in the constructor without awaiting it.
// If the user triggers `save()` before `_load()` completes, the late `_load()` can
// overwrite the newly saved colors/gradient types.
// Reproduction: Open the app (cold start), open the card color editor immediately, press Apply
// before the initial load finishes.
// Suggested fix: Track a generation/token for in-flight loads and ignore stale load results
// after any state mutation (save/reset/theme-change).
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
throw UnimplementedError('Override in main');
});
@@ -113,15 +104,13 @@ class TransactionsNotifier
}
}
// Returns a map: transactionId -> paired Transaction (its counterpart)
final transferPairsProvider = Provider<Map<String, Transaction>>((ref) {
final txs = ref.watch(transactionsProvider).valueOrNull ?? [];
final transfers = txs.where((t) => t.category == 'Transfer').toList();
final Map<String, Transaction> pairs = {};
for (final tx in transfers) {
if (pairs.containsKey(tx.id)) continue; // already paired
// Find counterpart: opposite type, same amount, same date (minute precision), same note
if (pairs.containsKey(tx.id)) continue;
final counterpart = transfers.firstWhereOrNull(
(other) =>
other.id != tx.id &&
@@ -156,18 +145,15 @@ final timeFilterProvider = StateProvider<TimeFilter>(
(ref) => TimeFilter.lastMonth,
);
// Base filtered transactions by active account
final accountFilteredTransactionsProvider = Provider<List<Transaction>>((ref) {
final txsAsync = ref.watch(transactionsProvider);
final txs = txsAsync.valueOrNull ?? [];
final activeAccount = ref.watch(activeAccountProvider);
// If activeAccount is null (Total Balance page), return all transactions
if (activeAccount == null) {
return txs;
}
// Filter by account ID
return txs.where((t) => t.accountId == activeAccount.id).toList();
});
@@ -214,18 +200,15 @@ final totalBalanceProvider = Provider<double>((ref) {
});
final totalIncomeProvider = Provider<double>((ref) {
// Watch the filtered transactions directly
final txs = ref.watch(accountFilteredTransactionsProvider);
final filtered = txs.where(
(t) => t.type == TransactionType.income && t.category != 'Transfer',
);
// Watch the dependencies that change on swipe!
final index = ref.watch(activeAccountIndexProvider);
final accountsAsync = ref.watch(accountsProvider);
final globalCurrency = ref.watch(currencyProvider).code;
// Resolve target currency synchronously based on the current swipe index
String targetCurrency = globalCurrency;
if (index > 0) {
final accounts = accountsAsync.valueOrNull ?? [];
@@ -341,12 +324,9 @@ final filteredTransactionsProvider = Provider<List<Transaction>>((ref) {
filtered.sort((a, b) => b.date.compareTo(a.date));
// Deduplicate transfers for Total Balance view
if (activeAccount == null) {
filtered = filtered.where((t) {
if (t.category != 'Transfer') return true;
// On Total Balance: show only expense side of complete pairs
// If income side has a known pair, hide it
if (t.type == TransactionType.income && transferPairs.containsKey(t.id)) {
return false;
}
@@ -361,7 +341,6 @@ final recentTransactionsProvider = Provider<List<Transaction>>((ref) {
return ref.watch(filteredTransactionsProvider).take(20).toList();
});
// Watches the list of all accounts
final accountsProvider = StreamProvider<List<Account>>((ref) async* {
final repository = ref.watch(accountRepositoryProvider);
while (true) {
@@ -370,15 +349,13 @@ final accountsProvider = StreamProvider<List<Account>>((ref) async* {
}
});
// Ephemeral UI state — active carousel index, starts at 0, not persisted
final activeAccountIndexProvider = StateProvider<int>((ref) => 0);
// Returns the currently active Account based on carousel index
final activeAccountProvider = Provider<Account?>((ref) {
final index = ref.watch(activeAccountIndexProvider);
final accountsAsync = ref.watch(accountsProvider);
if (index == 0) return null; // 0 means "Total Balance"
if (index == 0) return null;
return accountsAsync.when(
data: (accounts) {
@@ -416,7 +393,6 @@ final cardColorsProvider =
return notifier;
});
// Account-specific color provider
final accountCardColorsProvider =
StateNotifierProvider.family<CardColorsNotifier, CardColors, int>((
ref,
@@ -457,7 +433,7 @@ class CardColorsNotifier extends StateNotifier<CardColors> {
final (c1, c2, lightG, darkG) = await CardColorService.load(
accountId: accountId,
);
if (currentGeneration != _loadGeneration) return; // stale
if (currentGeneration != _loadGeneration) return;
state = CardColors(c1, c2, lightG, darkG);
}
@@ -467,7 +443,6 @@ class CardColorsNotifier extends StateNotifier<CardColors> {
GradientType lightGradient,
GradientType darkGradient,
) async {
// Invalidate any in-flight load so it can't overwrite this save.
_loadGeneration++;
state = CardColors(primary, secondary, lightGradient, darkGradient);
await CardColorService.save(
@@ -506,20 +481,17 @@ class CardColorsNotifier extends StateNotifier<CardColors> {
final previousBrightness = _resolve(previous);
final nextBrightness = _resolve(next);
// No change in actual brightness
if (previousBrightness == nextBrightness) return;
final oldDefaults = _defaultsFor(previousBrightness);
final newDefaults = _defaultsFor(nextBrightness);
// Check if current colors match old theme defaults
final isUsingOldDefaults =
state.primary == oldDefaults.primary &&
state.secondary == oldDefaults.secondary &&
state.gradientTypeForBrightness(previousBrightness) ==
oldDefaults.gradient;
// Only auto-switch if using default colors
if (isUsingOldDefaults) {
_loadGeneration++;
state = CardColors(
+17 -14
View File
@@ -44,7 +44,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
HSVColor savedSecondaryHSV = HSVColor.fromColor(
CardColorService.defaultSecondary,
);
// Per-theme gradient types (light/dark), persisted separately.
GradientType tempLightGradientType = CardColorService.defaultGradientLight;
GradientType tempDarkGradientType = CardColorService.defaultGradientDark;
GradientType savedLightGradientType = CardColorService.defaultGradientLight;
@@ -52,7 +51,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
OverlayEntry? overlayEntry;
// Account editing state
Account? editingAccount;
String tempAccountName = '';
String tempAccountCurrency = 'USD';
@@ -192,8 +190,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
accountId: newId,
);
} else if (editingAccount != null) {
// Existing edit logic
// Save colors
await ref
.read(accountCardColorsProvider(editingAccount!.id).notifier)
.save(
@@ -203,7 +199,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
tempDarkGradientType,
);
// Update account name and currency
final updatedAccount = Account(
id: editingAccount!.id,
name: tempAccountName.trim(),
@@ -216,7 +211,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
await ref.read(accountRepositoryProvider).update(updatedAccount);
}
} else {
// Restore original values on cancel
setState(() {
tempPrimary = savedPrimary;
tempSecondary = savedSecondary;
@@ -370,8 +364,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
previewSecondary: editingCard ? tempSecondary : null,
previewGradientType: editingCard
? (Theme.of(context).brightness == Brightness.dark
? tempDarkGradientType
: tempLightGradientType)
? tempDarkGradientType
: tempLightGradientType)
: null,
),
const SizedBox(height: 16),
@@ -457,9 +451,9 @@ class _AccountsInfoBlock extends ConsumerWidget {
final s = ref.watch(stringsProvider);
final onSurface = Theme.of(context).colorScheme.onSurface;
return Padding(
padding: const EdgeInsets.only(bottom: 60),
padding: const EdgeInsets.only(bottom: 60),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 260),
constraints: const BoxConstraints(maxWidth: 260),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
@@ -484,17 +478,26 @@ class _AccountsInfoBlock extends ConsumerWidget {
],
),
const SizedBox(height: 12),
_InfoRow(icon: Icons.swap_horiz_rounded, text: s.accountsInfoBalance),
_InfoRow(
icon: Icons.swap_horiz_rounded,
text: s.accountsInfoBalance,
),
const SizedBox(height: 8),
_InfoRow(icon: Icons.touch_app_rounded, text: s.accountsInfoCustomize),
_InfoRow(
icon: Icons.touch_app_rounded,
text: s.accountsInfoCustomize,
),
const SizedBox(height: 8),
_InfoRow(icon: Icons.lock_outline_rounded, text: s.accountsInfoLimit),
_InfoRow(
icon: Icons.lock_outline_rounded,
text: s.accountsInfoLimit,
),
],
),
),
);
}
}
}
class _InfoRow extends StatelessWidget {
final IconData icon;
@@ -90,7 +90,6 @@ class _AccountEditorOverlayState extends State<AccountEditorOverlay> {
final editorPanelTop = cardTop + cardHeight + 20;
final colorPanelTop = editorPanelTop + editorPanelHeight + 12;
const colorPanelHeight = 410.0;
// Preview card in overlay should match BalanceCardCarousel sizing.
return Consumer(
builder: (context, ref, _) {
@@ -203,25 +202,24 @@ class _AccountEditorOverlayState extends State<AccountEditorOverlay> {
child: SizedBox(
height: cardHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: BalanceCard(
balance: previewBalance,
currencyInfo: CurrencyInfo(
currencyMap[dash.tempAccountCurrency]?.symbol ??
'\$',
dash.tempAccountCurrency,
),
onLongPress: null,
accountName: dash.tempAccountName,
previewPrimary: dash.tempPrimary,
previewSecondary: dash.tempSecondary,
previewGradientType:
Theme.of(widget.context).brightness ==
Brightness.dark
? dash.tempDarkGradientType
: dash.tempLightGradientType,
padding: const EdgeInsets.symmetric(horizontal: 8),
child: BalanceCard(
balance: previewBalance,
currencyInfo: CurrencyInfo(
currencyMap[dash.tempAccountCurrency]?.symbol ?? '\$',
dash.tempAccountCurrency,
),
onLongPress: null,
accountName: dash.tempAccountName,
previewPrimary: dash.tempPrimary,
previewSecondary: dash.tempSecondary,
previewGradientType:
Theme.of(widget.context).brightness ==
Brightness.dark
? dash.tempDarkGradientType
: dash.tempLightGradientType,
),
),
),
),
),
@@ -299,75 +297,75 @@ class _AccountEditorOverlayState extends State<AccountEditorOverlay> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: kDisplayCurrencies.map((entry) {
final isSelected = entry.$1 == _selectedCurrency;
return InkWell(
onTap: () {
setState(() {
_selectedCurrency = entry.$1;
dash.setState(() {
dash.tempAccountCurrency = entry.$1;
});
dash.overlayEntry?.markNeedsBuild();
_showCurrencyDropdown = false;
});
},
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
entry.$1 == 'BYN'
? BynSign(
fontSize: 14,
color: isSelected
? const Color(0xFF7C6DED)
: Theme.of(
widget.context,
).colorScheme.onSurface,
)
: Text(
entry.$2,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isSelected
? const Color(0xFF7C6DED)
: null,
),
),
const SizedBox(width: 4),
Flexible(
child: Text(
entry.$1,
final isSelected = entry.$1 == _selectedCurrency;
return InkWell(
onTap: () {
setState(() {
_selectedCurrency = entry.$1;
dash.setState(() {
dash.tempAccountCurrency = entry.$1;
});
dash.overlayEntry?.markNeedsBuild();
_showCurrencyDropdown = false;
});
},
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
entry.$1 == 'BYN'
? BynSign(
fontSize: 14,
color: isSelected
? const Color(0xFF7C6DED)
: Theme.of(
widget.context,
).colorScheme.onSurface,
)
: Text(
entry.$2,
style: TextStyle(
fontSize: 11,
fontSize: 14,
fontWeight: FontWeight.w600,
color: isSelected
? const Color(0xFF7C6DED)
: Theme.of(widget.context)
.colorScheme
.onSurface
.withOpacity(0.6),
: null,
),
overflow: TextOverflow.ellipsis,
),
const SizedBox(width: 4),
Flexible(
child: Text(
entry.$1,
style: TextStyle(
fontSize: 11,
color: isSelected
? const Color(0xFF7C6DED)
: Theme.of(widget.context)
.colorScheme
.onSurface
.withOpacity(0.6),
),
if (isSelected) ...[
const SizedBox(width: 4),
const Icon(
Icons.check_rounded,
size: 14,
color: Color(0xFF7C6DED),
),
],
],
overflow: TextOverflow.ellipsis,
),
),
),
);
}).toList(),
if (isSelected) ...[
const SizedBox(width: 4),
const Icon(
Icons.check_rounded,
size: 14,
color: Color(0xFF7C6DED),
),
],
],
),
),
);
}).toList(),
),
),
),
@@ -41,7 +41,6 @@ class _BalanceCardCarouselState extends ConsumerState<BalanceCardCarousel> {
@override
void initState() {
super.initState();
// 0.92 позволяет видеть края предыдущей/следующей карточки
final savedIndex = ref.read(activeAccountIndexProvider);
_pageController = PageController(
viewportFraction: 0.92,
@@ -68,14 +67,12 @@ class _BalanceCardCarouselState extends ConsumerState<BalanceCardCarousel> {
children: [
SizedBox(
height: 230,
// OverflowBox позволяет PageView игнорировать паддинги родителя (DashboardScreen)
// и растянуться на всю ширину экрана
child: OverflowBox(
maxWidth: MediaQuery.of(context).size.width,
child: PageView.builder(
controller: _pageController,
clipBehavior:
Clip.none, // Не обрезает карточку при 3D-наклоне
Clip.none,
itemCount: totalPages,
onPageChanged: (index) {
ref.read(activeAccountIndexProvider.notifier).state = index;
@@ -105,7 +102,6 @@ class _BalanceCardCarouselState extends ConsumerState<BalanceCardCarousel> {
accountCardColorsProvider(account.id),
);
// Calculate this specific account's balance
final txs =
ref.watch(transactionsProvider).valueOrNull ?? [];
final accountTxs = txs
@@ -119,7 +115,7 @@ class _BalanceCardCarouselState extends ConsumerState<BalanceCardCarousel> {
t.amount,
t.currencyCode,
account
.currency, // target is the account's own currency
.currency,
);
return t.type == TransactionType.income
? sum + converted
@@ -128,7 +124,7 @@ class _BalanceCardCarouselState extends ConsumerState<BalanceCardCarousel> {
cardWidget = BalanceCard(
balance:
accountBalance, // Use the dynamically calculated balance!
accountBalance,
currencyInfo: CurrencyInfo(
currencyMap[account.currency]?.symbol ?? '\$',
account.currency,
@@ -144,7 +140,6 @@ class _BalanceCardCarouselState extends ConsumerState<BalanceCardCarousel> {
);
}
// Отступ между карточками во время свайпа
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: cardWidget,
@@ -198,12 +193,12 @@ class AddAccountCard extends StatelessWidget {
margin: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 10,
), // Reduced margins for larger size
),
child: CustomPaint(
painter: _DashedBorderPainter(),
child: Container(
width: double.infinity,
height: 205, // Increased height
height: 205,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface.withOpacity(0.4),
borderRadius: BorderRadius.circular(20),
@@ -213,7 +208,7 @@ class AddAccountCard extends StatelessWidget {
children: [
Icon(
Icons.add_rounded,
size: 36, // Slightly bigger icon
size: 36,
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.5),
@@ -94,7 +94,6 @@ class _FullScreenBlurOverlayState extends State<FullScreenBlurOverlay> {
child: _buildPanel(panelHeight),
),
),
// Close Button - Top Right
Positioned(
top: cardTop - 20,
right: 20,
@@ -367,7 +366,7 @@ class _FullScreenBlurOverlayState extends State<FullScreenBlurOverlay> {
),
const SizedBox(height: 8),
SizedBox(
height: 36, //
height: 36,
child: ColorPickerSlider(
TrackType.hue,
currentHSV,
@@ -42,7 +42,6 @@ class TransactionTile extends ConsumerWidget {
? 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;
@@ -57,19 +56,16 @@ class TransactionTile extends ConsumerWidget {
: 0.0;
final displaySymbol = currencyMap[displayCurrency]?.symbol ?? '';
// Look up the account name by matching transaction.accountId
final accounts = ref.watch(accountsProvider).valueOrNull ?? [];
final txAccount = accounts.firstWhereOrNull(
(a) => a.id == transaction.accountId,
);
// Build account label with 10-character limit
String accountLabel = txAccount?.name ?? '';
if (accountLabel.length > 10) {
accountLabel = '${accountLabel.substring(0, 10)}...';
}
// Transfer pairing logic
final pairs = ref.watch(transferPairsProvider);
final counterpart = pairs[transaction.id];
@@ -381,11 +377,9 @@ class TransactionTile extends ConsumerWidget {
bool isIncome,
Account? activeAccount,
) {
// Total Balance view with Transfer expense: no prefix
if (isTransfer && activeAccount == null && !isIncome) {
return '';
}
// All other cases: show + or
return isIncome ? '+ ' : '\u2212 ';
}
@@ -396,15 +390,12 @@ class TransactionTile extends ConsumerWidget {
Account? activeAccount,
Color defaultColor,
) {
// Total Balance view with Transfer expense: neutral color
if (isTransfer && activeAccount == null && !isIncome) {
return Theme.of(context).colorScheme.onSurface.withOpacity(0.8);
}
// Transfer in account view or Total Balance income: use income/expense colors
if (isTransfer) {
return isIncome ? AppColors.income : AppColors.expense;
}
// Non-transfer: use default color
return defaultColor;
}
}