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),
),
),
],
),
);
}
}