mirror of
https://github.com/koloideal/Casha.git
synced 2026-08-08 17:51:14 +03:00
step
big
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user