383 lines
16 KiB
Dart
383 lines
16 KiB
Dart
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, including userId
|
|
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('Сохранить'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|