mirror of
https://github.com/koloideal/Casha.git
synced 2026-08-08 17:51:14 +03:00
step
big
This commit is contained in:
@@ -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