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
|
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 = "../.."
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user