Init
This commit is contained in:
commit
295625c70f
151 files changed
+10677
No files matched your search
@@ -0,0 +1,268 @@
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter/material.dart';
|
||||
import '../database/database.dart' as db; // Import database with prefix 'db'
|
||||
import '../utils/category_utils.dart'; // Import CategoryUtils
|
||||
|
||||
class AddCategoryDialog extends StatefulWidget {
|
||||
final db.AppDatabase database;
|
||||
|
||||
const AddCategoryDialog({Key? key, required this.database}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AddCategoryDialog> createState() => _AddCategoryDialogState();
|
||||
}
|
||||
|
||||
class _AddCategoryDialogState extends State<AddCategoryDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
// State for selected icon
|
||||
String _selectedIconName = 'label_outline'; // Default icon name (string)
|
||||
IconData _selectedIconData = Icons.label_outline; // Default icon data
|
||||
// State for selected color
|
||||
Color _selectedColor = CategoryUtils.availableColors[9]; // Default to green
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialize icon data based on the default name
|
||||
_selectedIconData = CategoryUtils.getIconFromString(_selectedIconName);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// --- Function to show the icon picker dialog ---
|
||||
Future<void> _showIconPicker() async {
|
||||
final Map<String, IconData> availableIcons = CategoryUtils.getAvailableIcons();
|
||||
final List<String> iconNames = availableIcons.keys.toList();
|
||||
final List<IconData> iconDatas = availableIcons.values.toList();
|
||||
print("Number of available icons: ${availableIcons.length}"); // Debug print
|
||||
|
||||
final String? chosenIconName = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Выберите иконку'),
|
||||
contentPadding: const EdgeInsets.all(10.0), // Adjust padding
|
||||
content: SizedBox( // Constrain the size of the dialog content
|
||||
width: double.maxFinite, // Use maximum width available
|
||||
height: 300, // <--- ЗАДАЕМ ВЫСОТУ ДЛЯ ОБЛАСТИ ПРОКРУТКИ
|
||||
child: GridView.builder(
|
||||
// shrinkWrap: true, // <--- УБИРАЕМ SHRINKWRAP
|
||||
itemCount: iconNames.length,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 5, // Adjust number of columns
|
||||
crossAxisSpacing: 10.0,
|
||||
mainAxisSpacing: 10.0,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final bool isSelected = _selectedIconName == iconNames[index];
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Navigator.pop(context, iconNames[index]); // Return the selected icon name (String)
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8.0), // Ripple effect matches border
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary // Highlight selected
|
||||
: Colors.grey.withOpacity(0.3), // Subtle border for all
|
||||
width: isSelected ? 2.0 : 1.0, // Thicker border if selected
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
color: isSelected ? Theme.of(context).colorScheme.primary.withOpacity(0.1) : null,
|
||||
),
|
||||
child: Icon(
|
||||
iconDatas[index],
|
||||
size: 30.0, // Adjust icon size
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? Colors.white70
|
||||
: Colors.black87,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, null), // Close without selection
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Update state if an icon was chosen
|
||||
if (chosenIconName != null) {
|
||||
setState(() {
|
||||
_selectedIconName = chosenIconName;
|
||||
_selectedIconData = CategoryUtils.getIconFromString(chosenIconName);
|
||||
});
|
||||
}
|
||||
}
|
||||
// --- End of Icon Picker Function ---
|
||||
|
||||
|
||||
Future<void> _saveCategory() async {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final name = _nameController.text;
|
||||
// Use the selected icon name
|
||||
final icon = _selectedIconName;
|
||||
// Use the selected color value
|
||||
final color = _selectedColor.value;
|
||||
|
||||
final newCategoryCompanion = db.CategoriesCompanion(
|
||||
name: Value(name),
|
||||
icon: Value(icon), // Use selected icon name
|
||||
color: Value(color), // Use selected color value
|
||||
);
|
||||
|
||||
try {
|
||||
// TODO: Add check for duplicate category name before inserting (more robustly)
|
||||
final newCategoryId = await widget.database.addCategory(newCategoryCompanion);
|
||||
final newCategory = await widget.database.getCategoryById(newCategoryId);
|
||||
if (mounted) {
|
||||
Navigator.pop(context, newCategory); // Return the newly created CategoryDb object
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error adding category: $e');
|
||||
// Handle potential duplicate name error from database (depends on DB constraints)
|
||||
String errorMessage = 'Произошла ошибка при добавлении категории.';
|
||||
if (e.toString().toLowerCase().contains('unique constraint failed')) {
|
||||
errorMessage = 'Категория с таким именем уже существует.';
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(errorMessage),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
// Determine icon color based on the selected background color's brightness
|
||||
final iconColor = ThemeData.estimateBrightnessForColor(_selectedColor) == Brightness.dark
|
||||
? Colors.white70
|
||||
: Colors.black87;
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Создать категорию'),
|
||||
content: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start, // Align children to the start
|
||||
children: [
|
||||
// --- Icon and Name Row ---
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// --- Icon Picker ---
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0, bottom: 10.0), // Add padding
|
||||
child: InkWell(
|
||||
onTap: _showIconPicker, // Show picker on tap
|
||||
customBorder: const CircleBorder(), // Make ripple circular
|
||||
child: CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: _selectedColor, // Use selected color for background
|
||||
child: Icon(
|
||||
_selectedIconData, // Display selected icon
|
||||
color: iconColor, // Adjust icon color based on background
|
||||
size: 26,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// --- Name Field ---
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Название категории',
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Пожалуйста, введите название';
|
||||
}
|
||||
// Basic length check
|
||||
if (value.length > 50) {
|
||||
return 'Название слишком длинное';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20), // Space before color picker
|
||||
|
||||
// --- Color Picker ---
|
||||
const Text('Выберите цвет:', style: TextStyle(fontSize: 16)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap( // Use Wrap for horizontal layout with wrapping
|
||||
spacing: 8.0, // Horizontal space between circles
|
||||
runSpacing: 8.0, // Vertical space between rows
|
||||
children: CategoryUtils.availableColors.map((color) {
|
||||
final bool isSelected = _selectedColor == color;
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedColor = color; // Update selected color on tap
|
||||
});
|
||||
},
|
||||
customBorder: const CircleBorder(),
|
||||
child: Container(
|
||||
width: 32, // Size of the color circle
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? theme.colorScheme.onSurface // Highlight selected
|
||||
: theme.dividerColor, // Border for others
|
||||
width: isSelected ? 3.0 : 1.0,
|
||||
),
|
||||
boxShadow: isSelected ? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 3,
|
||||
offset: const Offset(0, 1),
|
||||
)
|
||||
] : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16), // Add some space at the bottom
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, null), // Return null if cancelled
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _saveCategory,
|
||||
child: const Text('Сохранить'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../database/database.dart' as db;
|
||||
import '../utils/category_utils.dart'; // For icon and color utilities
|
||||
|
||||
class EditTransactionDialog extends StatefulWidget {
|
||||
final db.AppDatabase database;
|
||||
final db.Transaction transaction; // The transaction to edit
|
||||
final Stream<List<db.CategoryDb>> categoriesStream; // Stream of available categories
|
||||
|
||||
const EditTransactionDialog({
|
||||
Key? key,
|
||||
required this.database,
|
||||
required this.transaction,
|
||||
required this.categoriesStream,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<EditTransactionDialog> createState() => _EditTransactionDialogState();
|
||||
}
|
||||
|
||||
class _EditTransactionDialogState extends State<EditTransactionDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late TextEditingController _amountController;
|
||||
late TextEditingController _merchantController;
|
||||
late String? _selectedCategoryName; // Can be null for Income
|
||||
late DateTime _selectedDate;
|
||||
late db.TransactionType _selectedType;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialize controllers and state with existing transaction data
|
||||
_amountController = TextEditingController(text: widget.transaction.amount.toString());
|
||||
_merchantController = TextEditingController(text: widget.transaction.merchant);
|
||||
_selectedDate = widget.transaction.date;
|
||||
_selectedType = widget.transaction.type == 'income' ? db.TransactionType.income : db.TransactionType.expense;
|
||||
|
||||
// Set initial category name based on transaction type
|
||||
if (_selectedType == db.TransactionType.expense) {
|
||||
_selectedCategoryName = widget.transaction.categoryName;
|
||||
} else {
|
||||
_selectedCategoryName = null; // Income doesn't have a selectable category
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_amountController.dispose();
|
||||
_merchantController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Function to show the date and time pickers
|
||||
Future<void> _selectDateTime(BuildContext context) async {
|
||||
// 1. Pick Date
|
||||
final DateTime? pickedDate = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedDate,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
);
|
||||
|
||||
if (pickedDate != null) {
|
||||
// If date was picked, proceed to pick time
|
||||
// 2. Pick Time
|
||||
final TimeOfDay? pickedTime = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(_selectedDate),
|
||||
);
|
||||
|
||||
if (pickedTime != null) {
|
||||
// If time was also picked, combine date and time and update state
|
||||
setState(() {
|
||||
_selectedDate = DateTime(
|
||||
pickedDate.year,
|
||||
pickedDate.month,
|
||||
pickedDate.day,
|
||||
pickedTime.hour,
|
||||
pickedTime.minute,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// If only date was picked, update state with the picked date and existing time
|
||||
setState(() {
|
||||
_selectedDate = DateTime(
|
||||
pickedDate.year,
|
||||
pickedDate.month,
|
||||
pickedDate.day,
|
||||
_selectedDate.hour, // Keep existing hour
|
||||
_selectedDate.minute, // Keep existing minute
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to handle form submission (Update)
|
||||
void _updateTransaction() async {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final amount = double.tryParse(_amountController.text);
|
||||
if (amount == null || amount <= 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Пожалуйста, введите корректную положительную сумму.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
String categoryToSave;
|
||||
String typeString = _selectedType == db.TransactionType.income ? 'income' : 'expense';
|
||||
|
||||
if (_selectedType == db.TransactionType.income) {
|
||||
categoryToSave = 'Income'; // Fixed category for income
|
||||
} else {
|
||||
if (_selectedCategoryName == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Пожалуйста, выберите категорию для расхода.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
categoryToSave = _selectedCategoryName!;
|
||||
}
|
||||
|
||||
// Create the updated transaction companion
|
||||
final updatedTransaction = db.TransactionsCompanion(
|
||||
id: Value(widget.transaction.id), // Include the ID for update
|
||||
categoryName: Value(categoryToSave),
|
||||
amount: Value(amount),
|
||||
date: Value(_selectedDate),
|
||||
merchant: Value(_merchantController.text),
|
||||
type: Value(typeString),
|
||||
);
|
||||
|
||||
try {
|
||||
// Update transaction in the database
|
||||
final success = await widget.database.updateTransaction(updatedTransaction);
|
||||
|
||||
if (success) {
|
||||
// Close the dialog and return the updated transaction
|
||||
if (mounted) Navigator.of(context).pop(widget.transaction.copyWith( // Return a copy with updated values
|
||||
categoryName: categoryToSave,
|
||||
amount: amount,
|
||||
date: _selectedDate,
|
||||
merchant: _merchantController.text,
|
||||
type: typeString,
|
||||
));
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Не удалось обновить транзакцию.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error updating transaction: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка при обновлении транзакции: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final bool isIncome = _selectedType == db.TransactionType.income;
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Редактировать транзакцию'),
|
||||
content: SingleChildScrollView( // Use SingleChildScrollView for content
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// --- Transaction Type Selector ---
|
||||
Center(
|
||||
child: ToggleButtons(
|
||||
isSelected: [!isIncome, isIncome],
|
||||
onPressed: (int index) {
|
||||
setState(() {
|
||||
_selectedType = index == 0 ? db.TransactionType.expense : db.TransactionType.income;
|
||||
// Reset category selection if switching to income
|
||||
if (_selectedType == db.TransactionType.income) {
|
||||
_selectedCategoryName = null;
|
||||
} else {
|
||||
// If switching to expense, try to select the first expense category
|
||||
// This relies on the StreamBuilder below to update the dropdown
|
||||
// and potentially set a default if _selectedCategoryName is null.
|
||||
}
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
// ИЗМЕНЕНО: Уменьшена минимальная ширина кнопок
|
||||
constraints: BoxConstraints(minWidth: (MediaQuery.of(context).size.width - 160) / 2, minHeight: 40), // Adjusted width for dialog
|
||||
selectedColor: Colors.white,
|
||||
fillColor: isIncome ? Colors.green.shade400 : Colors.red.shade400,
|
||||
color: isDark ? Colors.white70 : Colors.black54,
|
||||
selectedBorderColor: isIncome ? Colors.green.shade600 : Colors.red.shade600,
|
||||
borderColor: isDark ? Colors.grey.shade600 : Colors.grey.shade400,
|
||||
children: const <Widget>[
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [ Icon(Icons.arrow_upward_rounded, size: 18), SizedBox(width: 8), Text('Расход'), ],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [ Icon(Icons.arrow_downward_rounded, size: 18), SizedBox(width: 8), Text('Доход'), ],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// --- Amount Field ---
|
||||
TextFormField(
|
||||
controller: _amountController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Сумма',
|
||||
prefixIcon: Icon(Icons.attach_money, color: isIncome ? Colors.green : theme.colorScheme.primary),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
filled: true,
|
||||
fillColor: isDark ? Colors.grey.shade800.withOpacity(0.5) : Colors.grey.shade100,
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Пожалуйста, введите сумму';
|
||||
}
|
||||
if (double.tryParse(value) == null || double.parse(value) <= 0) {
|
||||
return 'Пожалуйста, введите корректное положительное число';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// --- Category Dropdown (Only for Expenses, uses StreamBuilder) ---
|
||||
if (!isIncome)
|
||||
StreamBuilder<List<db.CategoryDb>>(
|
||||
stream: widget.categoriesStream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting && !snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator(strokeWidth: 2));
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Text('Ошибка загрузки категорий: ${snapshot.error}');
|
||||
}
|
||||
|
||||
final categoriesFromDb = snapshot.data ?? [];
|
||||
// Filter out 'Income' category for the dropdown
|
||||
final expenseCategories = categoriesFromDb.where((c) => c.name != 'Income').toList();
|
||||
|
||||
// Ensure _selectedCategoryName is valid or reset it
|
||||
if (_selectedCategoryName != null && !expenseCategories.any((c) => c.name == _selectedCategoryName)) {
|
||||
_selectedCategoryName = null; // Reset if selected category is no longer valid
|
||||
}
|
||||
// Set default selection if nothing is selected and list is not empty
|
||||
// This handles the case when switching from Income to Expense
|
||||
if (_selectedCategoryName == null && expenseCategories.isNotEmpty) {
|
||||
// Use WidgetsBinding to schedule state update after build
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) { // Check if widget is still mounted
|
||||
setState(() {
|
||||
_selectedCategoryName = expenseCategories[0].name;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return DropdownButtonFormField<String>(
|
||||
value: _selectedCategoryName,
|
||||
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: 12.0, vertical: 16.0),
|
||||
),
|
||||
items: expenseCategories.map((db.CategoryDb category) {
|
||||
final iconData = CategoryUtils.getIconFromString(category.icon);
|
||||
final colorData = Color(category.color);
|
||||
return DropdownMenuItem<String>(
|
||||
value: category.name,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(iconData, color: colorData, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Text(category.name),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (String? newValue) {
|
||||
setState(() {
|
||||
_selectedCategoryName = newValue;
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
if (_selectedType == db.TransactionType.expense && value == null) {
|
||||
return 'Пожалуйста, выберите категорию';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!isIncome) const SizedBox(height: 16),
|
||||
|
||||
// --- Date and Time Picker ---
|
||||
InkWell(
|
||||
onTap: () => _selectDateTime(context),
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Дата и время',
|
||||
prefixIcon: const Icon(Icons.calendar_today_outlined),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
filled: true,
|
||||
fillColor: isDark ? Colors.grey.shade800.withOpacity(0.5) : Colors.grey.shade100,
|
||||
),
|
||||
child: Text(
|
||||
DateFormat.yMMMd().add_jm().format(_selectedDate),
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// --- Merchant / Source Field ---
|
||||
TextFormField(
|
||||
controller: _merchantController,
|
||||
decoration: InputDecoration(
|
||||
labelText: isIncome ? 'Источник' : 'Продавец / Магазин',
|
||||
prefixIcon: Icon(isIncome ? Icons.source_outlined : Icons.storefront_outlined),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
filled: true,
|
||||
fillColor: isDark ? Colors.grey.shade800.withOpacity(0.5) : Colors.grey.shade100,
|
||||
),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(), // Close dialog
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _updateTransaction, // Call update function
|
||||
child: const Text('Сохранить'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ExpandableSection extends StatelessWidget {
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final bool isExpanded;
|
||||
final VoidCallback onTap;
|
||||
final Widget child;
|
||||
final Animation<double> heightFactor;
|
||||
|
||||
const ExpandableSection({
|
||||
Key? key,
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.isExpanded,
|
||||
required this.onTap,
|
||||
required this.heightFactor,
|
||||
required this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? Colors.grey.shade800 : Colors.green.shade100,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: const Radius.circular(16),
|
||||
bottom: Radius.circular(isExpanded ? 0 : 16),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: isDark ? Colors.green.shade300 : Colors.green.shade800,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.green.shade300 : Colors.green.shade800,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
AnimatedRotation(
|
||||
turns: isExpanded ? 0.5 : 0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: isDark ? Colors.green.shade300 : Colors.green.shade800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: heightFactor,
|
||||
builder: (context, innerChild) {
|
||||
return ClipRect(
|
||||
child: Align(
|
||||
heightFactor: heightFactor.value,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 0),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF1E1E1E) : Colors.white,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
bottom: Radius.circular(16),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: isDark
|
||||
? Colors.black.withOpacity(0.3)
|
||||
: Colors.green.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: innerChild,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FilterChipWidget extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const FilterChipWidget({
|
||||
Key? key,
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), // Smaller padding
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? (isDark ? Colors.green.shade700 : Colors.green.shade100)
|
||||
: (isDark ? Colors.grey.shade800 : Colors.grey.shade200),
|
||||
borderRadius: BorderRadius.circular(14), // Smaller radius
|
||||
border: isSelected
|
||||
? Border.all(
|
||||
color: isDark ? Colors.green.shade300 : Colors.green,
|
||||
width: 1,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12, // Smaller text
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
color: isSelected
|
||||
? (isDark ? Colors.white : Colors.green.shade700)
|
||||
: (isDark ? Colors.white70 : Colors.grey.shade700),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:intl/intl.dart'; // For number formatting
|
||||
|
||||
import '../models/category.dart'; // Keep using the Category model for UI structure
|
||||
|
||||
// Changed to StatefulWidget to manage its own selection state
|
||||
class SpendingPieChart extends StatefulWidget {
|
||||
final List<Category> categories; // Expect List<Category> from database calculation
|
||||
final double totalExpenses;
|
||||
final Animation<double> animation; // For fade/scale animation
|
||||
|
||||
const SpendingPieChart({
|
||||
Key? key,
|
||||
required this.categories,
|
||||
required this.totalExpenses,
|
||||
required this.animation,
|
||||
// Removed selectedPieIndex and onSelectPieCategory
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<SpendingPieChart> createState() => _SpendingPieChartState();
|
||||
}
|
||||
|
||||
class _SpendingPieChartState extends State<SpendingPieChart> {
|
||||
// State for the selected index is now managed internally
|
||||
int _selectedPieIndex = -1;
|
||||
|
||||
// Handles selection logic internally
|
||||
void _handlePieTap(int index) {
|
||||
setState(() {
|
||||
// If the same index is selected, deselect (-1), otherwise select the new index
|
||||
_selectedPieIndex = (_selectedPieIndex == index) ? -1 : index;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final theme = Theme.of(context);
|
||||
final currencyFormatter = NumberFormat.currency(locale: 'en_US', symbol: '\$'); // Or your preferred locale/symbol
|
||||
|
||||
// Handle the case where there are no categories to display
|
||||
if (widget.categories.isEmpty) {
|
||||
return AnimatedBuilder( // Still use animation for consistency
|
||||
animation: widget.animation,
|
||||
builder: (context, child) => Opacity(
|
||||
opacity: widget.animation.value,
|
||||
child: Container(
|
||||
height: 230, // Maintain similar height to the chart version
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'No spending data for this period.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Use AnimatedBuilder to apply the fade/scale animation
|
||||
return AnimatedBuilder(
|
||||
animation: widget.animation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: widget.animation.value, // Apply scale animation
|
||||
child: Opacity(
|
||||
opacity: widget.animation.value, // Apply fade animation
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0), // Reduced vertical padding
|
||||
height: 230, // Fixed height for the chart and legend area
|
||||
child: Row(
|
||||
children: [
|
||||
// --- Pie Chart ---
|
||||
Expanded(
|
||||
flex: 5, // Give more space to the chart itself
|
||||
child: Stack( // Use Stack to overlay text on the chart center
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
PieChart(
|
||||
PieChartData(
|
||||
// Handle touch events on the pie chart
|
||||
pieTouchData: PieTouchData(
|
||||
touchCallback: (FlTouchEvent event, PieTouchResponse? pieTouchResponse) {
|
||||
// We are only interested in TapUp events to trigger selection changes
|
||||
if (event is FlTapUpEvent) {
|
||||
final section = pieTouchResponse?.touchedSection;
|
||||
if (section != null) {
|
||||
// Tap occurred ON a section
|
||||
final touchedIndex = section.touchedSectionIndex;
|
||||
// Use internal handler to update state
|
||||
_handlePieTap(touchedIndex);
|
||||
} else {
|
||||
// Tap occurred OUTSIDE any section
|
||||
// Deselect if something was selected
|
||||
if (_selectedPieIndex != -1) {
|
||||
_handlePieTap(-1); // Pass -1 to deselect
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
borderData: FlBorderData(show: false), // No border around the chart
|
||||
sectionsSpace: 2, // Space between slices
|
||||
centerSpaceRadius: 50, // Radius of the center hole
|
||||
sections: _generatePieSections(context), // Generate slices data
|
||||
startDegreeOffset: -90, // Start chart from the top (12 o'clock)
|
||||
),
|
||||
// Optional animation when data changes
|
||||
swapAnimationDuration: const Duration(milliseconds: 250),
|
||||
swapAnimationCurve: Curves.easeInOut,
|
||||
),
|
||||
// --- Center Text (Displayed when a slice is selected) ---
|
||||
if (_selectedPieIndex != -1)
|
||||
_buildCenterText(context, currencyFormatter)
|
||||
else // Optional: Display total or default text when nothing is selected
|
||||
_buildDefaultCenterText(context, currencyFormatter),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8), // Spacing between chart and legend
|
||||
|
||||
// --- Legend ---
|
||||
Expanded(
|
||||
flex: 4, // Allocate space for the legend
|
||||
// Use ListView for scrollable legend if many categories
|
||||
child: ListView.builder(
|
||||
itemCount: widget.categories.length,
|
||||
padding: const EdgeInsets.only(right: 8), // Padding for legend items
|
||||
itemBuilder: (context, index) => _buildPieLegendItem(context, index),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Builds the text displayed in the center when a slice is selected
|
||||
Widget _buildCenterText(BuildContext context, NumberFormat formatter) {
|
||||
final theme = Theme.of(context);
|
||||
// Check if selectedPieIndex is valid before accessing categories
|
||||
if (_selectedPieIndex < 0 || _selectedPieIndex >= widget.categories.length) {
|
||||
// Return an empty container or default text if index is invalid
|
||||
return _buildDefaultCenterText(context, formatter);
|
||||
}
|
||||
final selectedCategory = widget.categories[_selectedPieIndex];
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
selectedCategory.name,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.textTheme.bodyLarge?.color?.withOpacity(0.8),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
formatter.format(selectedCategory.amount), // Format the amount as currency
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: selectedCategory.colorCode, // Use category color for amount
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Builds the default text displayed in the center when no slice is selected
|
||||
Widget _buildDefaultCenterText(BuildContext context, NumberFormat formatter) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Total', // Label for the total amount
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.textTheme.bodyLarge?.color?.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
formatter.format(widget.totalExpenses), // Display total expenses
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.textTheme.bodyLarge?.color // Use default text color
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Builds a single item for the legend
|
||||
Widget _buildPieLegendItem(BuildContext context, int index) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final theme = Theme.of(context);
|
||||
final isSelected = index == _selectedPieIndex; // Check if this item is selected using internal state
|
||||
// Check if index is valid before accessing categories
|
||||
if (index < 0 || index >= widget.categories.length) {
|
||||
return const SizedBox.shrink(); // Return empty if index is invalid
|
||||
}
|
||||
final category = widget.categories[index];
|
||||
// Calculate percentage, handle totalExpenses being zero
|
||||
final percentage = widget.totalExpenses > 0 ? (category.amount / widget.totalExpenses * 100) : 0.0;
|
||||
|
||||
// Use InkWell for tap feedback and GestureDetector for tap logic
|
||||
return InkWell(
|
||||
onTap: () => _handlePieTap(index), // Use internal handler on tap
|
||||
borderRadius: BorderRadius.circular(8), // Match border radius
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200), // Animation for selection change
|
||||
margin: const EdgeInsets.symmetric(vertical: 3.0), // Spacing between legend items
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 6.0), // Padding inside item
|
||||
decoration: BoxDecoration(
|
||||
// Highlight background if selected
|
||||
color: isSelected
|
||||
? category.colorCode.withOpacity(isDark ? 0.3 : 0.15)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
// Add border if selected
|
||||
border: Border.all(
|
||||
color: isSelected ? category.colorCode.withOpacity(0.8) : Colors.transparent,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Color indicator dot
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: category.colorCode.withOpacity(isDark ? 0.9 : 1.0), // Use category color
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8), // Spacing
|
||||
// Category name
|
||||
Expanded(
|
||||
child: Text(
|
||||
category.name,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, // Bold if selected
|
||||
color: theme.textTheme.bodyLarge?.color, // Use default body text color
|
||||
),
|
||||
overflow: TextOverflow.ellipsis, // Prevent long names from wrapping
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8), // Spacing
|
||||
// Percentage text
|
||||
Text(
|
||||
'${percentage.toStringAsFixed(1)}%', // Format percentage
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, // Bold if selected
|
||||
color: theme.textTheme.bodyMedium?.color?.withOpacity(0.7), // Slightly faded color
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Generates the data for each slice (PieChartSectionData) of the pie chart
|
||||
List<PieChartSectionData> _generatePieSections(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return List.generate(widget.categories.length, (i) {
|
||||
// Check if index is valid before accessing categories
|
||||
if (i < 0 || i >= widget.categories.length) {
|
||||
// This should ideally not happen if List.generate is used correctly,
|
||||
// but adding a safeguard.
|
||||
return PieChartSectionData(); // Return an empty section
|
||||
}
|
||||
final isTouched = i == _selectedPieIndex; // Check if this slice is selected using internal state
|
||||
// Make selected slice slightly larger
|
||||
final double radius = isTouched ? 65 : 55;
|
||||
// Make title font slightly larger when selected
|
||||
final double titleFontSize = isTouched ? 14 : 12;
|
||||
final category = widget.categories[i];
|
||||
// Calculate percentage for the title
|
||||
final percentage = widget.totalExpenses > 0 ? (category.amount / widget.totalExpenses * 100) : 0;
|
||||
|
||||
return PieChartSectionData(
|
||||
color: category.colorCode.withOpacity(isDark ? 0.85 : 1.0), // Use category color
|
||||
value: category.amount, // Value determines the slice size
|
||||
title: '${percentage.toStringAsFixed(0)}%', // Display percentage as title
|
||||
radius: radius, // Apply radius (larger if touched)
|
||||
titleStyle: TextStyle(
|
||||
fontSize: titleFontSize, // Apply font size (larger if touched)
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white.withOpacity(0.9), // White text for contrast on colored slices
|
||||
shadows: const [Shadow(color: Colors.black38, blurRadius: 2)], // Subtle shadow for readability
|
||||
),
|
||||
// Add border to selected slice for emphasis
|
||||
borderSide: isTouched
|
||||
? BorderSide(color: isDark ? Colors.white60 : Colors.black54, width: 2)
|
||||
: BorderSide(color: category.colorCode.withOpacity(0.5), width: 1),
|
||||
// Optional: Add badge (icon) to the selected slice
|
||||
// badgeWidget: isTouched ? Icon(category.icon, color: Colors.white, size: 16) : null,
|
||||
// badgePositionPercentageOffset: .98,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SummaryItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String amount;
|
||||
final Color color;
|
||||
|
||||
const SummaryItem({
|
||||
Key? key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.amount,
|
||||
required this.color,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: isDark ? color.withOpacity(0.8) : color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isDark ? Colors.grey.shade400 : Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
amount,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart'; // For date formatting
|
||||
|
||||
import '../models/transaction_record.dart'; // Use the model class
|
||||
|
||||
class TransactionListItem extends StatelessWidget {
|
||||
final TransactionRecord transaction; // Use the model class
|
||||
final VoidCallback? onEdit; // Callback for edit action
|
||||
final VoidCallback? onDelete; // Callback for delete action
|
||||
|
||||
const TransactionListItem({
|
||||
Key? key,
|
||||
required this.transaction,
|
||||
this.onEdit, // Make callbacks optional
|
||||
this.onDelete,
|
||||
}) : super(key: key);
|
||||
|
||||
// Function to show the options menu
|
||||
void _showOptionsMenu(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return SafeArea( // Use SafeArea to avoid system UI
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min, // Take minimum space
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit_outlined),
|
||||
title: const Text('Редактировать'),
|
||||
onTap: () {
|
||||
Navigator.pop(context); // Close the bottom sheet
|
||||
onEdit?.call(); // Call the edit callback if it exists
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
title: const Text('Удалить', style: TextStyle(color: Colors.red)),
|
||||
onTap: () {
|
||||
Navigator.pop(context); // Close the bottom sheet
|
||||
onDelete?.call(); // Call the delete callback if it exists
|
||||
},
|
||||
),
|
||||
// Optional: Add a Cancel button
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cancel_outlined),
|
||||
title: const Text('Отмена'),
|
||||
onTap: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isExpense = transaction.type == 'expense';
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// Wrap the ListTile in an InkWell to handle long press
|
||||
return InkWell(
|
||||
onLongPress: () => _showOptionsMenu(context), // Show menu on long press
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), // Adjusted padding
|
||||
child: Row(
|
||||
children: [
|
||||
// Category Icon (only for expenses)
|
||||
if (isExpense && transaction.category != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: transaction.category!.colorCode.withOpacity(0.1), // Use category color
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
transaction.category!.iconCode, // Use category icon
|
||||
color: transaction.category!.colorCode,
|
||||
size: 20,
|
||||
),
|
||||
)
|
||||
else if (!isExpense) // Icon for Income
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade100, // Specific color for income icon background
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.attach_money_outlined, // Specific icon for income
|
||||
color: Colors.green.shade700,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16), // Space between icon and text
|
||||
|
||||
// Transaction Details (Category/Merchant, Date)
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isExpense ? transaction.merchant : transaction.merchant, // Display merchant for both for now
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500, // Medium weight
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis, // Prevent overflow
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
// Display category name for expense, or 'Income' for income
|
||||
isExpense ? transaction.category?.name ?? 'Unknown Category' : 'Income',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.textTheme.bodySmall?.color?.withOpacity(0.7), // Subtle color
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Amount and Date
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${isExpense ? '-' : '+'} \$${NumberFormat.currency(symbol: '', decimalDigits: 2).format(transaction.amount)}', // Format amount with sign
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isExpense ? Colors.red.shade600 : Colors.green.shade600, // Color based on type
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
DateFormat('MMM d, yyyy').format(transaction.date), // Format date
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.textTheme.bodySmall?.color?.withOpacity(0.7), // Subtle color
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user