From 3961327bdb87deefef70b32eaf783863ed4edfd0 Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 21 Jun 2026 14:45:30 +0300 Subject: [PATCH] step --- android/app/build.gradle.kts | 4 + android/settings.gradle.kts | 1 + lib/core/l10n/app_strings.dart | 4 +- lib/core/utils/card_layout.dart | 64 +++ lib/features/categories/provider.dart | 126 +++++- lib/features/categories/screen.dart | 386 +++++++++++++----- .../widgets/account_scope_chips.dart | 107 +++++ .../categories/widgets/stats_hero_card.dart | 138 +++++++ lib/features/dashboard/provider.dart | 7 +- .../account_editor_overlay.dart | 15 +- .../account_editor_overlay/color_panel.dart | 51 ++- .../dashboard/widgets/balance_card.dart | 47 +-- .../widgets/balance_card_carousel.dart | 11 +- .../widgets/color_editor_overlay.dart | 62 +-- lib/main.dart | 182 +-------- lib/shared/utils/card_gradient.dart | 43 ++ 16 files changed, 852 insertions(+), 396 deletions(-) create mode 100644 lib/core/utils/card_layout.dart create mode 100644 lib/features/categories/widgets/account_scope_chips.dart create mode 100644 lib/features/categories/widgets/stats_hero_card.dart create mode 100644 lib/shared/utils/card_gradient.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b060afe..c0dbd2b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -22,6 +22,10 @@ android { targetCompatibility = JavaVersion.VERSION_17 } + kotlinOptions { + jvmTarget = "17" + } + defaultConfig { applicationId = "com.kolo.casha" minSdk = flutter.minSdkVersion diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index e70423e..ca7fe06 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -20,6 +20,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false } include(":app") diff --git a/lib/core/l10n/app_strings.dart b/lib/core/l10n/app_strings.dart index 79f95a7..12a6be5 100644 --- a/lib/core/l10n/app_strings.dart +++ b/lib/core/l10n/app_strings.dart @@ -115,9 +115,11 @@ class AppStrings { String get dangerZone => _ru ? 'Опасная зона' : 'Danger Zone'; String get navDashboard => _ru ? 'Главная' : 'Dashboard'; - String get navCategories => _ru ? 'Категории' : 'Categories'; + String get navCategories => _ru ? 'Статистика' : 'Statistics'; String get navSettings => _ru ? 'Настройки' : 'Settings'; + String get statistics => _ru ? 'Статистика' : 'Statistics'; + String get allAccounts => _ru ? 'Все счета' : 'All accounts'; String get categories => _ru ? 'Категории' : 'Categories'; String get rankedByAmount => _ru ? 'По сумме' : 'Ranked by Amount'; String get addCategory => _ru ? 'Добавить категорию' : 'Add Category'; diff --git a/lib/core/utils/card_layout.dart b/lib/core/utils/card_layout.dart new file mode 100644 index 0000000..239ddc2 --- /dev/null +++ b/lib/core/utils/card_layout.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +const kBalanceCardHeight = 200.0; +const kBalanceCardCarouselHeight = 210.0; +const kAddAccountCardHeight = 200.0; + +class CardOverlayLayout { + final bool compact; + final double cardHeight; + final double cardTop; + final double cardPreviewGap; + final double editorPanelHeight; + final double sectionGap; + final double panelPaddingTop; + final double panelPaddingBottom; + final double reservedBelowControls; + final double hueSliderHeight; + final double hexRowHeight; + final double tabSpacing; + final double controlSpacing; + final double buttonVerticalPadding; + + const CardOverlayLayout._({ + required this.compact, + required this.cardHeight, + required this.cardTop, + required this.cardPreviewGap, + required this.editorPanelHeight, + required this.sectionGap, + required this.panelPaddingTop, + required this.panelPaddingBottom, + required this.reservedBelowControls, + required this.hueSliderHeight, + required this.hexRowHeight, + required this.tabSpacing, + required this.controlSpacing, + required this.buttonVerticalPadding, + }); + + factory CardOverlayLayout.fromMediaQuery(MediaQueryData mq) { + final compact = mq.size.height < 780; + return CardOverlayLayout._( + compact: compact, + cardHeight: kBalanceCardHeight, + cardTop: mq.padding.top + kToolbarHeight + (compact ? 8 : 16), + cardPreviewGap: compact ? 12 : 32, + editorPanelHeight: compact ? 88 : 96, + sectionGap: compact ? 8 : 12, + panelPaddingTop: compact ? 10 : 14, + panelPaddingBottom: compact ? 14 : 22, + reservedBelowControls: compact ? 62 : 78, + hueSliderHeight: compact ? 28 : 36, + hexRowHeight: compact ? 22 : 26, + tabSpacing: compact ? 6 : 10, + controlSpacing: compact ? 5 : 8, + buttonVerticalPadding: compact ? 8 : 10, + ); + } + + double colorPanelHeight(MediaQueryData mq, double panelTop) { + final available = mq.size.height - panelTop - mq.padding.bottom - 8; + return available.clamp(compact ? 250 : 320, 410); + } +} diff --git a/lib/features/categories/provider.dart b/lib/features/categories/provider.dart index 7afdad4..f8d6c71 100644 --- a/lib/features/categories/provider.dart +++ b/lib/features/categories/provider.dart @@ -1,44 +1,132 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../shared/models/transaction.dart'; import '../dashboard/provider.dart'; +import '../settings/provider.dart'; + +String _statsTargetCurrency(Ref ref) { + final index = ref.watch(activeAccountIndexProvider); + final accountsAsync = ref.watch(accountsProvider); + final globalCurrency = ref.watch(currencyProvider).code; + + if (index > 0) { + final accounts = accountsAsync.value ?? []; + if (index <= accounts.length) { + return accounts[index - 1].currency; + } + } + + return globalCurrency; +} + +CurrencyInfo _statsCurrencyInfo(Ref ref) { + final code = _statsTargetCurrency(ref); + return CurrencyInfo(currencyMap[code]?.symbol ?? '\$', code); +} + +List _statsScopedTransactions(Ref ref) { + final txs = ref.watch(accountFilteredTransactionsProvider); + final timeFilter = ref.watch(timeFilterProvider); + var filtered = txs.where((t) => t.category != 'Transfer'); + + if (timeFilter == TimeFilter.lastMonth) { + final now = DateTime.now(); + filtered = filtered.where( + (t) => t.date.year == now.year && t.date.month == now.month, + ); + } + + return filtered.toList(); +} + +double _convertAmount(Ref ref, Transaction t) { + final exchange = ref.watch(exchangeRateServiceProvider); + return exchange.convert( + t.amount, + t.currencyCode, + _statsTargetCurrency(ref), + ); +} + +final statsCurrencyProvider = Provider((ref) { + return _statsCurrencyInfo(ref); +}); + +final statsIncomeTotalProvider = Provider((ref) { + return _statsScopedTransactions(ref) + .where((t) => t.type == TransactionType.income) + .fold(0.0, (sum, t) => sum + _convertAmount(ref, t)); +}); + +final statsExpenseTotalProvider = Provider((ref) { + return _statsScopedTransactions(ref) + .where((t) => t.type == TransactionType.expense) + .fold(0.0, (sum, t) => sum + _convertAmount(ref, t)); +}); final categoryExpenseProvider = Provider>((ref) { - final txsAsync = ref.watch(transactionsProvider); - final txs = txsAsync.value ?? []; - final filtered = txs.where((t) => t.type == TransactionType.expense); - final map = {}; - for (final t in filtered) { - map[t.category] = (map[t.category] ?? 0) + t.amount; + for (final t in _statsScopedTransactions(ref)) { + if (t.type != TransactionType.expense) continue; + map[t.category] = (map[t.category] ?? 0) + _convertAmount(ref, t); } return map; }); final categoryIncomeProvider = Provider>((ref) { - final txsAsync = ref.watch(transactionsProvider); - final txs = txsAsync.value ?? []; - final filtered = txs.where((t) => t.type == TransactionType.income); - final map = {}; - for (final t in filtered) { - map[t.category] = (map[t.category] ?? 0) + t.amount; + for (final t in _statsScopedTransactions(ref)) { + if (t.type != TransactionType.income) continue; + map[t.category] = (map[t.category] ?? 0) + _convertAmount(ref, t); } return map; }); final monthlyBreakdownProvider = Provider>((ref) { - final txsAsync = ref.watch(transactionsProvider); - final txs = txsAsync.value ?? []; - final filtered = txs.where((t) => t.type == TransactionType.expense); - + final txs = ref.watch(accountFilteredTransactionsProvider); + final exchange = ref.watch(exchangeRateServiceProvider); + final target = _statsTargetCurrency(ref); final now = DateTime.now(); final months = []; for (var i = 5; i >= 0; i--) { final month = DateTime(now.year, now.month - i, 1); - final total = filtered - .where((t) => t.date.year == month.year && t.date.month == month.month) - .fold(0.0, (sum, t) => sum + t.amount); + final total = txs + .where( + (t) => + t.type == TransactionType.expense && + t.category != 'Transfer' && + t.date.year == month.year && + t.date.month == month.month, + ) + .fold(0.0, (sum, t) { + return sum + exchange.convert(t.amount, t.currencyCode, target); + }); + months.add(MonthlyData(month: month, amount: total)); + } + + return months; +}); + +final monthlyIncomeBreakdownProvider = Provider>((ref) { + final txs = ref.watch(accountFilteredTransactionsProvider); + final exchange = ref.watch(exchangeRateServiceProvider); + final target = _statsTargetCurrency(ref); + final now = DateTime.now(); + final months = []; + + for (var i = 5; i >= 0; i--) { + final month = DateTime(now.year, now.month - i, 1); + final total = txs + .where( + (t) => + t.type == TransactionType.income && + t.category != 'Transfer' && + t.date.year == month.year && + t.date.month == month.month, + ) + .fold(0.0, (sum, t) { + return sum + exchange.convert(t.amount, t.currencyCode, target); + }); months.add(MonthlyData(month: month, amount: total)); } diff --git a/lib/features/categories/screen.dart b/lib/features/categories/screen.dart index a73cb9c..a5aaaad 100644 --- a/lib/features/categories/screen.dart +++ b/lib/features/categories/screen.dart @@ -3,12 +3,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import '../../core/constants.dart'; +import '../../core/l10n/app_strings.dart'; import '../../core/l10n/locale_provider.dart'; -import '../../shared/utils/currency_utils.dart'; +import '../../core/services/haptic_service.dart'; import '../../shared/providers/amount_format_provider.dart'; +import '../../shared/utils/currency_utils.dart'; import '../../shared/widgets/byn_sign.dart'; +import '../dashboard/provider.dart'; +import '../dashboard/widgets/summary_row.dart'; import '../settings/provider.dart'; import 'provider.dart'; +import 'widgets/account_scope_chips.dart'; +import 'widgets/stats_hero_card.dart'; enum ChartType { pie, bar } @@ -24,15 +30,27 @@ class _CategoriesScreenState extends ConsumerState { ChartType _chartType = ChartType.pie; bool _showIncome = false; + String _scopeLabel(AppStrings s) { + final activeAccount = ref.watch(activeAccountProvider); + if (activeAccount == null) return s.allAccounts; + return activeAccount.name; + } + @override Widget build(BuildContext context) { final s = ref.watch(stringsProvider); final data = _showIncome ? ref.watch(categoryIncomeProvider) : ref.watch(categoryExpenseProvider); - final monthlyData = ref.watch(monthlyBreakdownProvider); + final monthlyData = _showIncome + ? ref.watch(monthlyIncomeBreakdownProvider) + : ref.watch(monthlyBreakdownProvider); final total = data.values.fold(0.0, (a, b) => a + b); - final currencyInfo = ref.watch(currencyProvider); + final currencyInfo = ref.watch(statsCurrencyProvider); + final income = ref.watch(statsIncomeTotalProvider); + final expense = ref.watch(statsExpenseTotalProvider); + final timeFilter = ref.watch(timeFilterProvider); + final scopeLabel = _scopeLabel(s); final sortedEntries = data.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); @@ -41,7 +59,7 @@ class _CategoriesScreenState extends ConsumerState { backgroundColor: Theme.of(context).scaffoldBackgroundColor, appBar: AppBar( title: Text( - s.categories, + s.statistics, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, color: Theme.of(context).colorScheme.onSurface, @@ -57,87 +75,59 @@ class _CategoriesScreenState extends ConsumerState { ), body: SafeArea( child: Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), + padding: const EdgeInsets.fromLTRB(20, 8, 20, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + const AccountScopeChips(), + const SizedBox(height: 12), + StatsHeroCard( + amount: total, + label: _showIncome ? s.income : s.expenses, + accentColor: _showIncome ? AppColors.income : AppColors.expense, + scopeLabel: scopeLabel, + ), + const SizedBox(height: 12), + SummaryRow( + income: income, + expense: expense, + currencyInfo: currencyInfo, + strings: s, + ), + const SizedBox(height: 12), + _StatsTimeFilter( + strings: s, + timeFilter: timeFilter, + onAllTime: () => + ref.read(timeFilterProvider.notifier).set(TimeFilter.allTime), + onMonth: () => ref + .read(timeFilterProvider.notifier) + .set(TimeFilter.lastMonth), + ), + const SizedBox(height: 12), Row( children: [ Expanded( - child: GestureDetector( + child: _TypeToggle( + label: s.expenses, + isSelected: !_showIncome, + color: AppColors.expense, onTap: () => setState(() { _showIncome = false; _touchedIndex = -1; }), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: !_showIncome - ? AppColors.accent - : Colors.transparent, - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: !_showIncome - ? AppColors.accent - : Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.2), - width: 1.5, - ), - ), - child: Text( - s.expenses, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: !_showIncome - ? Colors.white - : Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.6), - ), - ), - ), ), ), const SizedBox(width: 12), Expanded( - child: GestureDetector( + child: _TypeToggle( + label: s.income, + isSelected: _showIncome, + color: AppColors.income, onTap: () => setState(() { _showIncome = true; _touchedIndex = -1; }), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: _showIncome - ? AppColors.accent - : Colors.transparent, - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: _showIncome - ? AppColors.accent - : Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.2), - width: 1.5, - ), - ), - child: Text( - s.income, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: _showIncome - ? Colors.white - : Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.6), - ), - ), - ), ), ), ], @@ -155,12 +145,14 @@ class _CategoriesScreenState extends ConsumerState { total: total, touchedIndex: _touchedIndex, onTouch: (i) => setState(() => _touchedIndex = i), - currency: currencyInfo.symbol, + currencyInfo: currencyInfo, + isIncome: _showIncome, ) else _BarChartCard( monthlyData: monthlyData, - currency: currencyInfo.symbol, + currencyInfo: currencyInfo, + isIncome: _showIncome, ), const SizedBox(height: 20), Text( @@ -183,7 +175,7 @@ class _CategoriesScreenState extends ConsumerState { category: cat, amount: amount, total: total, - currency: currencyInfo.symbol, + currencyInfo: currencyInfo, isIncome: _showIncome, ), ); @@ -200,6 +192,140 @@ class _CategoriesScreenState extends ConsumerState { } } +class _StatsTimeFilter extends StatelessWidget { + final AppStrings strings; + final TimeFilter timeFilter; + final VoidCallback onAllTime; + final VoidCallback onMonth; + + const _StatsTimeFilter({ + required this.strings, + required this.timeFilter, + required this.onAllTime, + required this.onMonth, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Row( + children: [ + _TimeChip( + label: strings.filterAllTime, + isSelected: timeFilter == TimeFilter.allTime, + isDark: isDark, + onTap: () { + HapticService.selection(); + onAllTime(); + }, + ), + const SizedBox(width: 6), + _TimeChip( + label: strings.filterMonth, + isSelected: timeFilter == TimeFilter.lastMonth, + isDark: isDark, + onTap: () { + HapticService.selection(); + onMonth(); + }, + ), + ], + ); + } +} + +class _TimeChip extends StatelessWidget { + final String label; + final bool isSelected; + final bool isDark; + final VoidCallback onTap; + + const _TimeChip({ + required this.label, + required this.isSelected, + required this.isDark, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isSelected + ? AppColors.accent.withOpacity(0.2) + : Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(20), + border: isSelected + ? Border.all(color: AppColors.accent, width: 1.5) + : isDark + ? null + : Border.all(color: const Color(0xFFDDDDEE), width: 1), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected + ? AppColors.accent + : Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + ), + ), + ), + ); + } +} + +class _TypeToggle extends StatelessWidget { + final String label; + final bool isSelected; + final Color color; + final VoidCallback onTap; + + const _TypeToggle({ + required this.label, + required this.isSelected, + required this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: isSelected ? color : Colors.transparent, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isSelected + ? color + : Theme.of(context).colorScheme.onSurface.withOpacity(0.2), + width: 1.5, + ), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isSelected + ? Colors.white + : Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + ), + ), + ), + ); + } +} + class _ChartToggle extends StatelessWidget { final ChartType selected; final ValueChanged onChanged; @@ -207,10 +333,15 @@ class _ChartToggle extends StatelessWidget { @override Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(10), + border: isDark + ? null + : Border.all(color: const Color(0xFFDDDDEE), width: 1), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -270,14 +401,16 @@ class _PieChartCard extends ConsumerWidget { final double total; final int touchedIndex; final ValueChanged onTouch; - final String currency; + final CurrencyInfo currencyInfo; + final bool isIncome; const _PieChartCard({ required this.data, required this.total, required this.touchedIndex, required this.onTouch, - required this.currency, + required this.currencyInfo, + required this.isIncome, }); @override @@ -285,12 +418,20 @@ class _PieChartCard extends ConsumerWidget { final s = ref.watch(stringsProvider); final fmt = ref.watch(amountFormatProvider); final entries = data.entries.toList(); + final isDark = Theme.of(context).brightness == Brightness.dark; + final accent = isIncome ? AppColors.income : AppColors.expense; return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isDark + ? Colors.white.withOpacity(0.08) + : const Color(0xFFDDDDEE), + width: 1, + ), ), child: Column( children: [ @@ -327,10 +468,10 @@ class _PieChartCard extends ConsumerWidget { ? '${(val / total * 100).toStringAsFixed(1)}%' : '', radius: isTouched ? 60 : 50, - titleStyle: TextStyle( + titleStyle: const TextStyle( fontSize: 13, fontWeight: FontWeight.w700, - color: Theme.of(context).colorScheme.onPrimary, + color: Colors.white, ), ); }), @@ -348,9 +489,9 @@ class _PieChartCard extends ConsumerWidget { ), ), Text( - formatAmount(currency, total, fmt), + formatAmount(currencyInfo.symbol, total, fmt), style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface, + color: accent, fontWeight: FontWeight.w700, ), ), @@ -367,16 +508,24 @@ class _PieChartCard extends ConsumerWidget { class _BarChartCard extends ConsumerWidget { final List monthlyData; - final String currency; - const _BarChartCard({required this.monthlyData, required this.currency}); + final CurrencyInfo currencyInfo; + final bool isIncome; + + const _BarChartCard({ + required this.monthlyData, + required this.currencyInfo, + required this.isIncome, + }); @override Widget build(BuildContext context, WidgetRef ref) { final s = ref.watch(stringsProvider); final fmt = ref.watch(amountFormatProvider); - final maxY = monthlyData - .map((e) => e.amount) - .reduce((a, b) => a > b ? a : b); + final isDark = Theme.of(context).brightness == Brightness.dark; + final barColor = isIncome ? AppColors.income : AppColors.expense; + final maxY = monthlyData.isEmpty + ? 100.0 + : monthlyData.map((e) => e.amount).reduce((a, b) => a > b ? a : b); final adjustedMaxY = maxY * 1.2; return Container( @@ -384,6 +533,12 @@ class _BarChartCard extends ConsumerWidget { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isDark + ? Colors.white.withOpacity(0.08) + : const Color(0xFFDDDDEE), + width: 1, + ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -406,9 +561,9 @@ class _BarChartCard extends ConsumerWidget { touchTooltipData: BarTouchTooltipData( getTooltipItem: (group, groupIndex, rod, rodIndex) { return BarTooltipItem( - formatAmount(currency, rod.toY, fmt), - TextStyle( - color: Theme.of(context).colorScheme.onPrimary, + formatAmount(currencyInfo.symbol, rod.toY, fmt), + const TextStyle( + color: Colors.white, fontWeight: FontWeight.w600, fontSize: 12, ), @@ -469,7 +624,7 @@ class _BarChartCard extends ConsumerWidget { barRods: [ BarChartRodData( toY: monthlyData[i].amount, - color: AppColors.accent, + color: barColor, width: 24, borderRadius: const BorderRadius.vertical( top: Radius.circular(6), @@ -492,14 +647,15 @@ class _CategoryRow extends ConsumerWidget { final String category; final double amount; final double total; - final String currency; + final CurrencyInfo currencyInfo; final bool isIncome; + const _CategoryRow({ required this.rank, required this.category, required this.amount, required this.total, - required this.currency, + required this.currencyInfo, required this.isIncome, }); @@ -507,6 +663,7 @@ class _CategoryRow extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final s = ref.watch(stringsProvider); final fmt = ref.watch(amountFormatProvider); + final isDark = Theme.of(context).brightness == Brightness.dark; final color = AppCategories.colors[category] ?? AppColors.accent; final icon = AppCategories.icons[category] ?? Icons.category_rounded; final pct = total > 0 ? amount / total : 0.0; @@ -516,6 +673,12 @@ class _CategoryRow extends ConsumerWidget { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isDark + ? Colors.white.withOpacity(0.08) + : const Color(0xFFDDDDEE), + width: 1, + ), ), child: Column( children: [ @@ -565,13 +728,39 @@ class _CategoryRow extends ConsumerWidget { Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Text( - formatAmount(currency, amount, fmt), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: isIncome ? AppColors.income : AppColors.expense, - fontWeight: FontWeight.w700, - ), - ), + currencyInfo.code == 'BYN' + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + BynSign( + fontSize: 14, + color: isIncome + ? AppColors.income + : AppColors.expense, + ), + const SizedBox(width: 2), + Text( + formatAmount('', amount, fmt), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: isIncome + ? AppColors.income + : AppColors.expense, + fontWeight: FontWeight.w700, + ), + ), + ], + ) + : Text( + formatAmount(currencyInfo.symbol, amount, fmt), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: isIncome + ? AppColors.income + : AppColors.expense, + fontWeight: FontWeight.w700, + ), + ), Text( '${(pct * 100).toStringAsFixed(1)}%', style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -607,6 +796,8 @@ class _EmptyState extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final s = ref.watch(stringsProvider); + final isDark = Theme.of(context).brightness == Brightness.dark; + return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -616,6 +807,12 @@ class _EmptyState extends ConsumerWidget { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, shape: BoxShape.circle, + border: Border.all( + color: isDark + ? Colors.white.withOpacity(0.08) + : const Color(0xFFDDDDEE), + width: 1, + ), ), child: Icon( Icons.pie_chart_outline_rounded, @@ -634,6 +831,7 @@ class _EmptyState extends ConsumerWidget { const SizedBox(height: 6), Text( isIncome ? s.addIncomeToSeeBreakdown : s.addExpensesToSeeBreakdown, + textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), ), diff --git a/lib/features/categories/widgets/account_scope_chips.dart b/lib/features/categories/widgets/account_scope_chips.dart new file mode 100644 index 0000000..7849d8a --- /dev/null +++ b/lib/features/categories/widgets/account_scope_chips.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/constants.dart'; +import '../../../core/l10n/locale_provider.dart'; +import '../../../core/services/haptic_service.dart'; +import '../../dashboard/provider.dart'; + +class AccountScopeChips extends ConsumerWidget { + const AccountScopeChips({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final s = ref.watch(stringsProvider); + final accountsAsync = ref.watch(accountsProvider); + final activeIndex = ref.watch(activeAccountIndexProvider); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return accountsAsync.when( + data: (accounts) { + if (accounts.isEmpty) return const SizedBox.shrink(); + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + child: Row( + children: [ + _ScopeChip( + label: s.allAccounts, + isSelected: activeIndex == 0, + isDark: isDark, + onTap: () { + ref.read(activeAccountIndexProvider.notifier).set(0); + HapticService.selection(); + }, + ), + const SizedBox(width: 6), + ...accounts.asMap().entries.map((entry) { + final index = entry.key + 1; + final account = entry.value; + return Padding( + padding: const EdgeInsets.only(right: 6), + child: _ScopeChip( + label: account.name, + isSelected: activeIndex == index, + isDark: isDark, + onTap: () { + ref.read(activeAccountIndexProvider.notifier).set(index); + HapticService.selection(); + }, + ), + ); + }), + ], + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + ); + } +} + +class _ScopeChip extends StatelessWidget { + final String label; + final bool isSelected; + final bool isDark; + final VoidCallback onTap; + + const _ScopeChip({ + required this.label, + required this.isSelected, + required this.isDark, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isSelected + ? AppColors.accent.withOpacity(0.2) + : Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(20), + border: isSelected + ? Border.all(color: AppColors.accent, width: 1.5) + : isDark + ? null + : Border.all(color: const Color(0xFFDDDDEE), width: 1), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected + ? AppColors.accent + : Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + ), + ), + ), + ); + } +} diff --git a/lib/features/categories/widgets/stats_hero_card.dart b/lib/features/categories/widgets/stats_hero_card.dart new file mode 100644 index 0000000..eccc35f --- /dev/null +++ b/lib/features/categories/widgets/stats_hero_card.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/constants.dart'; +import '../../../core/services/card_color_service.dart'; +import '../../../shared/providers/amount_format_provider.dart'; +import '../../../shared/utils/card_gradient.dart'; +import '../../../shared/utils/currency_utils.dart'; +import '../../../shared/widgets/byn_sign.dart'; +import '../../dashboard/provider.dart'; +import '../../settings/provider.dart'; +import '../provider.dart'; + +class StatsHeroCard extends ConsumerWidget { + final double amount; + final String label; + final Color accentColor; + final String scopeLabel; + + const StatsHeroCard({ + super.key, + required this.amount, + required this.label, + required this.accentColor, + required this.scopeLabel, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fmt = ref.watch(amountFormatProvider); + final currencyInfo = ref.watch(statsCurrencyProvider); + final brightness = Theme.of(context).brightness; + final activeAccount = ref.watch(activeAccountProvider); + final globalColors = ref.watch(cardColorsProvider); + + final CardColors colors; + if (activeAccount != null) { + colors = ref.watch(accountCardColorsProvider(activeAccount.id)); + } else { + colors = globalColors; + } + + final primary = Color.lerp(colors.primary, accentColor, 0.35)!; + final secondary = Color.lerp(colors.secondary, accentColor, 0.2)!; + final gradientType = colors.gradientTypeForBrightness(brightness); + final onCard = primary.computeLuminance() > 0.35 + ? Colors.black + : Colors.white; + + return Container( + height: 128, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: buildCardGradient(primary, secondary, gradientType), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.35), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: onCard.withOpacity(0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + scopeLabel, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: onCard.withOpacity(0.85), + letterSpacing: 0.3, + ), + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + fontSize: 11, + letterSpacing: 1.2, + color: onCard.withOpacity(0.65), + ), + ), + const SizedBox(height: 2), + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: currencyInfo.code == 'BYN' + ? Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + BynSign(fontSize: 28, color: onCard), + const SizedBox(width: 2), + Text( + formatAmount('', amount, fmt), + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w700, + color: onCard, + ), + maxLines: 1, + ), + ], + ) + : Text( + formatAmount(currencyInfo.symbol, amount, fmt), + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w700, + color: onCard, + ), + maxLines: 1, + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/dashboard/provider.dart b/lib/features/dashboard/provider.dart index 7164f6f..1c0901e 100644 --- a/lib/features/dashboard/provider.dart +++ b/lib/features/dashboard/provider.dart @@ -362,12 +362,9 @@ final recentTransactionsProvider = Provider>((ref) { return ref.watch(filteredTransactionsProvider).take(20).toList(); }); -final accountsProvider = StreamProvider>((ref) async* { +final accountsProvider = StreamProvider>((ref) { final repository = ref.watch(accountRepositoryProvider); - while (true) { - yield await repository.getAll(); - await Future.delayed(const Duration(milliseconds: 100)); - } + return repository.watchAll(); }); final activeAccountIndexProvider = NotifierProvider<_ActiveAccountIndexNotifier, int>( diff --git a/lib/features/dashboard/widgets/account_editor_overlay/account_editor_overlay.dart b/lib/features/dashboard/widgets/account_editor_overlay/account_editor_overlay.dart index cc9ccfb..3d84a4b 100644 --- a/lib/features/dashboard/widgets/account_editor_overlay/account_editor_overlay.dart +++ b/lib/features/dashboard/widgets/account_editor_overlay/account_editor_overlay.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/constants.dart'; +import '../../../../core/utils/card_layout.dart'; import '../../../../shared/models/account.dart'; import '../../../../shared/models/transaction.dart'; import '../../../../shared/widgets/byn_sign.dart'; @@ -84,12 +85,13 @@ class _AccountEditorOverlayState extends State { @override Widget build(BuildContext context) { final mq = MediaQuery.of(widget.context); - final cardTop = mq.padding.top + kToolbarHeight + 16; - const cardHeight = 190.0; - const editorPanelHeight = 102.0; - final editorPanelTop = cardTop + cardHeight + 20; - final colorPanelTop = editorPanelTop + editorPanelHeight + 12; - const colorPanelHeight = 410.0; + final layout = CardOverlayLayout.fromMediaQuery(mq); + final cardTop = layout.cardTop; + final cardHeight = layout.cardHeight; + final editorPanelHeight = layout.editorPanelHeight; + final editorPanelTop = cardTop + cardHeight + layout.sectionGap; + final colorPanelTop = editorPanelTop + editorPanelHeight + layout.sectionGap; + final colorPanelHeight = layout.colorPanelHeight(mq, colorPanelTop); return Consumer( builder: (context, ref, _) { @@ -263,6 +265,7 @@ class _AccountEditorOverlayState extends State { dashboardState: dash, dashboardContext: widget.context, panelHeight: colorPanelHeight, + layout: layout, isDuplicateName: _isDuplicateName, onDuplicateError: () { setState(() => _showDuplicateError = true); diff --git a/lib/features/dashboard/widgets/account_editor_overlay/color_panel.dart b/lib/features/dashboard/widgets/account_editor_overlay/color_panel.dart index f59048a..55df861 100644 --- a/lib/features/dashboard/widgets/account_editor_overlay/color_panel.dart +++ b/lib/features/dashboard/widgets/account_editor_overlay/color_panel.dart @@ -5,6 +5,7 @@ import '../../../../core/l10n/app_strings.dart'; import '../../../../core/l10n/locale_provider.dart'; import '../../../../core/services/card_color_service.dart'; import '../../../../core/services/haptic_service.dart'; +import '../../../../core/utils/card_layout.dart'; import '../../../../shared/models/account.dart'; import '../../provider.dart'; import './panel_tab.dart'; @@ -14,6 +15,7 @@ class AccountColorPanel extends StatelessWidget { final dynamic dashboardState; final BuildContext dashboardContext; final double panelHeight; + final CardOverlayLayout layout; final bool Function(List, String) isDuplicateName; final VoidCallback onDuplicateError; @@ -22,6 +24,7 @@ class AccountColorPanel extends StatelessWidget { required this.dashboardState, required this.dashboardContext, required this.panelHeight, + required this.layout, required this.isDuplicateName, required this.onDuplicateError, }); @@ -77,7 +80,12 @@ class AccountColorPanel extends StatelessWidget { : dashboardState.tempSecondaryHSV; return Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 22), + padding: EdgeInsets.fromLTRB( + 16, + layout.panelPaddingTop, + 16, + layout.panelPaddingBottom, + ), child: Column( mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.start, @@ -239,12 +247,12 @@ class AccountColorPanel extends StatelessWidget { ], ), ), - const SizedBox(height: 10), + SizedBox(height: layout.tabSpacing), Expanded( child: LayoutBuilder( builder: (lbCtx, constraints) { - const reservedBelow = 78.0; - final spectrumH = (constraints.maxHeight - reservedBelow) + final spectrumH = (constraints.maxHeight - + layout.reservedBelowControls) .clamp(40.0, double.infinity); return Column( @@ -262,9 +270,9 @@ class AccountColorPanel extends StatelessWidget { ), ), ), - const SizedBox(height: 8), + SizedBox(height: layout.controlSpacing), SizedBox( - height: 36, + height: layout.hueSliderHeight, child: ColorPickerSlider( TrackType.hue, currentHSV, @@ -272,14 +280,14 @@ class AccountColorPanel extends StatelessWidget { displayThumbColor: true, ), ), - const SizedBox(height: 8), + SizedBox(height: layout.controlSpacing), IgnorePointer( ignoring: isSolid, child: AnimatedOpacity( duration: const Duration(milliseconds: 200), opacity: isSolid ? 0.4 : 1.0, child: SizedBox( - height: 26, + height: layout.hexRowHeight, child: Row( children: [ GestureDetector( @@ -411,7 +419,7 @@ class AccountColorPanel extends StatelessWidget { }, ), ), - const SizedBox(height: 8), + SizedBox(height: layout.controlSpacing), IgnorePointer( ignoring: isSolid, child: AnimatedOpacity( @@ -463,8 +471,8 @@ class AccountColorPanel extends StatelessWidget { }, child: AnimatedContainer( duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric( - vertical: 5, + padding: EdgeInsets.symmetric( + vertical: layout.compact ? 3 : 5, ), decoration: BoxDecoration( color: isSelected @@ -523,7 +531,7 @@ class AccountColorPanel extends StatelessWidget { ), ), ), - const SizedBox(height: 8), + SizedBox(height: layout.controlSpacing), Row( children: [ Expanded( @@ -554,10 +562,13 @@ class AccountColorPanel extends StatelessWidget { setPanelState(() {}); dashboardState.overlayEntry?.markNeedsBuild(); }, - icon: const Icon(Icons.restart_alt_rounded, size: 15), + icon: Icon( + Icons.restart_alt_rounded, + size: layout.compact ? 14 : 15, + ), label: Text( s.reset, - style: const TextStyle(fontSize: 13), + style: TextStyle(fontSize: layout.compact ? 12 : 13), ), style: OutlinedButton.styleFrom( foregroundColor: Theme.of( @@ -568,7 +579,9 @@ class AccountColorPanel extends StatelessWidget { dashboardContext, ).colorScheme.onSurface.withOpacity(0.2), ), - padding: const EdgeInsets.symmetric(vertical: 10), + padding: EdgeInsets.symmetric( + vertical: layout.buttonVerticalPadding, + ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), @@ -613,7 +626,9 @@ class AccountColorPanel extends StatelessWidget { disabledForegroundColor: Theme.of( dashboardContext, ).colorScheme.onSurface.withOpacity(0.38), - padding: const EdgeInsets.symmetric(vertical: 10), + padding: EdgeInsets.symmetric( + vertical: layout.buttonVerticalPadding, + ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), @@ -622,9 +637,9 @@ class AccountColorPanel extends StatelessWidget { dashboardState.isAddingAccount ? s.addAccount : s.apply, - style: const TextStyle( + style: TextStyle( fontWeight: FontWeight.w700, - fontSize: 14, + fontSize: layout.compact ? 13 : 14, ), ), ); diff --git a/lib/features/dashboard/widgets/balance_card.dart b/lib/features/dashboard/widgets/balance_card.dart index 2bd4673..b6f7be9 100644 --- a/lib/features/dashboard/widgets/balance_card.dart +++ b/lib/features/dashboard/widgets/balance_card.dart @@ -5,6 +5,8 @@ import 'package:sensors_plus/sensors_plus.dart'; import '../../../core/constants.dart'; import '../../../core/l10n/locale_provider.dart'; import '../../../core/services/card_color_service.dart'; +import '../../../core/utils/card_layout.dart'; +import '../../../shared/utils/card_gradient.dart'; import '../../../core/services/haptic_service.dart'; import '../../../shared/providers/amount_format_provider.dart'; import '../../../shared/widgets/byn_sign.dart'; @@ -83,47 +85,6 @@ class BalanceCardState extends ConsumerState super.dispose(); } - Gradient _buildGradient(Color primary, Color secondary, GradientType type) { - final colorDark = Color.lerp(secondary, Colors.black, 0.3)!; - - switch (type) { - case GradientType.linear: - return LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [primary, secondary, colorDark], - stops: const [0.0, 0.6, 1.0], - ); - case GradientType.linearReverse: - return LinearGradient( - begin: Alignment.topRight, - end: Alignment.bottomLeft, - colors: [primary, secondary, colorDark], - stops: const [0.0, 0.6, 1.0], - ); - case GradientType.radial: - return RadialGradient( - center: Alignment.center, - radius: 1.4, - colors: [primary, secondary, colorDark], - stops: const [0.0, 0.6, 1.0], - ); - case GradientType.sweep: - return SweepGradient( - center: Alignment.center, - startAngle: 0.0, - endAngle: 3.14159 * 2, - colors: [primary, secondary, colorDark, secondary, primary], - stops: const [0.0, 0.25, 0.5, 0.75, 1.0], - ); - case GradientType.solid: - return LinearGradient( - colors: [primary, primary, primary], - stops: const [0.0, 0.5, 1.0], - ); - } - } - @override Widget build(BuildContext context) { final s = ref.watch(stringsProvider); @@ -171,10 +132,10 @@ class BalanceCardState extends ConsumerState ..rotateY(_tiltY * 0.42), child: Container( width: double.infinity, - height: 180, + height: kBalanceCardHeight, decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), - gradient: _buildGradient(primary, secondary, gradientType), + gradient: buildCardGradient(primary, secondary, gradientType), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.4), diff --git a/lib/features/dashboard/widgets/balance_card_carousel.dart b/lib/features/dashboard/widgets/balance_card_carousel.dart index 45f02f6..4e6b16a 100644 --- a/lib/features/dashboard/widgets/balance_card_carousel.dart +++ b/lib/features/dashboard/widgets/balance_card_carousel.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/services/card_color_service.dart'; +import '../../../core/utils/card_layout.dart'; import '../../../core/services/haptic_service.dart'; import '../../../shared/models/account.dart'; import '../../../shared/models/transaction.dart'; @@ -66,7 +67,7 @@ class _BalanceCardCarouselState extends ConsumerState { return Column( children: [ SizedBox( - height: 190, + height: kBalanceCardCarouselHeight, child: OverflowBox( maxWidth: MediaQuery.of(context).size.width, child: PageView.builder( @@ -154,14 +155,14 @@ class _BalanceCardCarouselState extends ConsumerState { ); }, loading: () => const SizedBox( - height: 180, + height: kBalanceCardCarouselHeight, child: Center(child: CircularProgressIndicator()), ), error: (error, stack) { return Column( children: [ SizedBox( - height: 180, + height: kBalanceCardCarouselHeight, child: BalanceCard( balance: widget.balance, currencyInfo: widget.currencyInfo, @@ -192,13 +193,13 @@ class AddAccountCard extends StatelessWidget { child: Container( margin: const EdgeInsets.symmetric( horizontal: 10, - vertical: 10, + vertical: 5, ), child: CustomPaint( painter: _DashedBorderPainter(), child: Container( width: double.infinity, - height: 165, + height: kAddAccountCardHeight, decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface.withOpacity(0.4), borderRadius: BorderRadius.circular(20), diff --git a/lib/features/dashboard/widgets/color_editor_overlay.dart b/lib/features/dashboard/widgets/color_editor_overlay.dart index fc34cc1..4b764be 100644 --- a/lib/features/dashboard/widgets/color_editor_overlay.dart +++ b/lib/features/dashboard/widgets/color_editor_overlay.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/l10n/app_strings.dart'; import '../../../core/l10n/locale_provider.dart'; import '../../../core/services/card_color_service.dart'; +import '../../../core/utils/card_layout.dart'; import '../../settings/provider.dart'; import '../provider.dart'; import 'balance_card.dart'; @@ -34,10 +35,11 @@ class _FullScreenBlurOverlayState extends State { @override Widget build(BuildContext context) { final mq = MediaQuery.of(widget.context); - final cardTop = mq.padding.top + kToolbarHeight + 16; - const cardHeight = 230.0; - final panelTop = cardTop + cardHeight + 65; - const panelHeight = 410.0; + final layout = CardOverlayLayout.fromMediaQuery(mq); + final cardTop = layout.cardTop; + final cardHeight = layout.cardHeight; + final panelTop = cardTop + cardHeight + layout.cardPreviewGap; + final panelHeight = layout.colorPanelHeight(mq, panelTop); return Material( color: Colors.transparent, @@ -91,7 +93,7 @@ class _FullScreenBlurOverlayState extends State { child: GestureDetector( onTap: () {}, behavior: HitTestBehavior.opaque, - child: _buildPanel(panelHeight), + child: _buildPanel(panelHeight, layout), ), ), Positioned( @@ -132,7 +134,7 @@ class _FullScreenBlurOverlayState extends State { ); } - Widget _buildPanel(double panelHeight) { + Widget _buildPanel(double panelHeight, CardOverlayLayout layout) { return Container( height: panelHeight, decoration: BoxDecoration( @@ -182,7 +184,12 @@ class _FullScreenBlurOverlayState extends State { : dash.tempSecondaryHSV; return Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 22), + padding: EdgeInsets.fromLTRB( + 16, + layout.panelPaddingTop, + 16, + layout.panelPaddingBottom, + ), child: Column( mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.start, @@ -341,12 +348,12 @@ class _FullScreenBlurOverlayState extends State { ], ), ), - const SizedBox(height: 10), + SizedBox(height: layout.tabSpacing), Expanded( child: LayoutBuilder( builder: (lbCtx, constraints) { - const reservedBelow = 78.0; - final spectrumH = (constraints.maxHeight - reservedBelow) + final spectrumH = (constraints.maxHeight - + layout.reservedBelowControls) .clamp(40.0, double.infinity); return Column( @@ -364,9 +371,9 @@ class _FullScreenBlurOverlayState extends State { ), ), ), - const SizedBox(height: 8), + SizedBox(height: layout.controlSpacing), SizedBox( - height: 36, + height: layout.hueSliderHeight, child: ColorPickerSlider( TrackType.hue, currentHSV, @@ -374,14 +381,14 @@ class _FullScreenBlurOverlayState extends State { displayThumbColor: true, ), ), - const SizedBox(height: 8), + SizedBox(height: layout.controlSpacing), IgnorePointer( ignoring: isSolid, child: AnimatedOpacity( duration: const Duration(milliseconds: 200), opacity: isSolid ? 0.4 : 1.0, child: SizedBox( - height: 26, + height: layout.hexRowHeight, child: Row( children: [ GestureDetector( @@ -499,7 +506,7 @@ class _FullScreenBlurOverlayState extends State { }, ), ), - const SizedBox(height: 8), + SizedBox(height: layout.controlSpacing), IgnorePointer( ignoring: isSolid, child: AnimatedOpacity( @@ -545,8 +552,8 @@ class _FullScreenBlurOverlayState extends State { }, child: AnimatedContainer( duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric( - vertical: 5, + padding: EdgeInsets.symmetric( + vertical: layout.compact ? 3 : 5, ), decoration: BoxDecoration( color: isSelected @@ -605,7 +612,7 @@ class _FullScreenBlurOverlayState extends State { ), ), ), - const SizedBox(height: 8), + SizedBox(height: layout.controlSpacing), Row( children: [ Expanded( @@ -633,10 +640,13 @@ class _FullScreenBlurOverlayState extends State { setPanelState(() {}); dash.overlayEntry?.markNeedsBuild(); }, - icon: const Icon(Icons.restart_alt_rounded, size: 15), + icon: Icon( + Icons.restart_alt_rounded, + size: layout.compact ? 14 : 15, + ), label: Text( s.reset, - style: const TextStyle(fontSize: 13), + style: TextStyle(fontSize: layout.compact ? 12 : 13), ), style: OutlinedButton.styleFrom( foregroundColor: Theme.of( @@ -647,7 +657,9 @@ class _FullScreenBlurOverlayState extends State { widget.context, ).colorScheme.onSurface.withOpacity(0.2), ), - padding: const EdgeInsets.symmetric(vertical: 10), + padding: EdgeInsets.symmetric( + vertical: layout.buttonVerticalPadding, + ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), @@ -662,16 +674,18 @@ class _FullScreenBlurOverlayState extends State { style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF7C6DED), foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 10), + padding: EdgeInsets.symmetric( + vertical: layout.buttonVerticalPadding, + ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: Text( s.apply, - style: const TextStyle( + style: TextStyle( fontWeight: FontWeight.w700, - fontSize: 14, + fontSize: layout.compact ? 13 : 14, ), ), ), diff --git a/lib/main.dart b/lib/main.dart index 84c7e50..a278d03 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,188 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:uuid/uuid.dart'; import 'app/app.dart'; import 'core/services/haptic_service.dart'; -import 'data/database/app_database.dart' hide Account, Transaction; -import 'data/repositories/account_repository.dart'; -import 'data/repositories/transaction_repository.dart'; +import 'data/database/app_database.dart'; import 'features/dashboard/provider.dart'; -import 'shared/models/account.dart'; -import 'shared/models/transaction.dart'; - -Future seedTestData(AppDatabase database) async { - final accountRepo = AccountRepository(database); - final transactionRepo = TransactionRepository(database); - - final existingAccounts = await accountRepo.getAll(); - if (existingAccounts.length > 1) { - return; - } - - final now = DateTime.now(); - final uuid = const Uuid(); - - final cashId = await accountRepo.add( - Account( - id: 0, - name: 'Cash', - isMain: false, - sortOrder: 1, - currency: 'USD', - createdAt: now, - ), - ); - - final cardId = await accountRepo.add( - Account( - id: 0, - name: 'Card', - isMain: false, - sortOrder: 2, - currency: 'USD', - createdAt: now, - ), - ); - - final savingsId = await accountRepo.add( - Account( - id: 0, - name: 'Savings', - isMain: false, - sortOrder: 3, - currency: 'USD', - createdAt: now, - ), - ); - - final transactions = [ - Transaction( - id: uuid.v4(), - amount: 3500.0, - category: 'Salary', - type: TransactionType.income, - date: now.subtract(const Duration(days: 28)), - note: 'Monthly salary', - accountId: cardId, - ), - Transaction( - id: uuid.v4(), - amount: 85.50, - category: 'Groceries', - type: TransactionType.expense, - date: now.subtract(const Duration(days: 27)), - note: 'Weekly shopping', - accountId: cardId, - ), - Transaction( - id: uuid.v4(), - amount: 45.00, - category: 'Transportation', - type: TransactionType.expense, - date: now.subtract(const Duration(days: 25)), - accountId: cashId, - ), - Transaction( - id: uuid.v4(), - amount: 120.00, - category: 'Utilities', - type: TransactionType.expense, - date: now.subtract(const Duration(days: 22)), - note: 'Electricity bill', - accountId: cardId, - ), - Transaction( - id: uuid.v4(), - amount: 200.0, - category: 'Freelance', - type: TransactionType.income, - date: now.subtract(const Duration(days: 20)), - note: 'Side project', - accountId: cashId, - ), - Transaction( - id: uuid.v4(), - amount: 500.0, - category: 'Savings', - type: TransactionType.expense, - date: now.subtract(const Duration(days: 18)), - note: 'Monthly savings', - accountId: savingsId, - ), - Transaction( - id: uuid.v4(), - amount: 65.25, - category: 'Dining', - type: TransactionType.expense, - date: now.subtract(const Duration(days: 15)), - accountId: cardId, - ), - Transaction( - id: uuid.v4(), - amount: 30.00, - category: 'Entertainment', - type: TransactionType.expense, - date: now.subtract(const Duration(days: 12)), - note: 'Movie tickets', - accountId: cashId, - ), - Transaction( - id: uuid.v4(), - amount: 95.00, - category: 'Groceries', - type: TransactionType.expense, - date: now.subtract(const Duration(days: 10)), - accountId: cardId, - ), - Transaction( - id: uuid.v4(), - amount: 150.0, - category: 'Bonus', - type: TransactionType.income, - date: now.subtract(const Duration(days: 8)), - note: 'Performance bonus', - accountId: cardId, - ), - Transaction( - id: uuid.v4(), - amount: 75.00, - category: 'Shopping', - type: TransactionType.expense, - date: now.subtract(const Duration(days: 6)), - accountId: cardId, - ), - Transaction( - id: uuid.v4(), - amount: 40.00, - category: 'Transportation', - type: TransactionType.expense, - date: now.subtract(const Duration(days: 4)), - accountId: cashId, - ), - Transaction( - id: uuid.v4(), - amount: 55.80, - category: 'Dining', - type: TransactionType.expense, - date: now.subtract(const Duration(days: 2)), - note: 'Dinner with friends', - accountId: cardId, - ), - Transaction( - id: uuid.v4(), - amount: 100.0, - category: 'Gift', - type: TransactionType.income, - date: now.subtract(const Duration(days: 1)), - accountId: cashId, - ), - ]; - - for (final transaction in transactions) { - await transactionRepo.add(transaction); - } -} void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -197,8 +19,6 @@ void main() async { await HapticService.init(); final database = AppDatabase(); - - await seedTestData(database); runApp( ProviderScope( diff --git a/lib/shared/utils/card_gradient.dart b/lib/shared/utils/card_gradient.dart new file mode 100644 index 0000000..c5bbc15 --- /dev/null +++ b/lib/shared/utils/card_gradient.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import '../../core/services/card_color_service.dart'; + +Gradient buildCardGradient(Color primary, Color secondary, GradientType type) { + final colorDark = Color.lerp(secondary, Colors.black, 0.3)!; + + switch (type) { + case GradientType.linear: + return LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [primary, secondary, colorDark], + stops: const [0.0, 0.6, 1.0], + ); + case GradientType.linearReverse: + return LinearGradient( + begin: Alignment.topRight, + end: Alignment.bottomLeft, + colors: [primary, secondary, colorDark], + stops: const [0.0, 0.6, 1.0], + ); + case GradientType.radial: + return RadialGradient( + center: Alignment.center, + radius: 1.4, + colors: [primary, secondary, colorDark], + stops: const [0.0, 0.6, 1.0], + ); + case GradientType.sweep: + return SweepGradient( + center: Alignment.center, + startAngle: 0.0, + endAngle: 3.14159 * 2, + colors: [primary, secondary, colorDark, secondary, primary], + stops: const [0.0, 0.25, 0.5, 0.75, 1.0], + ); + case GradientType.solid: + return LinearGradient( + colors: [primary, primary, primary], + stops: const [0.0, 0.5, 1.0], + ); + } +}