mirror of
https://github.com/koloideal/Casha.git
synced 2026-08-08 09:41:13 +03:00
step
This commit is contained in:
@@ -34,6 +34,7 @@ android {
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
if (keyPropertiesFile.exists()) {
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
keyAlias = keyProperties["keyAlias"] as String
|
||||
@@ -42,13 +43,16 @@ android {
|
||||
storePassword = keyProperties["storePassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
if (keyPropertiesFile.exists()) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<CurrencyInfo>((ref) {
|
||||
return _statsCurrencyInfo(ref);
|
||||
});
|
||||
|
||||
final statsScopedTransactionsProvider = Provider<List<Transaction>>((ref) {
|
||||
return _statsScopedTransactions(ref);
|
||||
});
|
||||
|
||||
final statsIncomeTotalProvider = Provider<double>((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<double>((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<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 map = <String, double>{};
|
||||
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<Map<String, double>>((ref) {
|
||||
|
||||
final categoryIncomeProvider = Provider<Map<String, double>>((ref) {
|
||||
final map = <String, double>{};
|
||||
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);
|
||||
}
|
||||
|
||||
+659
-103
@@ -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<CategoriesScreen> {
|
||||
@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,13 +58,14 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 100),
|
||||
children: [
|
||||
const AccountScopeChips(),
|
||||
const SizedBox(height: 16),
|
||||
_FilterCard(
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -68,7 +78,7 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _TimeFilterChip(
|
||||
label: s.filterMonth,
|
||||
@@ -81,13 +91,14 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TypeSegment(
|
||||
label: s.expenses,
|
||||
isSelected: !_showIncome,
|
||||
color: AppColors.expense,
|
||||
onTap: () {
|
||||
HapticService.selection();
|
||||
setState(() {
|
||||
@@ -97,11 +108,12 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _TypeSegment(
|
||||
label: s.income,
|
||||
isSelected: _showIncome,
|
||||
color: AppColors.income,
|
||||
onTap: () {
|
||||
HapticService.selection();
|
||||
setState(() {
|
||||
@@ -113,14 +125,75 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (data.isEmpty)
|
||||
Expanded(child: _EmptyState(isIncome: _showIncome))
|
||||
else
|
||||
Expanded(
|
||||
child: ListView(
|
||||
],
|
||||
),
|
||||
),
|
||||
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: [
|
||||
_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,
|
||||
total: total,
|
||||
touchedIndex: _touchedIndex,
|
||||
@@ -128,21 +201,27 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
|
||||
currencyInfo: currencyInfo,
|
||||
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),
|
||||
_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: 8),
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _CategoryItem(
|
||||
category: cat,
|
||||
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) {
|
||||
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 ? color : Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? (_showIncomeColor(label) ? AppColors.income : AppColors.expense)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
? 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,9 +429,14 @@ 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,
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 260,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
@@ -289,21 +453,20 @@ class _PieChartSection extends ConsumerWidget {
|
||||
onTouch(response.touchedSection!.touchedSectionIndex);
|
||||
},
|
||||
),
|
||||
sectionsSpace: 2,
|
||||
centerSpaceRadius: 70,
|
||||
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;
|
||||
final color = AppCategories.colors[cat] ?? AppColors.accent;
|
||||
return PieChartSectionData(
|
||||
color: color,
|
||||
value: val,
|
||||
title: isTouched
|
||||
? '${(val / total * 100).toStringAsFixed(0)}%'
|
||||
: '',
|
||||
radius: isTouched ? 55 : 48,
|
||||
radius: isTouched ? 60 : 52,
|
||||
titleStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
@@ -313,8 +476,15 @@ class _PieChartSection extends ConsumerWidget {
|
||||
}),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
Container(
|
||||
width: 136,
|
||||
height: 136,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
s.total,
|
||||
@@ -323,38 +493,142 @@ class _PieChartSection extends ConsumerWidget {
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
currencyInfo.code == 'BYN'
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
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: [
|
||||
BynSign(
|
||||
fontSize: 24,
|
||||
color: accent,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
formatAmount('', total, fmt),
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: accent,
|
||||
'${(selectedAmount / total * 100).toStringAsFixed(1)}%',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 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: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 20),
|
||||
),
|
||||
@@ -401,7 +683,7 @@ class _CategoryItem extends ConsumerWidget {
|
||||
Text(
|
||||
s.categoryLabel(category),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
@@ -416,33 +698,288 @@ class _CategoryItem extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
currencyInfo.code == 'BYN'
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
_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>(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: [
|
||||
BynSign(
|
||||
fontSize: 15,
|
||||
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,
|
||||
),
|
||||
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(
|
||||
formatAmount('', amount, fmt),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: isIncome ? AppColors.income : AppColors.expense,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: 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) {
|
||||
final s = ref.watch(stringsProvider);
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.pie_chart_outline_rounded,
|
||||
size: 56,
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.3),
|
||||
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),
|
||||
),
|
||||
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(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user