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
@@ -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(