Init
This commit is contained in:
commit
295625c70f
151 files changed
+10677
No files matched your search
@@ -0,0 +1,299 @@
|
||||
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)}";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user