From ed45748c958e72ac2b0e6d720ca189ed6dda0c06 Mon Sep 17 00:00:00 2001 From: kolo Date: Fri, 26 Jun 2026 02:00:42 +0300 Subject: [PATCH] step --- android/app/build.gradle.kts | 18 +- lib/core/l10n/app_strings.dart | 14 + lib/features/categories/provider.dart | 61 +- lib/features/categories/screen.dart | 1066 +++++++++++++++++++------ 4 files changed, 893 insertions(+), 266 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c0dbd2b..d427c7d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -34,18 +34,22 @@ android { versionName = flutter.versionName } - signingConfigs { - create("release") { - keyAlias = keyProperties["keyAlias"] as String - keyPassword = keyProperties["keyPassword"] as String - storeFile = file(keyProperties["storeFile"] as String) - storePassword = keyProperties["storePassword"] as String + if (keyPropertiesFile.exists()) { + signingConfigs { + create("release") { + keyAlias = keyProperties["keyAlias"] as String + keyPassword = keyProperties["keyPassword"] as String + storeFile = file(keyProperties["storeFile"] as String) + storePassword = keyProperties["storePassword"] as String + } } } buildTypes { release { - signingConfig = signingConfigs.getByName("release") + if (keyPropertiesFile.exists()) { + signingConfig = signingConfigs.getByName("release") + } } } } diff --git a/lib/core/l10n/app_strings.dart b/lib/core/l10n/app_strings.dart index 12a6be5..341daad 100644 --- a/lib/core/l10n/app_strings.dart +++ b/lib/core/l10n/app_strings.dart @@ -122,6 +122,20 @@ class AppStrings { String get allAccounts => _ru ? 'Все счета' : 'All accounts'; String get categories => _ru ? 'Категории' : 'Categories'; String get rankedByAmount => _ru ? 'По сумме' : 'Ranked by Amount'; + String get overview => _ru ? 'Обзор' : 'Overview'; + String get netBalance => _ru ? 'Чистый баланс' : 'Net Balance'; + String get averageIncome => _ru ? 'Средний доход' : 'Average Income'; + String get averageExpense => _ru ? 'Средний расход' : 'Average Expense'; + String get transactionsCount => _ru ? 'Транзакции' : 'Transactions'; + String get expenseStructure => _ru ? 'Структура категорий' : 'Category Structure'; + String get topCategories => _ru ? 'Топ категорий' : 'Top Categories'; + String get monthlyTrend => _ru ? 'Тренд по месяцам' : 'Monthly Trend'; + String get topCategory => _ru ? 'Лидер категории' : 'Top Category'; + String get shareOfTotal => _ru ? 'Доля от общего' : 'Share of Total'; + String get thisPeriod => _ru ? 'За период' : 'This Period'; + String get analyticsInsight => _ru ? 'Финансовый срез по выбранному диапазону и счёту' : 'Financial snapshot for the selected range and account'; + String get noStatisticsYet => _ru ? 'Пока недостаточно данных' : 'Not enough data yet'; + String get statisticsWillAppear => _ru ? 'Когда появятся операции, здесь будет красивый аналитический обзор' : 'Once you add transactions, a beautiful analytics overview will appear here'; String get addCategory => _ru ? 'Добавить категорию' : 'Add Category'; String get editCategory => _ru ? 'Редактировать' : 'Edit Category'; String get categoryName => _ru ? 'Название' : 'Name'; diff --git a/lib/features/categories/provider.dart b/lib/features/categories/provider.dart index f8d6c71..1b4792a 100644 --- a/lib/features/categories/provider.dart +++ b/lib/features/categories/provider.dart @@ -3,6 +3,24 @@ import '../../shared/models/transaction.dart'; import '../dashboard/provider.dart'; import '../settings/provider.dart'; + class StatsSummary { + final double income; + final double expense; + final double balance; + final int transactionCount; + final double averageIncome; + final double averageExpense; + + const StatsSummary({ + required this.income, + required this.expense, + required this.balance, + required this.transactionCount, + required this.averageIncome, + required this.averageExpense, + }); + } + String _statsTargetCurrency(Ref ref) { final index = ref.watch(activeAccountIndexProvider); final accountsAsync = ref.watch(accountsProvider); @@ -51,21 +69,56 @@ final statsCurrencyProvider = Provider((ref) { return _statsCurrencyInfo(ref); }); + final statsScopedTransactionsProvider = Provider>((ref) { + return _statsScopedTransactions(ref); + }); + final statsIncomeTotalProvider = Provider((ref) { - return _statsScopedTransactions(ref) + return ref + .watch(statsScopedTransactionsProvider) .where((t) => t.type == TransactionType.income) .fold(0.0, (sum, t) => sum + _convertAmount(ref, t)); }); final statsExpenseTotalProvider = Provider((ref) { - return _statsScopedTransactions(ref) + return ref + .watch(statsScopedTransactionsProvider) .where((t) => t.type == TransactionType.expense) .fold(0.0, (sum, t) => sum + _convertAmount(ref, t)); }); + final statsSummaryProvider = Provider((ref) { + final transactions = ref.watch(statsScopedTransactionsProvider); + var income = 0.0; + var expense = 0.0; + var incomeCount = 0; + var expenseCount = 0; + + for (final transaction in transactions) { + final amount = _convertAmount(ref, transaction); + if (transaction.type == TransactionType.income) { + income += amount; + incomeCount++; + } + if (transaction.type == TransactionType.expense) { + expense += amount; + expenseCount++; + } + } + + return StatsSummary( + income: income, + expense: expense, + balance: income - expense, + transactionCount: transactions.length, + averageIncome: incomeCount == 0 ? 0 : income / incomeCount, + averageExpense: expenseCount == 0 ? 0 : expense / expenseCount, + ); + }); + final categoryExpenseProvider = Provider>((ref) { final map = {}; - for (final t in _statsScopedTransactions(ref)) { + for (final t in ref.watch(statsScopedTransactionsProvider)) { if (t.type != TransactionType.expense) continue; map[t.category] = (map[t.category] ?? 0) + _convertAmount(ref, t); } @@ -74,7 +127,7 @@ final categoryExpenseProvider = Provider>((ref) { final categoryIncomeProvider = Provider>((ref) { final map = {}; - for (final t in _statsScopedTransactions(ref)) { + for (final t in ref.watch(statsScopedTransactionsProvider)) { if (t.type != TransactionType.income) continue; map[t.category] = (map[t.category] ?? 0) + _convertAmount(ref, t); } diff --git a/lib/features/categories/screen.dart b/lib/features/categories/screen.dart index 84e41ee..24eddaf 100644 --- a/lib/features/categories/screen.dart +++ b/lib/features/categories/screen.dart @@ -1,6 +1,7 @@ import 'package:fl_chart/fl_chart.dart'; 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'; @@ -12,6 +13,7 @@ import '../dashboard/provider.dart'; import '../settings/provider.dart'; import 'provider.dart'; import 'widgets/account_scope_chips.dart'; +import 'widgets/stats_hero_card.dart'; class CategoriesScreen extends ConsumerStatefulWidget { const CategoriesScreen({super.key}); @@ -27,16 +29,23 @@ class _CategoriesScreenState extends ConsumerState { @override Widget build(BuildContext context) { final s = ref.watch(stringsProvider); + final summary = ref.watch(statsSummaryProvider); final data = _showIncome ? ref.watch(categoryIncomeProvider) : ref.watch(categoryExpenseProvider); final total = data.values.fold(0.0, (a, b) => a + b); final currencyInfo = ref.watch(statsCurrencyProvider); final timeFilter = ref.watch(timeFilterProvider); + final monthlyData = _showIncome + ? ref.watch(monthlyIncomeBreakdownProvider) + : ref.watch(monthlyBreakdownProvider); + final scopeLabel = _scopeLabel(s); final sortedEntries = data.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); + final topEntry = sortedEntries.isEmpty ? null : sortedEntries.first; + return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, appBar: AppBar( @@ -49,119 +58,262 @@ class _CategoriesScreenState extends ConsumerState { ), ), body: SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const AccountScopeChips(), - const SizedBox(height: 16), - Row( + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 100), + children: [ + const AccountScopeChips(), + const SizedBox(height: 16), + _FilterCard( + child: Column( children: [ - Expanded( - child: _TimeFilterChip( - label: s.filterAllTime, - isSelected: timeFilter == TimeFilter.allTime, - onTap: () { - HapticService.selection(); - ref.read(timeFilterProvider.notifier).set(TimeFilter.allTime); - }, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _TimeFilterChip( - label: s.filterMonth, - isSelected: timeFilter == TimeFilter.lastMonth, - onTap: () { - HapticService.selection(); - ref.read(timeFilterProvider.notifier).set(TimeFilter.lastMonth); - }, - ), - ), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: _TypeSegment( - label: s.expenses, - isSelected: !_showIncome, - onTap: () { - HapticService.selection(); - setState(() { - _showIncome = false; - _touchedIndex = -1; - }); - }, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _TypeSegment( - label: s.income, - isSelected: _showIncome, - onTap: () { - HapticService.selection(); - setState(() { - _showIncome = true; - _touchedIndex = -1; - }); - }, - ), - ), - ], - ), - const SizedBox(height: 20), - if (data.isEmpty) - Expanded(child: _EmptyState(isIncome: _showIncome)) - else - Expanded( - child: ListView( + Row( children: [ - _PieChartSection( - data: data, - total: total, - touchedIndex: _touchedIndex, - onTouch: (i) => setState(() => _touchedIndex = i), - currencyInfo: currencyInfo, - isIncome: _showIncome, + Expanded( + child: _TimeFilterChip( + label: s.filterAllTime, + isSelected: timeFilter == TimeFilter.allTime, + onTap: () { + HapticService.selection(); + ref.read(timeFilterProvider.notifier).set(TimeFilter.allTime); + }, + ), ), - const SizedBox(height: 24), - Text( - s.rankedByAmount, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith( - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurface, - ), + const SizedBox(width: 10), + Expanded( + child: _TimeFilterChip( + label: s.filterMonth, + isSelected: timeFilter == TimeFilter.lastMonth, + onTap: () { + HapticService.selection(); + ref.read(timeFilterProvider.notifier).set(TimeFilter.lastMonth); + }, + ), ), - const SizedBox(height: 16), - ...sortedEntries.map((entry) { - final cat = entry.key; - final amount = entry.value; - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: _CategoryItem( - category: cat, - amount: amount, - total: total, - currencyInfo: currencyInfo, - isIncome: _showIncome, - ), - ); - }), - const SizedBox(height: 80), ], ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _TypeSegment( + label: s.expenses, + isSelected: !_showIncome, + color: AppColors.expense, + onTap: () { + HapticService.selection(); + setState(() { + _showIncome = false; + _touchedIndex = -1; + }); + }, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _TypeSegment( + label: s.income, + isSelected: _showIncome, + color: AppColors.income, + onTap: () { + HapticService.selection(); + setState(() { + _showIncome = true; + _touchedIndex = -1; + }); + }, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 18), + StatsHeroCard( + amount: _showIncome ? summary.income : summary.expense, + label: _showIncome ? s.income.toUpperCase() : s.expenses.toUpperCase(), + accentColor: _showIncome ? AppColors.income : AppColors.expense, + scopeLabel: scopeLabel, + ), + const SizedBox(height: 16), + _InsightCard( + title: s.overview, + subtitle: s.analyticsInsight, + child: GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: 1.55, + children: [ + _MetricTile( + label: s.income, + value: summary.income, + currencyInfo: currencyInfo, + color: AppColors.income, + icon: Icons.south_west_rounded, + ), + _MetricTile( + label: s.expenses, + value: summary.expense, + currencyInfo: currencyInfo, + color: AppColors.expense, + icon: Icons.north_east_rounded, + ), + _MetricTile( + label: s.netBalance, + value: summary.balance, + currencyInfo: currencyInfo, + color: summary.balance >= 0 ? AppColors.accent : AppColors.warning, + icon: Icons.account_balance_wallet_rounded, + ), + _CountTile( + label: s.transactionsCount, + value: summary.transactionCount, + color: AppColors.accent, + icon: Icons.receipt_long_rounded, + ), + ], + ), + ), + const SizedBox(height: 16), + _InsightCard( + title: s.monthlyTrend, + subtitle: s.lastSixMonths, + child: _MonthlyTrendSection( + data: monthlyData, + color: _showIncome ? AppColors.income : AppColors.expense, + ), + ), + const SizedBox(height: 16), + if (data.isEmpty) + _EmptyState(isIncome: _showIncome) + else ...[ + _InsightCard( + title: s.expenseStructure, + subtitle: s.thisPeriod, + child: _PieChartSection( + data: data, + total: total, + touchedIndex: _touchedIndex, + onTouch: (i) => setState(() => _touchedIndex = i), + currencyInfo: currencyInfo, + isIncome: _showIncome, ), + ), + const SizedBox(height: 16), + _InsightCard( + title: s.topCategories, + subtitle: topEntry == null + ? s.rankedByAmount + : '${s.topCategory}: ${s.categoryLabel(topEntry.key)}', + child: Column( + children: [ + _SummaryBadgeRow( + isIncome: _showIncome, + currencyInfo: currencyInfo, + topEntry: topEntry, + total: total, + ), + const SizedBox(height: 14), + ...sortedEntries.map((entry) { + final cat = entry.key; + final amount = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _CategoryItem( + category: cat, + amount: amount, + total: total, + currencyInfo: currencyInfo, + isIncome: _showIncome, + ), + ); + }), + ], + ), + ), ], - ), + ], ), ), ); } + + String _scopeLabel(AppStrings s) { + final activeAccount = ref.watch(activeAccountProvider); + return activeAccount?.name ?? s.allAccounts; + } +} + +class _FilterCard extends StatelessWidget { + final Widget child; + + const _FilterCard({required this.child}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.06), + ), + ), + child: child, + ); + } +} + +class _InsightCard extends StatelessWidget { + final String title; + final String subtitle; + final Widget child; + + const _InsightCard({ + required this.title, + required this.subtitle, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: theme.colorScheme.onSurface.withOpacity(0.06), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.55), + ), + ), + const SizedBox(height: 16), + child, + ], + ), + ); + } } class _TimeFilterChip extends StatelessWidget { @@ -179,13 +331,19 @@ class _TimeFilterChip extends StatelessWidget { Widget build(BuildContext context) { return GestureDetector( onTap: onTap, - child: Container( + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), padding: const EdgeInsets.symmetric(vertical: 12), decoration: BoxDecoration( color: isSelected ? AppColors.accent : Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected + ? AppColors.accent + : Theme.of(context).colorScheme.onSurface.withOpacity(0.06), + ), ), child: Text( label, @@ -206,11 +364,13 @@ class _TimeFilterChip extends StatelessWidget { class _TypeSegment extends StatelessWidget { final String label; final bool isSelected; + final Color color; final VoidCallback onTap; const _TypeSegment({ required this.label, required this.isSelected, + required this.color, required this.onTap, }); @@ -218,33 +378,32 @@ class _TypeSegment extends StatelessWidget { Widget build(BuildContext context) { return GestureDetector( onTap: onTap, - child: Container( + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), padding: const EdgeInsets.symmetric(vertical: 12), decoration: BoxDecoration( - color: isSelected - ? (_showIncomeColor(label) ? AppColors.income : AppColors.expense) - : Colors.transparent, - borderRadius: BorderRadius.circular(12), + color: isSelected ? color : Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected + ? color + : Theme.of(context).colorScheme.onSurface.withOpacity(0.06), + ), ), child: Text( label, textAlign: TextAlign.center, style: TextStyle( fontSize: 14, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: isSelected ? Colors.white - : Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + : Theme.of(context).colorScheme.onSurface.withOpacity(0.65), ), ), ), ); } - - bool _showIncomeColor(String label) { - final lower = label.toLowerCase(); - return lower.contains('income') || lower.contains('доход'); - } } class _PieChartSection extends ConsumerWidget { @@ -270,91 +429,206 @@ class _PieChartSection extends ConsumerWidget { final fmt = ref.watch(amountFormatProvider); final entries = data.entries.toList(); final accent = isIncome ? AppColors.income : AppColors.expense; + final selectedIndex = touchedIndex >= 0 ? touchedIndex : 0; + final selectedEntry = entries[selectedIndex.clamp(0, entries.length - 1)]; + final selectedAmount = selectedEntry.value; - return SizedBox( - height: 240, - child: Stack( - alignment: Alignment.center, - children: [ - PieChart( - PieChartData( - pieTouchData: PieTouchData( - touchCallback: (event, response) { - if (!event.isInterestedForInteractions || - response == null || - response.touchedSection == null) { - onTouch(-1); - return; - } - onTouch(response.touchedSection!.touchedSectionIndex); - }, - ), - sectionsSpace: 2, - centerSpaceRadius: 70, - sections: List.generate(entries.length, (i) { - final isTouched = i == touchedIndex; - final cat = entries[i].key; - final val = entries[i].value; - final color = - AppCategories.colors[cat] ?? AppColors.accent; - return PieChartSectionData( - color: color, - value: val, - title: isTouched - ? '${(val / total * 100).toStringAsFixed(0)}%' - : '', - radius: isTouched ? 55 : 48, - titleStyle: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ); - }), - ), - ), - Column( - mainAxisSize: MainAxisSize.min, + return Column( + children: [ + SizedBox( + height: 260, + child: Stack( + alignment: Alignment.center, children: [ - Text( - s.total, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5), - fontSize: 11, + PieChart( + PieChartData( + pieTouchData: PieTouchData( + touchCallback: (event, response) { + if (!event.isInterestedForInteractions || + response == null || + response.touchedSection == null) { + onTouch(-1); + return; + } + onTouch(response.touchedSection!.touchedSectionIndex); + }, + ), + sectionsSpace: 4, + centerSpaceRadius: 78, + sections: List.generate(entries.length, (i) { + final isTouched = i == touchedIndex; + final cat = entries[i].key; + final val = entries[i].value; + final color = AppCategories.colors[cat] ?? AppColors.accent; + return PieChartSectionData( + color: color, + value: val, + title: isTouched + ? '${(val / total * 100).toStringAsFixed(0)}%' + : '', + radius: isTouched ? 60 : 52, + titleStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ); + }), ), ), - const SizedBox(height: 4), - currencyInfo.code == 'BYN' - ? Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - BynSign( - fontSize: 24, - color: accent, - ), - const SizedBox(width: 2), - Text( - formatAmount('', total, fmt), - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: accent, - fontWeight: FontWeight.w700, - fontSize: 24, - ), - ), - ], - ) - : Text( - formatAmount(currencyInfo.symbol, total, fmt), - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: accent, - fontWeight: FontWeight.w700, - fontSize: 24, + Container( + width: 136, + height: 136, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).scaffoldBackgroundColor, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + s.total, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5), + fontSize: 11, ), ), + const SizedBox(height: 6), + _FormattedAmount( + amount: total, + currencyInfo: currencyInfo, + color: accent, + fontSize: 20, + fontWeight: FontWeight.w700, + format: fmt, + center: true, + ), + ], + ), + ), ], ), - ], + ), + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: (AppCategories.colors[selectedEntry.key] ?? accent).withOpacity(0.10), + borderRadius: BorderRadius.circular(18), + ), + child: Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: AppCategories.colors[selectedEntry.key] ?? accent, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + s.categoryLabel(selectedEntry.key), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + '${(selectedAmount / total * 100).toStringAsFixed(1)}%', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + color: accent, + ), + ), + ], + ), + ), + ], + ); + } +} + +class _MonthlyTrendSection extends StatelessWidget { + final List data; + final Color color; + + const _MonthlyTrendSection({required this.data, required this.color}); + + @override + Widget build(BuildContext context) { + final locale = Localizations.localeOf(context).languageCode == 'ru' + ? 'ru_RU' + : 'en_US'; + final maxY = data.isEmpty + ? 1.0 + : data.map((item) => item.amount).reduce((a, b) => a > b ? a : b); + + return SizedBox( + height: 220, + child: BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: maxY == 0 ? 1 : maxY * 1.25, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: maxY == 0 ? 1 : (maxY * 1.25) / 4, + getDrawingHorizontalLine: (value) { + return FlLine( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.06), + strokeWidth: 1, + ); + }, + ), + borderData: FlBorderData(show: false), + titlesData: FlTitlesData( + leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + final index = value.toInt(); + if (index < 0 || index >= data.length) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + DateFormat('MMM', locale).format(data[index].month), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.55), + fontWeight: FontWeight.w600, + ), + ), + ); + }, + ), + ), + ), + barGroups: List.generate(data.length, (index) { + final amount = data[index].amount; + return BarChartGroupData( + x: index, + barRods: [ + BarChartRodData( + toY: amount, + width: 18, + borderRadius: BorderRadius.circular(8), + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [color.withOpacity(0.65), color], + ), + ), + ], + ); + }), + ), ), ); } @@ -383,70 +657,333 @@ class _CategoryItem extends ConsumerWidget { final icon = AppCategories.icons[category] ?? Icons.category_rounded; final pct = total > 0 ? amount / total : 0.0; - return Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: color.withOpacity(0.12), - borderRadius: BorderRadius.circular(10), - ), - child: Icon(icon, color: color, size: 20), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor.withOpacity(0.55), + borderRadius: BorderRadius.circular(18), + ), + child: Column( + children: [ + Row( children: [ - Text( - s.categoryLabel(category), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurface, + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: color, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + s.categoryLabel(category), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + '${(pct * 100).toStringAsFixed(1)}%', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5), + fontSize: 12, + ), + ), + ], ), ), - const SizedBox(height: 2), - Text( - '${(pct * 100).toStringAsFixed(1)}%', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5), - fontSize: 12, - ), + _FormattedAmount( + amount: amount, + currencyInfo: currencyInfo, + color: isIncome ? AppColors.income : AppColors.expense, + fontSize: 14, + fontWeight: FontWeight.w700, + format: fmt, ), ], ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + minHeight: 8, + value: pct, + backgroundColor: color.withOpacity(0.12), + valueColor: AlwaysStoppedAnimation(color), + ), + ), + ], + ), + ); + } +} + +class _SummaryBadgeRow extends ConsumerWidget { + final bool isIncome; + final CurrencyInfo currencyInfo; + final MapEntry? topEntry; + final double total; + + const _SummaryBadgeRow({ + required this.isIncome, + required this.currencyInfo, + required this.topEntry, + required this.total, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final s = ref.watch(stringsProvider); + final summary = ref.watch(statsSummaryProvider); + final fmt = ref.watch(amountFormatProvider); + final average = isIncome ? summary.averageIncome : summary.averageExpense; + final share = topEntry == null || total == 0 ? 0.0 : topEntry!.value / total; + + return Row( + children: [ + Expanded( + child: _MiniBadge( + title: isIncome ? s.averageIncome : s.averageExpense, + child: _FormattedAmount( + amount: average, + currencyInfo: currencyInfo, + color: isIncome ? AppColors.income : AppColors.expense, + fontSize: 13, + fontWeight: FontWeight.w700, + format: fmt, + ), + ), ), - currencyInfo.code == 'BYN' - ? Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - BynSign( - fontSize: 15, - 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, - ), + const SizedBox(width: 10), + Expanded( + child: _MiniBadge( + title: s.shareOfTotal, + child: Text( + '${(share * 100).toStringAsFixed(1)}%', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + color: AppColors.accent, ), + ), + ), + ), ], ); } } +class _MiniBadge extends StatelessWidget { + final String title; + final Widget child; + + const _MiniBadge({required this.title, required this.child}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.scaffoldBackgroundColor.withOpacity(0.55), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.55), + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + child, + ], + ), + ); + } +} + +class _FormattedAmount extends StatelessWidget { + final double amount; + final CurrencyInfo currencyInfo; + final Color color; + final double fontSize; + final FontWeight fontWeight; + final AmountFormat format; + final bool center; + + const _FormattedAmount({ + required this.amount, + required this.currencyInfo, + required this.color, + required this.fontSize, + required this.fontWeight, + required this.format, + this.center = false, + }); + + @override + Widget build(BuildContext context) { + final text = Text( + formatAmount(currencyInfo.code == 'BYN' ? '' : currencyInfo.symbol, amount, format), + textAlign: center ? TextAlign.center : TextAlign.start, + style: TextStyle( + fontSize: fontSize, + fontWeight: fontWeight, + color: color, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + + if (currencyInfo.code != 'BYN') { + return text; + } + + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: center ? MainAxisAlignment.center : MainAxisAlignment.start, + children: [ + BynSign(fontSize: fontSize, color: color), + const SizedBox(width: 2), + Flexible(child: text), + ], + ); + } +} + +class _MetricTile extends ConsumerWidget { + final String label; + final double value; + final CurrencyInfo currencyInfo; + final Color color; + final IconData icon; + + const _MetricTile({ + required this.label, + required this.value, + required this.currencyInfo, + required this.color, + required this.icon, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fmt = ref.watch(amountFormatProvider); + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.scaffoldBackgroundColor.withOpacity(0.55), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: color, size: 18), + ), + const SizedBox(height: 8), + Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.55), + fontWeight: FontWeight.w600, + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + _FormattedAmount( + amount: value, + currencyInfo: currencyInfo, + color: color, + fontSize: 15, + fontWeight: FontWeight.w700, + format: fmt, + ), + ], + ), + ); + } +} + +class _CountTile extends StatelessWidget { + final String label; + final int value; + final Color color; + final IconData icon; + + const _CountTile({ + required this.label, + required this.value, + required this.color, + required this.icon, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.scaffoldBackgroundColor.withOpacity(0.55), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: color, size: 18), + ), + const SizedBox(height: 8), + Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.55), + fontWeight: FontWeight.w600, + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + value.toString(), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + color: color, + fontSize: 15, + ), + ), + ], + ), + ); + } +} + class _EmptyState extends ConsumerWidget { final bool isIncome; const _EmptyState({required this.isIncome}); @@ -455,33 +992,52 @@ class _EmptyState extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final s = ref.watch(stringsProvider); - return Center( + return Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.06), + ), + ), child: Column( - mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.pie_chart_outline_rounded, - size: 56, - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.3), + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: (_isIncomeColor(isIncome) ? AppColors.income : AppColors.expense) + .withOpacity(0.12), + ), + child: Icon( + Icons.analytics_rounded, + size: 34, + color: _isIncomeColor(isIncome) ? AppColors.income : AppColors.expense, + ), ), - const SizedBox(height: 16), + const SizedBox(height: 18), Text( - isIncome ? s.noIncomeData : s.noExpenseData, + s.noStatisticsYet, style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, ), ), const SizedBox(height: 8), Text( - isIncome ? s.addIncomeToSeeBreakdown : s.addExpensesToSeeBreakdown, + s.statisticsWillAppear, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.4), + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5), ), ), ], ), ); } + + bool _isIncomeColor(bool isIncome) { + return isIncome; + } }