300 lines
13 KiB
Dart
300 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:drift/drift.dart' show Value;
|
|
import '../database/database.dart' as db;
|
|
import '../utils/category_utils.dart';
|
|
|
|
class EditCategoryDialog extends StatefulWidget {
|
|
final db.AppDatabase database;
|
|
final db.CategoryDb? categoryToEdit; // null если добавляем новую
|
|
|
|
const EditCategoryDialog({
|
|
Key? key,
|
|
required this.database,
|
|
this.categoryToEdit,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
State<EditCategoryDialog> createState() => _EditCategoryDialogState();
|
|
}
|
|
|
|
class _EditCategoryDialogState extends State<EditCategoryDialog> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
late TextEditingController _nameController;
|
|
String? _selectedIconName;
|
|
Color? _selectedColor;
|
|
|
|
bool get _isEditing => widget.categoryToEdit != null;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_nameController = TextEditingController(text: widget.categoryToEdit?.name ?? '');
|
|
// Ensure the initial icon exists in the available list, otherwise pick the first
|
|
_selectedIconName = widget.categoryToEdit?.icon;
|
|
if (_selectedIconName == null || !CategoryUtils.getAvailableIcons().containsKey(_selectedIconName)) {
|
|
_selectedIconName = CategoryUtils.getAvailableIcons().keys.first;
|
|
}
|
|
|
|
// Инициализируем цвет.
|
|
// Если редактируем существующую категорию, используем ее цвет из БД.
|
|
// Если добавляем новую, используем первый цвет из доступных.
|
|
if (_isEditing) {
|
|
_selectedColor = Color(widget.categoryToEdit!.color);
|
|
} else {
|
|
_selectedColor = CategoryUtils.availableColors.first;
|
|
}
|
|
|
|
// Убедимся, что _selectedColor не null после инициализации
|
|
// (это должно быть гарантировано логикой выше, но для безопасности)
|
|
_selectedColor ??= CategoryUtils.availableColors.first;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _saveCategory() async {
|
|
if (_formKey.currentState!.validate()) {
|
|
final name = _nameController.text.trim();
|
|
final icon = _selectedIconName;
|
|
final color = _selectedColor;
|
|
|
|
if (icon == null || color == null) {
|
|
// This should not happen due to initialization logic, but check anyway
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Пожалуйста, выберите иконку и цвет'),
|
|
backgroundColor: Colors.orange,
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Проверка на уникальность имени (кроме случая редактирования той же категории)
|
|
// Используем case-insensitive сравнение
|
|
final existingCategories = await widget.database.watchAllCategoriesDb().first;
|
|
final isNameTaken = existingCategories.any((c) =>
|
|
c.name.toLowerCase() == name.toLowerCase() &&
|
|
(!_isEditing || c.id != widget.categoryToEdit!.id)); // Проверяем ID только при редактировании
|
|
|
|
if (isNameTaken) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Категория с именем "$name" уже существует.'),
|
|
backgroundColor: Colors.orange,
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
// Запрещаем имя 'Income' (case-insensitive)
|
|
if (name.toLowerCase() == 'income') {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Имя "Income" зарезервировано.'),
|
|
backgroundColor: Colors.orange,
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
|
|
final companion = db.CategoriesCompanion(
|
|
id: _isEditing ? Value(widget.categoryToEdit!.id) : const Value.absent(),
|
|
name: Value(name),
|
|
icon: Value(icon),
|
|
color: Value(color.value),
|
|
);
|
|
|
|
try {
|
|
if (_isEditing) {
|
|
await widget.database.updateCategory(companion);
|
|
} else {
|
|
await widget.database.addCategory(companion);
|
|
}
|
|
if (mounted) Navigator.of(context).pop(true); // Возвращаем true при успехе
|
|
} catch (e) {
|
|
print('Error saving category: $e');
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Ошибка сохранения категории: ${e.toString()}'),
|
|
backgroundColor: Colors.red,
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final isDark = theme.brightness == Brightness.dark;
|
|
final availableIcons = CategoryUtils.getAvailableIcons(); // Get filtered icons
|
|
|
|
// Объединяем цвет текущей категории (если редактируем) с доступными цветами
|
|
// для отображения в палитре. Это нужно, чтобы текущий цвет был виден,
|
|
// даже если его нет в стандартном списке.
|
|
final List<Color> displayedColors = List.from(CategoryUtils.availableColors);
|
|
if (_isEditing && _selectedColor != null && !CategoryUtils.availableColors.contains(_selectedColor)) {
|
|
// Добавляем цвет текущей категории в начало списка для отображения
|
|
displayedColors.insert(0, _selectedColor!);
|
|
}
|
|
|
|
|
|
return AlertDialog(
|
|
title: Text(_isEditing ? 'Редактировать категорию' : 'Добавить категорию'),
|
|
contentPadding: const EdgeInsets.fromLTRB(24.0, 20.0, 24.0, 0.0), // Adjust padding
|
|
content: SizedBox( // Constrain width for better appearance on large screens
|
|
width: MediaQuery.of(context).size.width * 0.8, // Example width constraint
|
|
child: SingleChildScrollView( // Позволяет прокручивать, если не помещается
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start, // Align labels to start
|
|
children: [
|
|
// --- Поле Имя ---
|
|
TextFormField(
|
|
controller: _nameController,
|
|
decoration: InputDecoration(
|
|
labelText: 'Название категории',
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
|
filled: true,
|
|
fillColor: isDark ? Colors.grey.shade800.withOpacity(0.5) : Colors.grey.shade100,
|
|
),
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Введите название';
|
|
}
|
|
if (value.trim().toLowerCase() == 'income') {
|
|
return 'Имя "Income" зарезервировано';
|
|
}
|
|
return null;
|
|
},
|
|
textCapitalization: TextCapitalization.words,
|
|
),
|
|
const SizedBox(height: 20),
|
|
|
|
// --- Выбор Иконки ---
|
|
DropdownButtonFormField<String>(
|
|
value: _selectedIconName,
|
|
isExpanded: true, // Allow dropdown to expand
|
|
decoration: InputDecoration(
|
|
labelText: 'Иконка',
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
|
filled: true,
|
|
fillColor: isDark ? Colors.grey.shade800.withOpacity(0.5) : Colors.grey.shade100,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0),
|
|
),
|
|
items: availableIcons.entries.map((entry) {
|
|
return DropdownMenuItem<String>(
|
|
value: entry.key,
|
|
child: Row(
|
|
children: [
|
|
Icon(entry.value, color: _selectedColor ?? theme.colorScheme.primary, size: 20),
|
|
const SizedBox(width: 12),
|
|
// Отображаем имя иконки, убирая '_outlined' и делая первую букву заглавной
|
|
Text(entry.key.replaceAll('_outlined', '').replaceAll('_', ' ').capitalizeFirst()),
|
|
],
|
|
),
|
|
);
|
|
}).toList(),
|
|
onChanged: (value) {
|
|
if (value != null) {
|
|
setState(() {
|
|
_selectedIconName = value;
|
|
});
|
|
}
|
|
},
|
|
validator: (value) => value == null ? 'Выберите иконку' : null,
|
|
),
|
|
const SizedBox(height: 20),
|
|
|
|
// --- Выбор Цвета ---
|
|
Text('Цвет категории:', style: theme.textTheme.titleSmall),
|
|
const SizedBox(height: 10),
|
|
Wrap( // Используем Wrap для отображения цветов в несколько рядов
|
|
spacing: 10.0, // Горизонтальный отступ
|
|
runSpacing: 10.0, // Вертикальный отступ
|
|
children: displayedColors.map((color) { // Используем объединенный список цветов
|
|
final isSelected = _selectedColor == color;
|
|
return GestureDetector(
|
|
onTap: () {
|
|
setState(() {
|
|
_selectedColor = color;
|
|
});
|
|
},
|
|
child: Container(
|
|
width: 38,
|
|
height: 38,
|
|
decoration: BoxDecoration(
|
|
color: color,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: isSelected
|
|
? (isDark ? Colors.white70 : Colors.black87)
|
|
: theme.dividerColor.withOpacity(0.5), // Subtle border for unselected
|
|
width: isSelected ? 2.5 : 1.0,
|
|
),
|
|
boxShadow: isSelected ? [
|
|
BoxShadow(
|
|
color: color.withOpacity(0.5),
|
|
blurRadius: 4,
|
|
offset: const Offset(0, 2),
|
|
)
|
|
] : [],
|
|
),
|
|
child: isSelected
|
|
? Icon(Icons.check, color: ThemeData.estimateBrightnessForColor(color) == Brightness.dark ? Colors.white : Colors.black, size: 20)
|
|
: null,
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
const SizedBox(height: 24), // Add space before actions
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
actionsPadding: const EdgeInsets.fromLTRB(24.0, 0.0, 24.0, 16.0), // Adjust actions padding
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false), // Возвращаем false при отмене
|
|
child: const Text('Отмена'),
|
|
),
|
|
ElevatedButton.icon(
|
|
icon: const Icon(Icons.save_alt_rounded),
|
|
onPressed: _saveCategory,
|
|
label: const Text('Сохранить'),
|
|
style: ElevatedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// Helper extension for capitalizing first letter
|
|
extension StringExtension on String {
|
|
String capitalizeFirst() {
|
|
if (isEmpty) return this;
|
|
return "${this[0].toUpperCase()}${substring(1)}";
|
|
}
|
|
}
|