This commit is contained in:
2026-06-26 02:00:42 +03:00
parent 83fd8bdbf1
commit ed45748c95
4 changed files with 893 additions and 266 deletions
+4
View File
@@ -34,6 +34,7 @@ android {
versionName = flutter.versionName versionName = flutter.versionName
} }
if (keyPropertiesFile.exists()) {
signingConfigs { signingConfigs {
create("release") { create("release") {
keyAlias = keyProperties["keyAlias"] as String keyAlias = keyProperties["keyAlias"] as String
@@ -42,13 +43,16 @@ android {
storePassword = keyProperties["storePassword"] as String storePassword = keyProperties["storePassword"] as String
} }
} }
}
buildTypes { buildTypes {
release { release {
if (keyPropertiesFile.exists()) {
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
} }
} }
} }
}
flutter { flutter {
source = "../.." source = "../.."
+14
View File
@@ -122,6 +122,20 @@ class AppStrings {
String get allAccounts => _ru ? 'Все счета' : 'All accounts'; String get allAccounts => _ru ? 'Все счета' : 'All accounts';
String get categories => _ru ? 'Категории' : 'Categories'; String get categories => _ru ? 'Категории' : 'Categories';
String get rankedByAmount => _ru ? 'По сумме' : 'Ranked by Amount'; 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 addCategory => _ru ? 'Добавить категорию' : 'Add Category';
String get editCategory => _ru ? 'Редактировать' : 'Edit Category'; String get editCategory => _ru ? 'Редактировать' : 'Edit Category';
String get categoryName => _ru ? 'Название' : 'Name'; String get categoryName => _ru ? 'Название' : 'Name';
+57 -4
View File
@@ -3,6 +3,24 @@ import '../../shared/models/transaction.dart';
import '../dashboard/provider.dart'; import '../dashboard/provider.dart';
import '../settings/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) { String _statsTargetCurrency(Ref ref) {
final index = ref.watch(activeAccountIndexProvider); final index = ref.watch(activeAccountIndexProvider);
final accountsAsync = ref.watch(accountsProvider); final accountsAsync = ref.watch(accountsProvider);
@@ -51,21 +69,56 @@ final statsCurrencyProvider = Provider<CurrencyInfo>((ref) {
return _statsCurrencyInfo(ref); return _statsCurrencyInfo(ref);
}); });
final statsScopedTransactionsProvider = Provider<List<Transaction>>((ref) {
return _statsScopedTransactions(ref);
});
final statsIncomeTotalProvider = Provider<double>((ref) { final statsIncomeTotalProvider = Provider<double>((ref) {
return _statsScopedTransactions(ref) return ref
.watch(statsScopedTransactionsProvider)
.where((t) => t.type == TransactionType.income) .where((t) => t.type == TransactionType.income)
.fold(0.0, (sum, t) => sum + _convertAmount(ref, t)); .fold(0.0, (sum, t) => sum + _convertAmount(ref, t));
}); });
final statsExpenseTotalProvider = Provider<double>((ref) { final statsExpenseTotalProvider = Provider<double>((ref) {
return _statsScopedTransactions(ref) return ref
.watch(statsScopedTransactionsProvider)
.where((t) => t.type == TransactionType.expense) .where((t) => t.type == TransactionType.expense)
.fold(0.0, (sum, t) => sum + _convertAmount(ref, t)); .fold(0.0, (sum, t) => sum + _convertAmount(ref, t));
}); });
final statsSummaryProvider = Provider<StatsSummary>((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<Map<String, double>>((ref) { final categoryExpenseProvider = Provider<Map<String, double>>((ref) {
final map = <String, double>{}; final map = <String, double>{};
for (final t in _statsScopedTransactions(ref)) { for (final t in ref.watch(statsScopedTransactionsProvider)) {
if (t.type != TransactionType.expense) continue; if (t.type != TransactionType.expense) continue;
map[t.category] = (map[t.category] ?? 0) + _convertAmount(ref, t); map[t.category] = (map[t.category] ?? 0) + _convertAmount(ref, t);
} }
@@ -74,7 +127,7 @@ final categoryExpenseProvider = Provider<Map<String, double>>((ref) {
final categoryIncomeProvider = Provider<Map<String, double>>((ref) { final categoryIncomeProvider = Provider<Map<String, double>>((ref) {
final map = <String, double>{}; final map = <String, double>{};
for (final t in _statsScopedTransactions(ref)) { for (final t in ref.watch(statsScopedTransactionsProvider)) {
if (t.type != TransactionType.income) continue; if (t.type != TransactionType.income) continue;
map[t.category] = (map[t.category] ?? 0) + _convertAmount(ref, t); map[t.category] = (map[t.category] ?? 0) + _convertAmount(ref, t);
} }
+659 -103
View File
@@ -1,6 +1,7 @@
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../core/constants.dart'; import '../../core/constants.dart';
import '../../core/l10n/app_strings.dart'; import '../../core/l10n/app_strings.dart';
import '../../core/l10n/locale_provider.dart'; import '../../core/l10n/locale_provider.dart';
@@ -12,6 +13,7 @@ import '../dashboard/provider.dart';
import '../settings/provider.dart'; import '../settings/provider.dart';
import 'provider.dart'; import 'provider.dart';
import 'widgets/account_scope_chips.dart'; import 'widgets/account_scope_chips.dart';
import 'widgets/stats_hero_card.dart';
class CategoriesScreen extends ConsumerStatefulWidget { class CategoriesScreen extends ConsumerStatefulWidget {
const CategoriesScreen({super.key}); const CategoriesScreen({super.key});
@@ -27,16 +29,23 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final s = ref.watch(stringsProvider); final s = ref.watch(stringsProvider);
final summary = ref.watch(statsSummaryProvider);
final data = _showIncome final data = _showIncome
? ref.watch(categoryIncomeProvider) ? ref.watch(categoryIncomeProvider)
: ref.watch(categoryExpenseProvider); : ref.watch(categoryExpenseProvider);
final total = data.values.fold(0.0, (a, b) => a + b); final total = data.values.fold(0.0, (a, b) => a + b);
final currencyInfo = ref.watch(statsCurrencyProvider); final currencyInfo = ref.watch(statsCurrencyProvider);
final timeFilter = ref.watch(timeFilterProvider); final timeFilter = ref.watch(timeFilterProvider);
final monthlyData = _showIncome
? ref.watch(monthlyIncomeBreakdownProvider)
: ref.watch(monthlyBreakdownProvider);
final scopeLabel = _scopeLabel(s);
final sortedEntries = data.entries.toList() final sortedEntries = data.entries.toList()
..sort((a, b) => b.value.compareTo(a.value)); ..sort((a, b) => b.value.compareTo(a.value));
final topEntry = sortedEntries.isEmpty ? null : sortedEntries.first;
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor, backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar( appBar: AppBar(
@@ -49,13 +58,14 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
), ),
), ),
body: SafeArea( body: SafeArea(
child: Padding( child: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 0), padding: const EdgeInsets.fromLTRB(20, 8, 20, 100),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const AccountScopeChips(), const AccountScopeChips(),
const SizedBox(height: 16), const SizedBox(height: 16),
_FilterCard(
child: Column(
children: [
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -68,7 +78,7 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
}, },
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 10),
Expanded( Expanded(
child: _TimeFilterChip( child: _TimeFilterChip(
label: s.filterMonth, label: s.filterMonth,
@@ -81,13 +91,14 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 10),
Row( Row(
children: [ children: [
Expanded( Expanded(
child: _TypeSegment( child: _TypeSegment(
label: s.expenses, label: s.expenses,
isSelected: !_showIncome, isSelected: !_showIncome,
color: AppColors.expense,
onTap: () { onTap: () {
HapticService.selection(); HapticService.selection();
setState(() { setState(() {
@@ -97,11 +108,12 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
}, },
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 10),
Expanded( Expanded(
child: _TypeSegment( child: _TypeSegment(
label: s.income, label: s.income,
isSelected: _showIncome, isSelected: _showIncome,
color: AppColors.income,
onTap: () { onTap: () {
HapticService.selection(); HapticService.selection();
setState(() { setState(() {
@@ -113,14 +125,75 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
), ),
], ],
), ),
const SizedBox(height: 20), ],
if (data.isEmpty) ),
Expanded(child: _EmptyState(isIncome: _showIncome)) ),
else const SizedBox(height: 18),
Expanded( StatsHeroCard(
child: ListView( 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: [ children: [
_PieChartSection( _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, data: data,
total: total, total: total,
touchedIndex: _touchedIndex, touchedIndex: _touchedIndex,
@@ -128,21 +201,27 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
currencyInfo: currencyInfo, currencyInfo: currencyInfo,
isIncome: _showIncome, isIncome: _showIncome,
), ),
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(height: 16), 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) { ...sortedEntries.map((entry) {
final cat = entry.key; final cat = entry.key;
final amount = entry.value; final amount = entry.value;
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: 10),
child: _CategoryItem( child: _CategoryItem(
category: cat, category: cat,
amount: amount, amount: amount,
@@ -152,13 +231,86 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
), ),
); );
}), }),
const SizedBox(height: 80),
], ],
), ),
), ),
], ],
],
), ),
), ),
);
}
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,
],
), ),
); );
} }
@@ -179,13 +331,19 @@ class _TimeFilterChip extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
onTap: onTap, onTap: onTap,
child: Container( child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected
? AppColors.accent ? AppColors.accent
: Theme.of(context).colorScheme.surface, : 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( child: Text(
label, label,
@@ -206,11 +364,13 @@ class _TimeFilterChip extends StatelessWidget {
class _TypeSegment extends StatelessWidget { class _TypeSegment extends StatelessWidget {
final String label; final String label;
final bool isSelected; final bool isSelected;
final Color color;
final VoidCallback onTap; final VoidCallback onTap;
const _TypeSegment({ const _TypeSegment({
required this.label, required this.label,
required this.isSelected, required this.isSelected,
required this.color,
required this.onTap, required this.onTap,
}); });
@@ -218,33 +378,32 @@ class _TypeSegment extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
onTap: onTap, onTap: onTap,
child: Container( child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? color : Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected color: isSelected
? (_showIncomeColor(label) ? AppColors.income : AppColors.expense) ? color
: Colors.transparent, : Theme.of(context).colorScheme.onSurface.withOpacity(0.06),
borderRadius: BorderRadius.circular(12), ),
), ),
child: Text( child: Text(
label, label,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
color: isSelected color: isSelected
? Colors.white ? 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 { class _PieChartSection extends ConsumerWidget {
@@ -270,9 +429,14 @@ class _PieChartSection extends ConsumerWidget {
final fmt = ref.watch(amountFormatProvider); final fmt = ref.watch(amountFormatProvider);
final entries = data.entries.toList(); final entries = data.entries.toList();
final accent = isIncome ? AppColors.income : AppColors.expense; 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( return Column(
height: 240, children: [
SizedBox(
height: 260,
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
@@ -289,21 +453,20 @@ class _PieChartSection extends ConsumerWidget {
onTouch(response.touchedSection!.touchedSectionIndex); onTouch(response.touchedSection!.touchedSectionIndex);
}, },
), ),
sectionsSpace: 2, sectionsSpace: 4,
centerSpaceRadius: 70, centerSpaceRadius: 78,
sections: List.generate(entries.length, (i) { sections: List.generate(entries.length, (i) {
final isTouched = i == touchedIndex; final isTouched = i == touchedIndex;
final cat = entries[i].key; final cat = entries[i].key;
final val = entries[i].value; final val = entries[i].value;
final color = final color = AppCategories.colors[cat] ?? AppColors.accent;
AppCategories.colors[cat] ?? AppColors.accent;
return PieChartSectionData( return PieChartSectionData(
color: color, color: color,
value: val, value: val,
title: isTouched title: isTouched
? '${(val / total * 100).toStringAsFixed(0)}%' ? '${(val / total * 100).toStringAsFixed(0)}%'
: '', : '',
radius: isTouched ? 55 : 48, radius: isTouched ? 60 : 52,
titleStyle: const TextStyle( titleStyle: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@@ -313,8 +476,15 @@ class _PieChartSection extends ConsumerWidget {
}), }),
), ),
), ),
Column( Container(
mainAxisSize: MainAxisSize.min, width: 136,
height: 136,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).scaffoldBackgroundColor,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text( Text(
s.total, s.total,
@@ -323,38 +493,142 @@ class _PieChartSection extends ConsumerWidget {
fontSize: 11, fontSize: 11,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 6),
currencyInfo.code == 'BYN' _FormattedAmount(
? Row( amount: total,
mainAxisSize: MainAxisSize.min, currencyInfo: currencyInfo,
crossAxisAlignment: CrossAxisAlignment.center, 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: [ children: [
BynSign( Container(
fontSize: 24, width: 12,
color: accent, 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,
),
),
), ),
const SizedBox(width: 2),
Text( Text(
formatAmount('', total, fmt), '${(selectedAmount / total * 100).toStringAsFixed(1)}%',
style: Theme.of(context).textTheme.titleLarge?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: accent,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
fontSize: 24,
),
),
],
)
: Text(
formatAmount(currencyInfo.symbol, total, fmt),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: accent, color: accent,
fontWeight: FontWeight.w700,
fontSize: 24,
), ),
), ),
], ],
), ),
),
], ],
);
}
}
class _MonthlyTrendSection extends StatelessWidget {
final List<MonthlyData> 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,13 +657,21 @@ class _CategoryItem extends ConsumerWidget {
final icon = AppCategories.icons[category] ?? Icons.category_rounded; final icon = AppCategories.icons[category] ?? Icons.category_rounded;
final pct = total > 0 ? amount / total : 0.0; final pct = total > 0 ? amount / total : 0.0;
return Row( 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: [ children: [
Container( Container(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withOpacity(0.12), color: color.withOpacity(0.12),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(12),
), ),
child: Icon(icon, color: color, size: 20), child: Icon(icon, color: color, size: 20),
), ),
@@ -401,7 +683,7 @@ class _CategoryItem extends ConsumerWidget {
Text( Text(
s.categoryLabel(category), s.categoryLabel(category),
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
@@ -416,33 +698,288 @@ class _CategoryItem extends ConsumerWidget {
], ],
), ),
), ),
currencyInfo.code == 'BYN' _FormattedAmount(
? Row( amount: amount,
mainAxisSize: MainAxisSize.min, currencyInfo: currencyInfo,
crossAxisAlignment: CrossAxisAlignment.center, 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>(color),
),
),
],
),
);
}
}
class _SummaryBadgeRow extends ConsumerWidget {
final bool isIncome;
final CurrencyInfo currencyInfo;
final MapEntry<String, double>? 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: [ children: [
BynSign( Expanded(
fontSize: 15, child: _MiniBadge(
title: isIncome ? s.averageIncome : s.averageExpense,
child: _FormattedAmount(
amount: average,
currencyInfo: currencyInfo,
color: isIncome ? AppColors.income : AppColors.expense, color: isIncome ? AppColors.income : AppColors.expense,
fontSize: 13,
fontWeight: FontWeight.w700,
format: fmt,
), ),
const SizedBox(width: 2), ),
),
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( Text(
formatAmount('', amount, fmt), title,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: isIncome ? AppColors.income : AppColors.expense, 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, 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,
), ),
), ),
], ],
)
: Text(
formatAmount(currencyInfo.symbol, amount, fmt),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isIncome ? AppColors.income : AppColors.expense,
fontWeight: FontWeight.w700,
), ),
),
],
); );
} }
} }
@@ -455,33 +992,52 @@ class _EmptyState extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final s = ref.watch(stringsProvider); final s = ref.watch(stringsProvider);
return Center( return Container(
child: Column( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
mainAxisAlignment: MainAxisAlignment.center, decoration: BoxDecoration(
children: [ color: Theme.of(context).colorScheme.surface,
Icon( borderRadius: BorderRadius.circular(24),
Icons.pie_chart_outline_rounded, border: Border.all(
size: 56, color: Theme.of(context).colorScheme.onSurface.withOpacity(0.06),
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.3),
), ),
const SizedBox(height: 16), ),
child: Column(
children: [
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: 18),
Text( Text(
isIncome ? s.noIncomeData : s.noExpenseData, s.noStatisticsYet,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
isIncome ? s.addIncomeToSeeBreakdown : s.addExpensesToSeeBreakdown, s.statisticsWillAppear,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( 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;
}
} }