big
This commit is contained in:
2026-06-28 02:19:57 +03:00
parent ed45748c95
commit bc51609f85
26 changed files with 1757 additions and 1525 deletions
+5 -2
View File
@@ -1,6 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/constants.dart';
import '../../shared/models/app_category.dart';
import '../../shared/models/transaction.dart';
import '../../shared/providers/category_provider.dart';
class AddTransactionState {
final double? amount;
@@ -133,10 +135,11 @@ class AddTransactionNotifier extends Notifier<AddTransactionState> {
}
final availableCategoriesProvider = Provider.autoDispose
.family<List<String>, Transaction?>((ref, initial) {
.family<List<AppCategory>, Transaction?>((ref, initial) {
final type = ref.watch(
addTransactionProvider(initial).select((s) => s.type),
);
return AppCategories.forType(type);
if (type == TransactionType.transfer) return const [];
return ref.watch(categoryCatalogProvider).forType(type);
});
@@ -1,10 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/constants.dart';
import '../../../core/l10n/app_strings.dart';
import '../../../core/l10n/locale_provider.dart';
import '../../../core/services/haptic_service.dart';
import '../../../shared/models/app_category.dart';
class CategoryPicker extends ConsumerWidget {
final List<String> categories;
final List<AppCategory> categories;
final String selected;
final ValueChanged<String> onChanged;
@@ -18,61 +22,109 @@ class CategoryPicker extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final s = ref.watch(stringsProvider);
final isRu = s.locale == AppLocale.ru;
final isDark = Theme.of(context).brightness == Brightness.dark;
return Wrap(
spacing: 8,
runSpacing: 8,
children: categories.map((cat) {
final isSelected = cat == selected;
final color = AppCategories.colors[cat] ?? AppColors.accent;
final icon = AppCategories.icons[cat] ?? Icons.category_rounded;
return GestureDetector(
onTap: () => onChanged(cat),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: isSelected
? color.withOpacity(0.2)
: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: isSelected
? Border.all(color: color, width: 1.5)
: (isDark
? null
: Border.all(color: const Color(0xFFDDDDEE), width: 1)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: isSelected
? color
: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
size: 16,
),
const SizedBox(width: 6),
Text(
s.categoryLabel(cat),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
children: [
...categories.map((cat) {
final isSelected = cat.key == selected;
final color = cat.color;
return GestureDetector(
onTap: () {
HapticService.light();
onChanged(cat.key);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: isSelected
? color.withOpacity(0.2)
: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: isSelected
? Border.all(color: color, width: 1.5)
: (isDark
? null
: Border.all(
color: const Color(0xFFDDDDEE), width: 1)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
cat.icon,
color: isSelected
? color
: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
size: 16,
),
),
],
const SizedBox(width: 6),
Text(
cat.label(isRu),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: isSelected
? color
: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
),
),
],
),
),
),
);
}).toList(),
);
}),
_AddCategoryChip(label: s.addCategory),
],
);
}
}
class _AddCategoryChip extends StatelessWidget {
final String label;
const _AddCategoryChip({required this.label});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
HapticService.light();
context.push('/settings/categories');
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppColors.accent.withOpacity(0.12),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.accent.withOpacity(0.5),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.add_rounded, color: AppColors.accent, size: 16),
const SizedBox(width: 6),
Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.accent,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}
+2 -197
View File
@@ -3,17 +3,13 @@ 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 '../../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 '../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});
@@ -24,7 +20,7 @@ class CategoriesScreen extends ConsumerStatefulWidget {
class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
int _touchedIndex = -1;
bool _showIncome = false;
final bool _showIncome = false;
@override
Widget build(BuildContext context) {
@@ -35,11 +31,9 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
: 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));
@@ -63,79 +57,6 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
children: [
const AccountScopeChips(),
const SizedBox(height: 16),
_FilterCard(
child: Column(
children: [
Row(
children: [
Expanded(
child: _TimeFilterChip(
label: s.filterAllTime,
isSelected: timeFilter == TimeFilter.allTime,
onTap: () {
HapticService.selection();
ref.read(timeFilterProvider.notifier).set(TimeFilter.allTime);
},
),
),
const SizedBox(width: 10),
Expanded(
child: _TimeFilterChip(
label: s.filterMonth,
isSelected: timeFilter == TimeFilter.lastMonth,
onTap: () {
HapticService.selection();
ref.read(timeFilterProvider.notifier).set(TimeFilter.lastMonth);
},
),
),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _TypeSegment(
label: s.expenses,
isSelected: !_showIncome,
color: AppColors.expense,
onTap: () {
HapticService.selection();
setState(() {
_showIncome = false;
_touchedIndex = -1;
});
},
),
),
const SizedBox(width: 10),
Expanded(
child: _TypeSegment(
label: s.income,
isSelected: _showIncome,
color: AppColors.income,
onTap: () {
HapticService.selection();
setState(() {
_showIncome = true;
_touchedIndex = -1;
});
},
),
),
],
),
],
),
),
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,
@@ -145,7 +66,7 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 1.55,
childAspectRatio: 1.22,
children: [
_MetricTile(
label: s.income,
@@ -240,32 +161,6 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
),
);
}
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 {
@@ -316,96 +211,6 @@ class _InsightCard extends StatelessWidget {
}
}
class _TimeFilterChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _TimeFilterChip({
required this.label,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
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(16),
border: Border.all(
color: isSelected
? AppColors.accent
: Theme.of(context).colorScheme.onSurface.withOpacity(0.06),
),
),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isSelected
? Colors.white
: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
),
),
),
);
}
}
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,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
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
? color
: Theme.of(context).colorScheme.onSurface.withOpacity(0.06),
),
),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: isSelected
? Colors.white
: Theme.of(context).colorScheme.onSurface.withOpacity(0.65),
),
),
),
);
}
}
class _PieChartSection extends ConsumerWidget {
final Map<String, double> data;
final double total;
@@ -1,138 +0,0 @@
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,
),
),
],
),
],
),
),
);
}
}
-30
View File
@@ -273,36 +273,6 @@ final totalExpenseProvider = Provider<double>((ref) {
});
});
final currentMonthExpenseProvider = Provider<double>((ref) {
final now = DateTime.now();
final txs = ref.watch(accountFilteredTransactionsProvider);
final filtered = txs.where(
(t) =>
t.type == TransactionType.expense &&
t.date.year == now.year &&
t.date.month == now.month,
);
final index = ref.watch(activeAccountIndexProvider);
final accountsAsync = ref.watch(accountsProvider);
final globalCurrency = ref.watch(currencyProvider).code;
String targetCurrency = globalCurrency;
if (index > 0) {
final accounts = accountsAsync.value ?? [];
if (index <= accounts.length) {
targetCurrency = accounts[index - 1].currency;
}
}
final exchangeService = ref.watch(exchangeRateServiceProvider);
return filtered.fold(0.0, (sum, t) {
return sum +
exchangeService.convert(t.amount, t.currencyCode, targetCurrency);
});
});
final filteredTransactionsProvider = Provider<List<Transaction>>((ref) {
final txs = ref.watch(accountFilteredTransactionsProvider);
final query = ref.watch(searchQueryProvider).toLowerCase();
-12
View File
@@ -10,7 +10,6 @@ import '../settings/provider.dart';
import 'provider.dart';
import 'widgets/account_editor_overlay/account_editor_overlay.dart';
import 'widgets/balance_card_carousel.dart';
import 'widgets/budget_progress.dart';
import 'widgets/color_editor_overlay.dart';
import 'widgets/filter_chips.dart';
import 'widgets/search_bar.dart' as custom;
@@ -266,8 +265,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final balance = ref.watch(totalBalanceProvider);
final income = ref.watch(totalIncomeProvider);
final expense = ref.watch(totalExpenseProvider);
final monthExpense = ref.watch(currentMonthExpenseProvider);
final budget = ref.watch(budgetProvider);
final recent = ref.watch(recentTransactionsProvider);
final activeAccount = ref.watch(activeAccountProvider);
final globalCurrencyInfo = ref.watch(currencyProvider);
@@ -376,15 +373,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
currencyInfo: currencyInfo,
strings: s,
),
if (budget != null) ...[
const SizedBox(height: 16),
BudgetProgress(
spent: monthExpense,
budget: budget,
currencyInfo: currencyInfo,
strings: s,
),
],
const SizedBox(height: 24),
custom.SearchBar(
controller: _searchController,
@@ -1,187 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/l10n/app_strings.dart';
import '../../../shared/providers/amount_format_provider.dart';
import '../../../shared/utils/currency_utils.dart';
import '../../../shared/widgets/byn_sign.dart';
import '../../settings/provider.dart';
class BudgetProgress extends ConsumerWidget {
final double spent;
final double budget;
final CurrencyInfo currencyInfo;
final AppStrings strings;
const BudgetProgress({
super.key,
required this.spent,
required this.budget,
required this.currencyInfo,
required this.strings,
});
Border? _themeBorder(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return isDark ? null : Border.all(color: const Color(0xFFDDDDEE), width: 1);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final fmt = ref.watch(amountFormatProvider);
final progress = budget > 0 ? spent / budget : 0.0;
final isOver = progress > 1.0;
final displayPercent = (progress * 100).toStringAsFixed(0);
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
left: BorderSide(
color: isOver ? const Color(0xFFE05C6B) : const Color(0xFF7C6DED),
width: 3,
),
top: _themeBorder(context)?.top ?? BorderSide.none,
right: _themeBorder(context)?.right ?? BorderSide.none,
bottom: _themeBorder(context)?.bottom ?? BorderSide.none,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
strings.monthlyBudget,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
),
),
Text(
'$displayPercent%',
style: TextStyle(
color: isOver
? const Color(0xFFE05C6B)
: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.7),
fontWeight: isOver ? FontWeight.w700 : FontWeight.normal,
fontSize: 12,
),
),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: LinearProgressIndicator(
value: isOver ? 1.0 : progress,
backgroundColor: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.1),
valueColor: AlwaysStoppedAnimation<Color>(
isOver
? const Color(0xFFE05C6B)
: (progress > 0.8
? Colors.orange
: const Color(0xFF4CAF8C)),
),
minHeight: 8,
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
currencyInfo.code == 'BYN'
? Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'${strings.spent}: ',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
),
),
BynSign(
fontSize: 12,
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 2),
Text(
formatAmount('', spent, fmt),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
),
),
],
)
: Text(
'${strings.spent}: ${formatAmount(currencyInfo.symbol, spent, fmt)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
),
),
currencyInfo.code == 'BYN'
? Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'${strings.limit}: ',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
),
),
BynSign(
fontSize: 12,
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 2),
Text(
formatAmount('', budget, fmt),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
),
),
],
)
: Text(
'${strings.limit}: ${formatAmount(currencyInfo.symbol, budget, fmt)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
],
),
),
);
}
}
@@ -9,6 +9,7 @@ import '../../../core/l10n/locale_provider.dart';
import '../../../shared/models/transaction.dart';
import '../../../shared/models/account.dart';
import '../../../shared/providers/amount_format_provider.dart';
import '../../../shared/providers/category_provider.dart';
import '../../../shared/utils/currency_utils.dart';
import '../../../shared/widgets/byn_sign.dart';
import '../../settings/provider.dart';
@@ -29,7 +30,9 @@ class TransactionTile extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final s = ref.watch(stringsProvider);
final isRu = s.locale == AppLocale.ru;
final fmt = ref.watch(amountFormatProvider);
final catalog = ref.watch(categoryCatalogProvider);
final isTransfer = transaction.category == 'Transfer';
final isIncome = transaction.type == TransactionType.income;
final color = isTransfer
@@ -37,10 +40,11 @@ class TransactionTile extends ConsumerWidget {
: (isIncome ? AppColors.income : AppColors.expense);
final catColor = isTransfer
? const Color(0xFF7C6DED)
: (AppCategories.colors[transaction.category] ?? AppColors.accent);
: catalog.colorFor(transaction.category);
final catIcon = isTransfer
? Icons.swap_horiz_rounded
: (AppCategories.icons[transaction.category] ?? Icons.category_rounded);
: catalog.iconFor(transaction.category);
final catLabel = catalog.labelFor(transaction.category, isRu);
final activeAccount = ref.watch(activeAccountProvider);
final displayCurrency =
@@ -105,7 +109,7 @@ class TransactionTile extends ConsumerWidget {
activeAccount,
)
: Text(
s.categoryLabel(transaction.category),
catLabel,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
fontWeight: FontWeight.w600,
@@ -0,0 +1,617 @@
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 '../../../core/utils/result.dart';
import '../../../shared/models/app_category.dart';
import '../../../shared/models/transaction.dart';
import '../../../shared/providers/category_provider.dart';
import '../../../shared/services/translation_service.dart';
import '../../../shared/widgets/error_snackbar.dart';
Future<void> showCategoryEditor(
BuildContext context, {
AppCategory? existing,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => CategoryEditorSheet(existing: existing),
);
}
class CategoryEditorSheet extends ConsumerStatefulWidget {
final AppCategory? existing;
const CategoryEditorSheet({super.key, this.existing});
@override
ConsumerState<CategoryEditorSheet> createState() =>
_CategoryEditorSheetState();
}
class _CategoryEditorSheetState extends ConsumerState<CategoryEditorSheet> {
late final TextEditingController _enController;
late final TextEditingController _ruController;
late TransactionType _type;
late String _iconName;
late int _colorValue;
String? _enSuggestion;
String? _ruSuggestion;
bool _translatingEn = false;
bool _translatingRu = false;
bool _saving = false;
@override
void initState() {
super.initState();
final existing = widget.existing;
_enController = TextEditingController(text: existing?.labelEn ?? '');
_ruController = TextEditingController(text: existing?.labelRu ?? '');
_type = existing?.type == TransactionType.income
? TransactionType.income
: TransactionType.expense;
_iconName = existing?.iconName ?? kCategoryIcons.keys.first;
_colorValue = existing?.color.value ?? kCategoryColors.first.value;
_enController.addListener(_onEnChanged);
_ruController.addListener(_onRuChanged);
}
@override
void dispose() {
_enController.dispose();
_ruController.dispose();
super.dispose();
}
void _onEnChanged() {
if (_enController.text.trim().isNotEmpty && _enSuggestion != null) {
setState(() => _enSuggestion = null);
}
}
void _onRuChanged() {
if (_ruController.text.trim().isNotEmpty && _ruSuggestion != null) {
setState(() => _ruSuggestion = null);
}
}
Future<void> _translateToRu() async {
final source = _enController.text.trim();
if (source.isEmpty) return;
setState(() => _translatingRu = true);
final result = await ref
.read(translationServiceProvider)
.translate(source, TranslateDirection.enToRu);
if (!mounted) return;
setState(() {
_translatingRu = false;
_ruSuggestion = result?.text;
});
if (result == null) {
showErrorSnackbar(context, ref.read(stringsProvider).translationFailed);
}
}
Future<void> _translateToEn() async {
final source = _ruController.text.trim();
if (source.isEmpty) return;
setState(() => _translatingEn = true);
final result = await ref
.read(translationServiceProvider)
.translate(source, TranslateDirection.ruToEn);
if (!mounted) return;
setState(() {
_translatingEn = false;
_enSuggestion = result?.text;
});
if (result == null) {
showErrorSnackbar(context, ref.read(stringsProvider).translationFailed);
}
}
Future<void> _save() async {
final s = ref.read(stringsProvider);
if (_enController.text.trim().isEmpty &&
_ruController.text.trim().isEmpty) {
showErrorSnackbar(context, s.categoryNameRequired);
return;
}
setState(() => _saving = true);
HapticService.medium();
final actions = ref.read(categoryActionsProvider);
final existing = widget.existing;
final result = existing != null && existing.id != null
? await actions.edit(
id: existing.id!,
type: _type,
labelEn: _enController.text,
labelRu: _ruController.text,
iconName: _iconName,
colorValue: _colorValue,
)
: await actions.create(
type: _type,
labelEn: _enController.text,
labelRu: _ruController.text,
iconName: _iconName,
colorValue: _colorValue,
);
if (!mounted) return;
setState(() => _saving = false);
if (result case Failure(message: final message)) {
showErrorSnackbar(context, message);
} else {
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
final s = ref.watch(stringsProvider);
final theme = Theme.of(context);
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
return Padding(
padding: EdgeInsets.only(bottom: bottomInset),
child: Container(
decoration: BoxDecoration(
color: theme.scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
child: SafeArea(
top: false,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.onSurface.withOpacity(0.2),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
Text(
widget.existing != null ? s.editCategory : s.newCategory,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 20),
_TypeToggle(
type: _type,
onChanged: (t) => setState(() => _type = t),
expenseLabel: s.typeExpense,
incomeLabel: s.typeIncome,
),
const SizedBox(height: 20),
_TranslatableField(
controller: _enController,
label: s.nameEn,
hint: s.nameEnHint,
suggestion: _enSuggestion,
isTranslating: _translatingEn,
canTranslate: _ruController.text.trim().isNotEmpty,
translatingLabel: s.translating,
applyLabel: s.applyTranslation,
onTranslate: _translateToEn,
onApply: () {
_enController.text = _enSuggestion ?? '';
setState(() => _enSuggestion = null);
},
),
const SizedBox(height: 16),
_TranslatableField(
controller: _ruController,
label: s.nameRu,
hint: s.nameRuHint,
suggestion: _ruSuggestion,
isTranslating: _translatingRu,
canTranslate: _enController.text.trim().isNotEmpty,
translatingLabel: s.translating,
applyLabel: s.applyTranslation,
onTranslate: _translateToRu,
onApply: () {
_ruController.text = _ruSuggestion ?? '';
setState(() => _ruSuggestion = null);
},
),
const SizedBox(height: 20),
Text(
s.categoryIcon,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
const SizedBox(height: 10),
_IconGrid(
selected: _iconName,
color: Color(_colorValue),
onSelected: (name) {
HapticService.light();
setState(() => _iconName = name);
},
),
const SizedBox(height: 20),
Text(
s.categoryColor,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
const SizedBox(height: 10),
_ColorRow(
selected: _colorValue,
onSelected: (value) {
HapticService.light();
setState(() => _colorValue = value);
},
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _saving ? null : _save,
style: FilledButton.styleFrom(
backgroundColor: AppColors.accent,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: _saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
s.save,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
),
),
],
),
),
),
),
);
}
}
class _TypeToggle extends StatelessWidget {
final TransactionType type;
final ValueChanged<TransactionType> onChanged;
final String expenseLabel;
final String incomeLabel;
const _TypeToggle({
required this.type,
required this.onChanged,
required this.expenseLabel,
required this.incomeLabel,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
_segment(
context,
label: expenseLabel,
selected: type == TransactionType.expense,
color: AppColors.expense,
onTap: () => onChanged(TransactionType.expense),
),
_segment(
context,
label: incomeLabel,
selected: type == TransactionType.income,
color: AppColors.income,
onTap: () => onChanged(TransactionType.income),
),
],
),
);
}
Widget _segment(
BuildContext context, {
required String label,
required bool selected,
required Color color,
required VoidCallback onTap,
}) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: selected ? color.withOpacity(0.18) : Colors.transparent,
borderRadius: BorderRadius.circular(10),
),
alignment: Alignment.center,
child: Text(
label,
style: TextStyle(
color: selected
? color
: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
),
),
);
}
}
class _TranslatableField extends StatelessWidget {
final TextEditingController controller;
final String label;
final String hint;
final String? suggestion;
final bool isTranslating;
final bool canTranslate;
final String translatingLabel;
final String applyLabel;
final VoidCallback onTranslate;
final VoidCallback onApply;
const _TranslatableField({
required this.controller,
required this.label,
required this.hint,
required this.suggestion,
required this.isTranslating,
required this.canTranslate,
required this.translatingLabel,
required this.applyLabel,
required this.onTranslate,
required this.onApply,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
final isEmpty = controller.text.trim().isEmpty;
final showGhost = isEmpty && suggestion != null && !isTranslating;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(14),
border: isDark
? null
: Border.all(color: const Color(0xFFDDDDEE), width: 1),
),
child: Row(
children: [
Expanded(
child: Stack(
children: [
if (showGhost)
Positioned.fill(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
suggestion!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface
.withOpacity(0.28),
fontStyle: FontStyle.italic,
),
),
),
),
),
TextField(
controller: controller,
style: theme.textTheme.bodyLarge,
decoration: InputDecoration(
hintText: showGhost ? '' : hint,
isDense: true,
filled: false,
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 14,
),
),
),
],
),
),
_trailing(context),
],
),
),
],
);
},
);
}
Widget _trailing(BuildContext context) {
if (isTranslating) {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 14),
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
final isEmpty = controller.text.trim().isEmpty;
if (isEmpty && suggestion != null) {
return Padding(
padding: const EdgeInsets.only(right: 6),
child: TextButton(
onPressed: onApply,
style: TextButton.styleFrom(
foregroundColor: AppColors.accent,
padding: const EdgeInsets.symmetric(horizontal: 10),
minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
applyLabel,
style: const TextStyle(fontWeight: FontWeight.w700),
),
),
);
}
if (isEmpty && canTranslate) {
return IconButton(
onPressed: onTranslate,
icon: const Icon(Icons.translate_rounded, size: 20),
color: AppColors.accent,
tooltip: '',
);
}
return const SizedBox(width: 8);
}
}
class _IconGrid extends StatelessWidget {
final String selected;
final Color color;
final ValueChanged<String> onSelected;
const _IconGrid({
required this.selected,
required this.color,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Wrap(
spacing: 10,
runSpacing: 10,
children: kCategoryIcons.entries.map((entry) {
final isSelected = entry.key == selected;
return GestureDetector(
onTap: () => onSelected(entry.key),
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: isSelected
? color.withOpacity(0.2)
: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? color : Colors.transparent,
width: 1.5,
),
),
child: Icon(
entry.value,
color: isSelected
? color
: theme.colorScheme.onSurface.withOpacity(0.6),
size: 22,
),
),
);
}).toList(),
);
}
}
class _ColorRow extends StatelessWidget {
final int selected;
final ValueChanged<int> onSelected;
const _ColorRow({required this.selected, required this.onSelected});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 12,
runSpacing: 12,
children: kCategoryColors.map((color) {
final isSelected = color.value == selected;
return GestureDetector(
onTap: () => onSelected(color.value),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: isSelected ? Colors.white : Colors.transparent,
width: 3,
),
boxShadow: isSelected
? [
BoxShadow(
color: color.withOpacity(0.5),
blurRadius: 8,
spreadRadius: 1,
),
]
: null,
),
child: isSelected
? const Icon(Icons.check_rounded, color: Colors.white, size: 20)
: null,
),
);
}).toList(),
);
}
}
@@ -0,0 +1,268 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/constants.dart';
import '../../../core/l10n/app_strings.dart';
import '../../../core/l10n/locale_provider.dart';
import '../../../core/services/haptic_service.dart';
import '../../../shared/models/app_category.dart';
import '../../../shared/models/transaction.dart';
import '../../../shared/providers/category_provider.dart';
import 'category_editor_sheet.dart';
class CategoryManagerScreen extends ConsumerWidget {
const CategoryManagerScreen({super.key});
Future<void> _confirmDelete(
BuildContext context,
WidgetRef ref,
AppCategory category,
) async {
final s = ref.read(stringsProvider);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(s.deleteCategoryConfirm),
content: Text(s.deleteCategoryWarning),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(s.cancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: AppColors.expense),
child: Text(s.delete),
),
],
),
);
if (confirmed == true && category.id != null) {
HapticService.medium();
await ref.read(categoryActionsProvider).remove(category.id!);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final s = ref.watch(stringsProvider);
final isRu = s.locale == AppLocale.ru;
final catalog = ref.watch(categoryCatalogProvider);
final custom = catalog.custom;
final defaults = catalog.all.where((c) => !c.isCustom).toList();
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
elevation: 0,
scrolledUnderElevation: 0,
title: Text(
s.manageCategories,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
HapticService.medium();
showCategoryEditor(context);
},
backgroundColor: AppColors.accent,
foregroundColor: Colors.white,
icon: const Icon(Icons.add),
label: Text(
s.addCategory,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
body: ListView(
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(20, 8, 20, 100),
children: [
_SectionLabel(text: s.customCategories),
const SizedBox(height: 12),
if (custom.isEmpty)
_EmptyState(
title: s.noCustomCategories,
subtitle: s.noCustomCategoriesHint,
)
else
...custom.map(
(c) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _CategoryRow(
category: c,
isRu: isRu,
onTap: () => showCategoryEditor(context, existing: c),
onDelete: () => _confirmDelete(context, ref, c),
),
),
),
const SizedBox(height: 28),
_SectionLabel(text: s.defaultCategories),
const SizedBox(height: 12),
...defaults.map(
(c) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _CategoryRow(category: c, isRu: isRu),
),
),
],
),
);
}
}
class _SectionLabel extends StatelessWidget {
final String text;
const _SectionLabel({required this.text});
@override
Widget build(BuildContext context) {
return Text(
text.toUpperCase(),
style: TextStyle(
fontSize: 12,
letterSpacing: 1.1,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
),
);
}
}
class _CategoryRow extends StatelessWidget {
final AppCategory category;
final bool isRu;
final VoidCallback? onTap;
final VoidCallback? onDelete;
const _CategoryRow({
required this.category,
required this.isRu,
this.onTap,
this.onDelete,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final isIncome = category.type == TransactionType.income;
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(14),
border: isDark
? null
: Border.all(color: const Color(0xFFDDDDEE), width: 1),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: category.color.withOpacity(0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(category.icon, color: category.color, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
category.label(isRu),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
isIncome
? (isRu ? 'Доход' : 'Income')
: (isRu ? 'Расход' : 'Expense'),
style: theme.textTheme.bodySmall?.copyWith(
color: (isIncome ? AppColors.income : AppColors.expense)
.withOpacity(0.9),
fontWeight: FontWeight.w500,
),
),
],
),
),
if (onDelete != null)
IconButton(
onPressed: onDelete,
icon: const Icon(Icons.delete_outline_rounded, size: 20),
color: theme.colorScheme.onSurface.withOpacity(0.4),
)
else
Icon(
Icons.lock_outline_rounded,
size: 18,
color: theme.colorScheme.onSurface.withOpacity(0.25),
),
],
),
),
);
}
}
class _EmptyState extends StatelessWidget {
final String title;
final String subtitle;
const _EmptyState({required this.title, required this.subtitle});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 20),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: theme.brightness == Brightness.dark
? null
: Border.all(color: const Color(0xFFDDDDEE), width: 1),
),
child: Column(
children: [
Icon(
Icons.category_outlined,
size: 36,
color: theme.colorScheme.onSurface.withOpacity(0.3),
),
const SizedBox(height: 12),
Text(
title,
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
subtitle,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
);
}
}
-28
View File
@@ -11,34 +11,6 @@ import '../../shared/utils/currency_utils.dart';
import '../../shared/providers/amount_format_provider.dart';
import '../dashboard/provider.dart';
final budgetProvider = NotifierProvider<BudgetNotifier, double?>(
BudgetNotifier.new,
);
class BudgetNotifier extends Notifier<double?> {
@override
double? build() {
final storage = ref.watch(storageServiceProvider);
return storage.loadBudget();
}
Future<void> setBudget(double? budget) async {
final storage = ref.read(storageServiceProvider);
await storage.saveBudget(budget);
state = budget;
}
void onCurrencyChanged(
String oldCode,
String newCode,
ExchangeRateService rates,
) {
if (state == null) return;
final converted = rates.convert(state!, oldCode, newCode);
setBudget(converted);
}
}
class CurrencyInfo {
final String symbol;
final String code;
+9 -6
View File
@@ -13,7 +13,7 @@ import 'widgets/currency_conversions_section.dart';
import 'widgets/language_section.dart';
import 'widgets/currency_section.dart';
import 'widgets/amount_format_section.dart';
import 'widgets/budget_section.dart';
import 'widgets/categories_section.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@@ -112,11 +112,16 @@ class SettingsScreen extends ConsumerWidget {
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
title: Text(
s.settings,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
'Casha',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w800,
color: Theme.of(context).colorScheme.onSurface,
letterSpacing: -0.5,
),
),
),
@@ -138,8 +143,6 @@ class SettingsScreen extends ConsumerWidget {
const CurrencySection(),
const SizedBox(height: 16),
const AmountFormatSection(),
const SizedBox(height: 16),
const BudgetSection(),
const SizedBox(height: 24),
Text(
s.dangerZone,
@@ -1,219 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/constants.dart';
import '../../../core/l10n/locale_provider.dart';
import '../../../shared/providers/amount_format_provider.dart';
import '../../../shared/utils/currency_utils.dart';
import '../../../shared/widgets/byn_sign.dart';
import '../provider.dart';
class BudgetSection extends ConsumerStatefulWidget {
const BudgetSection({super.key});
@override
ConsumerState<BudgetSection> createState() => _BudgetSectionState();
}
class _BudgetSectionState extends ConsumerState<BudgetSection> {
final _budgetController = TextEditingController();
bool _isEditing = false;
@override
void initState() {
super.initState();
final budget = ref.read(budgetProvider);
if (budget != null) {
_budgetController.text = budget.toStringAsFixed(2);
}
}
@override
void dispose() {
_budgetController.dispose();
super.dispose();
}
Future<void> _saveBudget() async {
final text = _budgetController.text.trim();
if (text.isEmpty) {
await ref.read(budgetProvider.notifier).setBudget(null);
} else {
final value = double.tryParse(text);
if (value != null && value > 0) {
await ref.read(budgetProvider.notifier).setBudget(value);
}
}
setState(() => _isEditing = false);
}
@override
Widget build(BuildContext context) {
final s = ref.watch(stringsProvider);
final budget = ref.watch(budgetProvider);
final currencyInfo = ref.watch(currencyProvider);
final fmt = ref.watch(amountFormatProvider);
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: isDark
? null
: Border.all(color: const Color(0xFFDDDDEE), width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.accent.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.account_balance_wallet_rounded,
color: AppColors.accent,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
s.monthlyBudgetSetting,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
if (!_isEditing)
IconButton(
icon: const Icon(Icons.edit_rounded, size: 20),
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
onPressed: () => setState(() => _isEditing = true),
),
],
),
const SizedBox(height: 16),
if (_isEditing)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _budgetController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d+\.?\d{0,2}'),
),
],
decoration: InputDecoration(
prefix: currencyInfo.code == 'BYN'
? Row(
mainAxisSize: MainAxisSize.min,
children: [
BynSign(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurface,
),
const SizedBox(width: 4),
],
)
: null,
prefixText: currencyInfo.code != 'BYN'
? (currencyInfo.symbol == ''
? '${currencyInfo.symbol} '
: currencyInfo.symbol)
: null,
hintText: '0.00',
helperText: s.leaveEmptyToRemove,
),
autofocus: true,
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {
final budget = ref.read(budgetProvider);
_budgetController.text =
budget?.toStringAsFixed(2) ?? '';
setState(() => _isEditing = false);
},
child: Text(s.cancel),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _saveBudget,
style: ElevatedButton.styleFrom(
minimumSize: const Size(80, 40),
),
child: Text(s.save),
),
],
),
],
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
budget != null && currencyInfo.code == 'BYN'
? Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
BynSign(fontSize: 24, color: AppColors.accent),
const SizedBox(width: 2),
Text(
formatAmount('', budget, fmt),
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
color: AppColors.accent,
fontWeight: FontWeight.w700,
),
),
],
)
: Text(
budget != null
? formatAmount(currencyInfo.symbol, budget, fmt)
: s.budgetNone,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
color: budget != null
? AppColors.accent
: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
budget != null
? s.yourMonthlySpendingLimit
: s.setMonthlySpendingLimit,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
],
),
);
}
}
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/constants.dart';
import '../../../core/l10n/locale_provider.dart';
import '../../../core/services/haptic_service.dart';
class CategoriesSection extends ConsumerWidget {
const CategoriesSection({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final s = ref.watch(stringsProvider);
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return GestureDetector(
onTap: () {
HapticService.light();
context.push('/settings/categories');
},
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: isDark
? null
: Border.all(color: const Color(0xFFDDDDEE), width: 1),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.accent.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.category_rounded,
color: AppColors.accent,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
s.manageCategories,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
Text(
s.manageCategoriesSubtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
),
Icon(
Icons.chevron_right_rounded,
color: theme.colorScheme.onSurface.withOpacity(0.4),
),
],
),
),
);
}
}
@@ -62,11 +62,6 @@ class CurrencySection extends ConsumerWidget {
padding: const EdgeInsets.only(right: 8),
child: GestureDetector(
onTap: () {
final oldCode = ref.read(currencyProvider).code;
final rates = ref.read(exchangeRateServiceProvider);
ref
.read(budgetProvider.notifier)
.onCurrencyChanged(oldCode, code, rates);
ref.read(currencyProvider.notifier).setCurrency(code);
},
child: Container(