mirror of
https://github.com/koloideal/Casha.git
synced 2026-08-08 17:51:14 +03:00
step
This commit is contained in:
@@ -1,44 +1,132 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../shared/models/transaction.dart';
|
||||
import '../dashboard/provider.dart';
|
||||
import '../settings/provider.dart';
|
||||
|
||||
String _statsTargetCurrency(Ref ref) {
|
||||
final index = ref.watch(activeAccountIndexProvider);
|
||||
final accountsAsync = ref.watch(accountsProvider);
|
||||
final globalCurrency = ref.watch(currencyProvider).code;
|
||||
|
||||
if (index > 0) {
|
||||
final accounts = accountsAsync.value ?? [];
|
||||
if (index <= accounts.length) {
|
||||
return accounts[index - 1].currency;
|
||||
}
|
||||
}
|
||||
|
||||
return globalCurrency;
|
||||
}
|
||||
|
||||
CurrencyInfo _statsCurrencyInfo(Ref ref) {
|
||||
final code = _statsTargetCurrency(ref);
|
||||
return CurrencyInfo(currencyMap[code]?.symbol ?? '\$', code);
|
||||
}
|
||||
|
||||
List<Transaction> _statsScopedTransactions(Ref ref) {
|
||||
final txs = ref.watch(accountFilteredTransactionsProvider);
|
||||
final timeFilter = ref.watch(timeFilterProvider);
|
||||
var filtered = txs.where((t) => t.category != 'Transfer');
|
||||
|
||||
if (timeFilter == TimeFilter.lastMonth) {
|
||||
final now = DateTime.now();
|
||||
filtered = filtered.where(
|
||||
(t) => t.date.year == now.year && t.date.month == now.month,
|
||||
);
|
||||
}
|
||||
|
||||
return filtered.toList();
|
||||
}
|
||||
|
||||
double _convertAmount(Ref ref, Transaction t) {
|
||||
final exchange = ref.watch(exchangeRateServiceProvider);
|
||||
return exchange.convert(
|
||||
t.amount,
|
||||
t.currencyCode,
|
||||
_statsTargetCurrency(ref),
|
||||
);
|
||||
}
|
||||
|
||||
final statsCurrencyProvider = Provider<CurrencyInfo>((ref) {
|
||||
return _statsCurrencyInfo(ref);
|
||||
});
|
||||
|
||||
final statsIncomeTotalProvider = Provider<double>((ref) {
|
||||
return _statsScopedTransactions(ref)
|
||||
.where((t) => t.type == TransactionType.income)
|
||||
.fold(0.0, (sum, t) => sum + _convertAmount(ref, t));
|
||||
});
|
||||
|
||||
final statsExpenseTotalProvider = Provider<double>((ref) {
|
||||
return _statsScopedTransactions(ref)
|
||||
.where((t) => t.type == TransactionType.expense)
|
||||
.fold(0.0, (sum, t) => sum + _convertAmount(ref, t));
|
||||
});
|
||||
|
||||
final categoryExpenseProvider = Provider<Map<String, double>>((ref) {
|
||||
final txsAsync = ref.watch(transactionsProvider);
|
||||
final txs = txsAsync.value ?? [];
|
||||
final filtered = txs.where((t) => t.type == TransactionType.expense);
|
||||
|
||||
final map = <String, double>{};
|
||||
for (final t in filtered) {
|
||||
map[t.category] = (map[t.category] ?? 0) + t.amount;
|
||||
for (final t in _statsScopedTransactions(ref)) {
|
||||
if (t.type != TransactionType.expense) continue;
|
||||
map[t.category] = (map[t.category] ?? 0) + _convertAmount(ref, t);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
final categoryIncomeProvider = Provider<Map<String, double>>((ref) {
|
||||
final txsAsync = ref.watch(transactionsProvider);
|
||||
final txs = txsAsync.value ?? [];
|
||||
final filtered = txs.where((t) => t.type == TransactionType.income);
|
||||
|
||||
final map = <String, double>{};
|
||||
for (final t in filtered) {
|
||||
map[t.category] = (map[t.category] ?? 0) + t.amount;
|
||||
for (final t in _statsScopedTransactions(ref)) {
|
||||
if (t.type != TransactionType.income) continue;
|
||||
map[t.category] = (map[t.category] ?? 0) + _convertAmount(ref, t);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
final monthlyBreakdownProvider = Provider<List<MonthlyData>>((ref) {
|
||||
final txsAsync = ref.watch(transactionsProvider);
|
||||
final txs = txsAsync.value ?? [];
|
||||
final filtered = txs.where((t) => t.type == TransactionType.expense);
|
||||
|
||||
final txs = ref.watch(accountFilteredTransactionsProvider);
|
||||
final exchange = ref.watch(exchangeRateServiceProvider);
|
||||
final target = _statsTargetCurrency(ref);
|
||||
final now = DateTime.now();
|
||||
final months = <MonthlyData>[];
|
||||
|
||||
for (var i = 5; i >= 0; i--) {
|
||||
final month = DateTime(now.year, now.month - i, 1);
|
||||
final total = filtered
|
||||
.where((t) => t.date.year == month.year && t.date.month == month.month)
|
||||
.fold(0.0, (sum, t) => sum + t.amount);
|
||||
final total = txs
|
||||
.where(
|
||||
(t) =>
|
||||
t.type == TransactionType.expense &&
|
||||
t.category != 'Transfer' &&
|
||||
t.date.year == month.year &&
|
||||
t.date.month == month.month,
|
||||
)
|
||||
.fold(0.0, (sum, t) {
|
||||
return sum + exchange.convert(t.amount, t.currencyCode, target);
|
||||
});
|
||||
months.add(MonthlyData(month: month, amount: total));
|
||||
}
|
||||
|
||||
return months;
|
||||
});
|
||||
|
||||
final monthlyIncomeBreakdownProvider = Provider<List<MonthlyData>>((ref) {
|
||||
final txs = ref.watch(accountFilteredTransactionsProvider);
|
||||
final exchange = ref.watch(exchangeRateServiceProvider);
|
||||
final target = _statsTargetCurrency(ref);
|
||||
final now = DateTime.now();
|
||||
final months = <MonthlyData>[];
|
||||
|
||||
for (var i = 5; i >= 0; i--) {
|
||||
final month = DateTime(now.year, now.month - i, 1);
|
||||
final total = txs
|
||||
.where(
|
||||
(t) =>
|
||||
t.type == TransactionType.income &&
|
||||
t.category != 'Transfer' &&
|
||||
t.date.year == month.year &&
|
||||
t.date.month == month.month,
|
||||
)
|
||||
.fold(0.0, (sum, t) {
|
||||
return sum + exchange.convert(t.amount, t.currencyCode, target);
|
||||
});
|
||||
months.add(MonthlyData(month: month, amount: total));
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,18 @@ 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';
|
||||
import '../../shared/utils/currency_utils.dart';
|
||||
import '../../core/services/haptic_service.dart';
|
||||
import '../../shared/providers/amount_format_provider.dart';
|
||||
import '../../shared/utils/currency_utils.dart';
|
||||
import '../../shared/widgets/byn_sign.dart';
|
||||
import '../dashboard/provider.dart';
|
||||
import '../dashboard/widgets/summary_row.dart';
|
||||
import '../settings/provider.dart';
|
||||
import 'provider.dart';
|
||||
import 'widgets/account_scope_chips.dart';
|
||||
import 'widgets/stats_hero_card.dart';
|
||||
|
||||
enum ChartType { pie, bar }
|
||||
|
||||
@@ -24,15 +30,27 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
|
||||
ChartType _chartType = ChartType.pie;
|
||||
bool _showIncome = false;
|
||||
|
||||
String _scopeLabel(AppStrings s) {
|
||||
final activeAccount = ref.watch(activeAccountProvider);
|
||||
if (activeAccount == null) return s.allAccounts;
|
||||
return activeAccount.name;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = ref.watch(stringsProvider);
|
||||
final data = _showIncome
|
||||
? ref.watch(categoryIncomeProvider)
|
||||
: ref.watch(categoryExpenseProvider);
|
||||
final monthlyData = ref.watch(monthlyBreakdownProvider);
|
||||
final monthlyData = _showIncome
|
||||
? ref.watch(monthlyIncomeBreakdownProvider)
|
||||
: ref.watch(monthlyBreakdownProvider);
|
||||
final total = data.values.fold(0.0, (a, b) => a + b);
|
||||
final currencyInfo = ref.watch(currencyProvider);
|
||||
final currencyInfo = ref.watch(statsCurrencyProvider);
|
||||
final income = ref.watch(statsIncomeTotalProvider);
|
||||
final expense = ref.watch(statsExpenseTotalProvider);
|
||||
final timeFilter = ref.watch(timeFilterProvider);
|
||||
final scopeLabel = _scopeLabel(s);
|
||||
|
||||
final sortedEntries = data.entries.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
@@ -41,7 +59,7 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
s.categories,
|
||||
s.statistics,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
@@ -57,87 +75,59 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const AccountScopeChips(),
|
||||
const SizedBox(height: 12),
|
||||
StatsHeroCard(
|
||||
amount: total,
|
||||
label: _showIncome ? s.income : s.expenses,
|
||||
accentColor: _showIncome ? AppColors.income : AppColors.expense,
|
||||
scopeLabel: scopeLabel,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SummaryRow(
|
||||
income: income,
|
||||
expense: expense,
|
||||
currencyInfo: currencyInfo,
|
||||
strings: s,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_StatsTimeFilter(
|
||||
strings: s,
|
||||
timeFilter: timeFilter,
|
||||
onAllTime: () =>
|
||||
ref.read(timeFilterProvider.notifier).set(TimeFilter.allTime),
|
||||
onMonth: () => ref
|
||||
.read(timeFilterProvider.notifier)
|
||||
.set(TimeFilter.lastMonth),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
child: _TypeToggle(
|
||||
label: s.expenses,
|
||||
isSelected: !_showIncome,
|
||||
color: AppColors.expense,
|
||||
onTap: () => setState(() {
|
||||
_showIncome = false;
|
||||
_touchedIndex = -1;
|
||||
}),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: !_showIncome
|
||||
? AppColors.accent
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: !_showIncome
|
||||
? AppColors.accent
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withOpacity(0.2),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
s.expenses,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: !_showIncome
|
||||
? Colors.white
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
child: _TypeToggle(
|
||||
label: s.income,
|
||||
isSelected: _showIncome,
|
||||
color: AppColors.income,
|
||||
onTap: () => setState(() {
|
||||
_showIncome = true;
|
||||
_touchedIndex = -1;
|
||||
}),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: _showIncome
|
||||
? AppColors.accent
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: _showIncome
|
||||
? AppColors.accent
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withOpacity(0.2),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
s.income,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _showIncome
|
||||
? Colors.white
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -155,12 +145,14 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
|
||||
total: total,
|
||||
touchedIndex: _touchedIndex,
|
||||
onTouch: (i) => setState(() => _touchedIndex = i),
|
||||
currency: currencyInfo.symbol,
|
||||
currencyInfo: currencyInfo,
|
||||
isIncome: _showIncome,
|
||||
)
|
||||
else
|
||||
_BarChartCard(
|
||||
monthlyData: monthlyData,
|
||||
currency: currencyInfo.symbol,
|
||||
currencyInfo: currencyInfo,
|
||||
isIncome: _showIncome,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
@@ -183,7 +175,7 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
|
||||
category: cat,
|
||||
amount: amount,
|
||||
total: total,
|
||||
currency: currencyInfo.symbol,
|
||||
currencyInfo: currencyInfo,
|
||||
isIncome: _showIncome,
|
||||
),
|
||||
);
|
||||
@@ -200,6 +192,140 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _StatsTimeFilter extends StatelessWidget {
|
||||
final AppStrings strings;
|
||||
final TimeFilter timeFilter;
|
||||
final VoidCallback onAllTime;
|
||||
final VoidCallback onMonth;
|
||||
|
||||
const _StatsTimeFilter({
|
||||
required this.strings,
|
||||
required this.timeFilter,
|
||||
required this.onAllTime,
|
||||
required this.onMonth,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
_TimeChip(
|
||||
label: strings.filterAllTime,
|
||||
isSelected: timeFilter == TimeFilter.allTime,
|
||||
isDark: isDark,
|
||||
onTap: () {
|
||||
HapticService.selection();
|
||||
onAllTime();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_TimeChip(
|
||||
label: strings.filterMonth,
|
||||
isSelected: timeFilter == TimeFilter.lastMonth,
|
||||
isDark: isDark,
|
||||
onTap: () {
|
||||
HapticService.selection();
|
||||
onMonth();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TimeChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final bool isDark;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _TimeChip({
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.isDark,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? AppColors.accent.withOpacity(0.2)
|
||||
: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: isSelected
|
||||
? Border.all(color: AppColors.accent, width: 1.5)
|
||||
: isDark
|
||||
? null
|
||||
: Border.all(color: const Color(0xFFDDDDEE), width: 1),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected
|
||||
? AppColors.accent
|
||||
: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TypeToggle extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _TypeToggle({
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? color : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? color
|
||||
: Theme.of(context).colorScheme.onSurface.withOpacity(0.2),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChartToggle extends StatelessWidget {
|
||||
final ChartType selected;
|
||||
final ValueChanged<ChartType> onChanged;
|
||||
@@ -207,10 +333,15 @@ class _ChartToggle extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: isDark
|
||||
? null
|
||||
: Border.all(color: const Color(0xFFDDDDEE), width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -270,14 +401,16 @@ class _PieChartCard extends ConsumerWidget {
|
||||
final double total;
|
||||
final int touchedIndex;
|
||||
final ValueChanged<int> onTouch;
|
||||
final String currency;
|
||||
final CurrencyInfo currencyInfo;
|
||||
final bool isIncome;
|
||||
|
||||
const _PieChartCard({
|
||||
required this.data,
|
||||
required this.total,
|
||||
required this.touchedIndex,
|
||||
required this.onTouch,
|
||||
required this.currency,
|
||||
required this.currencyInfo,
|
||||
required this.isIncome,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -285,12 +418,20 @@ class _PieChartCard extends ConsumerWidget {
|
||||
final s = ref.watch(stringsProvider);
|
||||
final fmt = ref.watch(amountFormatProvider);
|
||||
final entries = data.entries.toList();
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final accent = isIncome ? AppColors.income : AppColors.expense;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? Colors.white.withOpacity(0.08)
|
||||
: const Color(0xFFDDDDEE),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -327,10 +468,10 @@ class _PieChartCard extends ConsumerWidget {
|
||||
? '${(val / total * 100).toStringAsFixed(1)}%'
|
||||
: '',
|
||||
radius: isTouched ? 60 : 50,
|
||||
titleStyle: TextStyle(
|
||||
titleStyle: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
}),
|
||||
@@ -348,9 +489,9 @@ class _PieChartCard extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatAmount(currency, total, fmt),
|
||||
formatAmount(currencyInfo.symbol, total, fmt),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
color: accent,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
@@ -367,16 +508,24 @@ class _PieChartCard extends ConsumerWidget {
|
||||
|
||||
class _BarChartCard extends ConsumerWidget {
|
||||
final List<MonthlyData> monthlyData;
|
||||
final String currency;
|
||||
const _BarChartCard({required this.monthlyData, required this.currency});
|
||||
final CurrencyInfo currencyInfo;
|
||||
final bool isIncome;
|
||||
|
||||
const _BarChartCard({
|
||||
required this.monthlyData,
|
||||
required this.currencyInfo,
|
||||
required this.isIncome,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final s = ref.watch(stringsProvider);
|
||||
final fmt = ref.watch(amountFormatProvider);
|
||||
final maxY = monthlyData
|
||||
.map((e) => e.amount)
|
||||
.reduce((a, b) => a > b ? a : b);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final barColor = isIncome ? AppColors.income : AppColors.expense;
|
||||
final maxY = monthlyData.isEmpty
|
||||
? 100.0
|
||||
: monthlyData.map((e) => e.amount).reduce((a, b) => a > b ? a : b);
|
||||
final adjustedMaxY = maxY * 1.2;
|
||||
|
||||
return Container(
|
||||
@@ -384,6 +533,12 @@ class _BarChartCard extends ConsumerWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? Colors.white.withOpacity(0.08)
|
||||
: const Color(0xFFDDDDEE),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -406,9 +561,9 @@ class _BarChartCard extends ConsumerWidget {
|
||||
touchTooltipData: BarTouchTooltipData(
|
||||
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
||||
return BarTooltipItem(
|
||||
formatAmount(currency, rod.toY, fmt),
|
||||
TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
formatAmount(currencyInfo.symbol, rod.toY, fmt),
|
||||
const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
@@ -469,7 +624,7 @@ class _BarChartCard extends ConsumerWidget {
|
||||
barRods: [
|
||||
BarChartRodData(
|
||||
toY: monthlyData[i].amount,
|
||||
color: AppColors.accent,
|
||||
color: barColor,
|
||||
width: 24,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(6),
|
||||
@@ -492,14 +647,15 @@ class _CategoryRow extends ConsumerWidget {
|
||||
final String category;
|
||||
final double amount;
|
||||
final double total;
|
||||
final String currency;
|
||||
final CurrencyInfo currencyInfo;
|
||||
final bool isIncome;
|
||||
|
||||
const _CategoryRow({
|
||||
required this.rank,
|
||||
required this.category,
|
||||
required this.amount,
|
||||
required this.total,
|
||||
required this.currency,
|
||||
required this.currencyInfo,
|
||||
required this.isIncome,
|
||||
});
|
||||
|
||||
@@ -507,6 +663,7 @@ class _CategoryRow extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final s = ref.watch(stringsProvider);
|
||||
final fmt = ref.watch(amountFormatProvider);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final color = AppCategories.colors[category] ?? AppColors.accent;
|
||||
final icon = AppCategories.icons[category] ?? Icons.category_rounded;
|
||||
final pct = total > 0 ? amount / total : 0.0;
|
||||
@@ -516,6 +673,12 @@ class _CategoryRow extends ConsumerWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? Colors.white.withOpacity(0.08)
|
||||
: const Color(0xFFDDDDEE),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -565,13 +728,39 @@ class _CategoryRow extends ConsumerWidget {
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
formatAmount(currency, amount, fmt),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: isIncome ? AppColors.income : AppColors.expense,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
currencyInfo.code == 'BYN'
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
BynSign(
|
||||
fontSize: 14,
|
||||
color: isIncome
|
||||
? AppColors.income
|
||||
: AppColors.expense,
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
formatAmount('', amount, fmt),
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: isIncome
|
||||
? AppColors.income
|
||||
: AppColors.expense,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Text(
|
||||
formatAmount(currencyInfo.symbol, amount, fmt),
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: isIncome
|
||||
? AppColors.income
|
||||
: AppColors.expense,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${(pct * 100).toStringAsFixed(1)}%',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
@@ -607,6 +796,8 @@ class _EmptyState extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final s = ref.watch(stringsProvider);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -616,6 +807,12 @@ class _EmptyState extends ConsumerWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? Colors.white.withOpacity(0.08)
|
||||
: const Color(0xFFDDDDEE),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.pie_chart_outline_rounded,
|
||||
@@ -634,6 +831,7 @@ class _EmptyState extends ConsumerWidget {
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
isIncome ? s.addIncomeToSeeBreakdown : s.addExpensesToSeeBreakdown,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/constants.dart';
|
||||
import '../../../core/l10n/locale_provider.dart';
|
||||
import '../../../core/services/haptic_service.dart';
|
||||
import '../../dashboard/provider.dart';
|
||||
|
||||
class AccountScopeChips extends ConsumerWidget {
|
||||
const AccountScopeChips({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final s = ref.watch(stringsProvider);
|
||||
final accountsAsync = ref.watch(accountsProvider);
|
||||
final activeIndex = ref.watch(activeAccountIndexProvider);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return accountsAsync.when(
|
||||
data: (accounts) {
|
||||
if (accounts.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
clipBehavior: Clip.none,
|
||||
child: Row(
|
||||
children: [
|
||||
_ScopeChip(
|
||||
label: s.allAccounts,
|
||||
isSelected: activeIndex == 0,
|
||||
isDark: isDark,
|
||||
onTap: () {
|
||||
ref.read(activeAccountIndexProvider.notifier).set(0);
|
||||
HapticService.selection();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
...accounts.asMap().entries.map((entry) {
|
||||
final index = entry.key + 1;
|
||||
final account = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: _ScopeChip(
|
||||
label: account.name,
|
||||
isSelected: activeIndex == index,
|
||||
isDark: isDark,
|
||||
onTap: () {
|
||||
ref.read(activeAccountIndexProvider.notifier).set(index);
|
||||
HapticService.selection();
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScopeChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final bool isDark;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ScopeChip({
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.isDark,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? AppColors.accent.withOpacity(0.2)
|
||||
: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: isSelected
|
||||
? Border.all(color: AppColors.accent, width: 1.5)
|
||||
: isDark
|
||||
? null
|
||||
: Border.all(color: const Color(0xFFDDDDEE), width: 1),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected
|
||||
? AppColors.accent
|
||||
: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/constants.dart';
|
||||
import '../../../core/services/card_color_service.dart';
|
||||
import '../../../shared/providers/amount_format_provider.dart';
|
||||
import '../../../shared/utils/card_gradient.dart';
|
||||
import '../../../shared/utils/currency_utils.dart';
|
||||
import '../../../shared/widgets/byn_sign.dart';
|
||||
import '../../dashboard/provider.dart';
|
||||
import '../../settings/provider.dart';
|
||||
import '../provider.dart';
|
||||
|
||||
class StatsHeroCard extends ConsumerWidget {
|
||||
final double amount;
|
||||
final String label;
|
||||
final Color accentColor;
|
||||
final String scopeLabel;
|
||||
|
||||
const StatsHeroCard({
|
||||
super.key,
|
||||
required this.amount,
|
||||
required this.label,
|
||||
required this.accentColor,
|
||||
required this.scopeLabel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fmt = ref.watch(amountFormatProvider);
|
||||
final currencyInfo = ref.watch(statsCurrencyProvider);
|
||||
final brightness = Theme.of(context).brightness;
|
||||
final activeAccount = ref.watch(activeAccountProvider);
|
||||
final globalColors = ref.watch(cardColorsProvider);
|
||||
|
||||
final CardColors colors;
|
||||
if (activeAccount != null) {
|
||||
colors = ref.watch(accountCardColorsProvider(activeAccount.id));
|
||||
} else {
|
||||
colors = globalColors;
|
||||
}
|
||||
|
||||
final primary = Color.lerp(colors.primary, accentColor, 0.35)!;
|
||||
final secondary = Color.lerp(colors.secondary, accentColor, 0.2)!;
|
||||
final gradientType = colors.gradientTypeForBrightness(brightness);
|
||||
final onCard = primary.computeLuminance() > 0.35
|
||||
? Colors.black
|
||||
: Colors.white;
|
||||
|
||||
return Container(
|
||||
height: 128,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
gradient: buildCardGradient(primary, secondary, gradientType),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.35),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: onCard.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
scopeLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: onCard.withOpacity(0.85),
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.2,
|
||||
color: onCard.withOpacity(0.65),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: currencyInfo.code == 'BYN'
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
BynSign(fontSize: 28, color: onCard),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
formatAmount('', amount, fmt),
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: onCard,
|
||||
),
|
||||
maxLines: 1,
|
||||
),
|
||||
],
|
||||
)
|
||||
: Text(
|
||||
formatAmount(currencyInfo.symbol, amount, fmt),
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: onCard,
|
||||
),
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user