This commit is contained in:
Sanders committed 2025-05-12 19:08:22 +03:00
commit 295625c70f
151 files changed
+10677

No files matched your search

+268
View File
@@ -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('Сохранить'),
),
],
);
}
}
+299
View File
@@ -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)}";
}
}
+382
View File
@@ -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('Сохранить'),
),
],
);
}
}
+101
View File
@@ -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,
),
],
);
}
}
+49
View File
@@ -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),
),
),
),
);
}
}
+318
View File
@@ -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,
);
});
}
}
+51
View File
@@ -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,
),
),
],
);
}
}
+148
View File
@@ -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
),
),
],
),
],
),
),
);
}
}