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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user