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
}
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 = "../.."
+14
View File
@@ -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';
+57 -4
View File
@@ -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
View File
@@ -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;
}
}