import 'package:drift/drift.dart' show Value; // Only import Value for optional fields import 'package:flutter/material.dart'; import 'package:fl_chart/fl_chart.dart'; // Used indirectly by SpendingPieChart import 'package:intl/intl.dart'; // For date formatting import 'dart:async'; import 'package:async/async.dart'; // Import StreamZip // Removed import 'dart:math'; // No longer needed for random data import '../database/database.dart' as db; // Import database with prefix 'db' import '../models/category.dart'; // Keep Category model for UI structure (SpendingPieChart) // Import TransactionRecord with a different alias to avoid conflict with db.Transaction import '../models/transaction_record.dart' as model; // Assuming this model will have a 'type' field import '../widgets/summary_item.dart'; import '../widgets/expandable_section.dart'; import '../widgets/spending_pie_chart.dart'; import '../widgets/transaction_list_item.dart'; import '../widgets/filter_chip_widget.dart'; import '../utils/category_utils.dart'; // Import category utils import 'profile_screen.dart'; // Import profile screen import 'settings_menu_screen.dart'; // Import the new settings menu screen import '../widgets/add_category_dialog.dart'; // Import the new dialog import '../widgets/edit_transaction_dialog.dart'; // Import the new edit dialog // Enum for transaction type selection in the form enum TransactionType { expense, income } class ExpensesScreen extends StatefulWidget { final Function toggleTheme; final bool isDarkMode; final db.AppDatabase database; // Accept database instance const ExpensesScreen({ Key? key, required this.toggleTheme, required this.isDarkMode, required this.database, // Require database instance }) : super(key: key); @override State createState() => _ExpensesScreenState(); } class _ExpensesScreenState extends State with TickerProviderStateMixin { // Animation controllers for UI elements late AnimationController _pieChartAnimationController; late Animation _pieChartAnimation; late AnimationController _pieChartExpandController; late Animation _pieChartHeightFactor; // State variables int _selectedNavIndex = 0; // Index for bottom navigation bar bool _isPieChartExpanded = true; // Controls visibility of the pie chart section bool _isFilterVisible = false; // Controls visibility of the filter chips String _selectedFilter = 'All'; // Currently selected transaction filter late Stream> _transactionsStream; // Stream for transactions late Stream _totalIncomeStream; // Stream for total income late Stream _totalExpensesStream; // Stream for total expenses late Stream> _categoriesStream; // Stream for categories from DB @override void initState() { super.initState(); // Initialize animation controller for pie chart fade/scale effect _pieChartAnimationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 800), ); _pieChartAnimation = CurvedAnimation( parent: _pieChartAnimationController, curve: Curves.easeInOut, ); // Initialize animation controller for pie chart expand/collapse effect _pieChartExpandController = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), value: 1.0, // Start expanded ); _pieChartHeightFactor = CurvedAnimation( parent: _pieChartExpandController, curve: Curves.easeInOut, ); // Initialize the streams _transactionsStream = widget.database.watchFilteredTransactions(_selectedFilter); _totalIncomeStream = widget.database.watchTotalIncome(); _totalExpensesStream = widget.database.watchTotalExpenses(); // Use direct expense stream _categoriesStream = widget.database.watchAllCategoriesDb(); // Watch categories from DB // Start the pie chart appearance animation _pieChartAnimationController.forward(); } @override void dispose() { // Dispose controllers to free up resources _pieChartAnimationController.dispose(); _pieChartExpandController.dispose(); super.dispose(); } // Stream that provides category totals calculated from transactions (EXPENSES ONLY) Stream> _watchCategoryTotals() { // This stream is provided by Drift and updates automatically. // It's already configured in database.dart to only calculate expenses. return widget.database.calculateCategoryTotals(); } // Toggles the visibility of the pie chart section with animation void _togglePieChartVisibility() { setState(() { _isPieChartExpanded = !_isPieChartExpanded; if (_isPieChartExpanded) { _pieChartExpandController.forward(); // Expand animation } else { _pieChartExpandController.reverse(); // Collapse animation } }); } // Toggles the visibility of the filter chip row void _toggleFilterVisibility() { setState(() { _isFilterVisible = !_isFilterVisible; }); } // Applies the selected filter to the transaction list void _applyFilter(String filter) { // Only call setState if the filter actually changes if (_selectedFilter != filter) { setState(() { _selectedFilter = filter; // Update the stream instance when the filter changes // watchFilteredTransactions handles 'All' vs specific expense category _transactionsStream = widget.database.watchFilteredTransactions(_selectedFilter); }); } } // --- Function to show the modal bottom sheet for adding a transaction --- void _showAddTransactionSheet() { showModalBottomSheet( context: context, isScrollControlled: true, // Allows the sheet to take up more height shape: const RoundedRectangleBorder( // Rounded corners borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (context) { // Pass the database instance and the categories stream to the form widget return Padding( // Add padding to account for the keyboard padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), child: _AddTransactionForm( database: widget.database, categoriesStream: _categoriesStream, // Pass the stream here ), ); }, ); } // --- End of show add transaction sheet function --- // --- Function to handle Bottom Navigation Bar taps --- void _onItemTapped(int index) { setState(() { _selectedNavIndex = index; }); // Handle navigation based on index switch (index) { case 0: // Stay on Expenses Screen (Home) break; case 1: // TODO: Navigate to Reports Screen ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Reports Screen not implemented yet.'), duration: Duration(seconds: 1), behavior: SnackBarBehavior.floating, ), ); break; case 2: // Navigate to Settings Menu Screen Navigator.push( context, MaterialPageRoute( builder: (context) => SettingsMenuScreen(database: widget.database), // Navigate to the new menu screen ), ); break; } } // --- End of Bottom Navigation Bar taps function --- // --- Function to show the edit transaction dialog --- void _showEditTransactionDialog(db.Transaction transaction) async { final updatedTransaction = await showDialog( context: context, builder: (context) => EditTransactionDialog( database: widget.database, transaction: transaction, categoriesStream: _categoriesStream, // Pass categories stream ), ); if (updatedTransaction != null) { // Transaction was updated, database stream will automatically refresh the list if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Транзакция обновлена.'), duration: Duration(seconds: 2), behavior: SnackBarBehavior.floating, ), ); } } } // --- End of show edit transaction dialog function --- // --- Function to handle transaction deletion --- void _deleteTransaction(int transactionId) async { // Show a confirmation dialog before deleting final bool confirmDelete = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Подтверждение удаления'), content: const Text('Вы уверены, что хотите удалить эту транзакцию?'), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), // Cancel child: const Text('Отмена'), ), TextButton( onPressed: () => Navigator.of(context).pop(true), // Confirm child: const Text('Удалить'), style: TextButton.styleFrom(foregroundColor: Colors.red), ), ], ), ) ?? false; // Default to false if dialog is dismissed if (confirmDelete) { try { final deletedCount = await widget.database.deleteTransaction(transactionId); if (deletedCount > 0) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Транзакция удалена.'), duration: Duration(seconds: 2), behavior: SnackBarBehavior.floating, ), ); } } else { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Не удалось удалить транзакцию.'), backgroundColor: Colors.red, duration: Duration(seconds: 2), behavior: SnackBarBehavior.floating, ), ); } } } catch (e) { print('Error deleting transaction: $e'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Ошибка при удалении транзакции: $e'), backgroundColor: Colors.red, duration: Duration(seconds: 3), behavior: SnackBarBehavior.floating, ), ); } } } } // --- End of handle transaction deletion function --- @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; final theme = Theme.of(context); // Get theme for easier access to styles return Scaffold( appBar: AppBar( // Title with icon title: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.account_balance_wallet_outlined, // Updated icon color: isDark ? Colors.greenAccent.shade100 : Colors.green.shade800, size: 24, ), const SizedBox(width: 8), const Text('My Finances'), // Updated title ], ), centerTitle: true, // Theme toggle button leading: IconButton( tooltip: isDark ? 'Switch to Light Mode' : 'Switch to Dark Mode', icon: Icon( widget.isDarkMode ? Icons.wb_sunny_outlined : Icons.nightlight_round, color: widget.isDarkMode ? Colors.yellow.shade300 : Colors.blue.shade700, ), onPressed: () => widget.toggleTheme(), ), // Profile avatar button actions: [ Padding( padding: const EdgeInsets.only(right: 12.0), // Adjusted padding child: Hero( tag: 'profileAvatar', // Tag for Hero animation child: Material( type: MaterialType.transparency, // Needed for Hero animation across routes child: IconButton( tooltip: 'View Profile', icon: CircleAvatar( radius: 18, // Slightly smaller avatar backgroundColor: isDark ? Colors.green.shade800 : Colors.green.shade100, child: const Icon(Icons.person_outline, color: Colors.green, size: 20), ), onPressed: () { // Navigate to ProfileScreen with a fade transition Navigator.push( context, PageRouteBuilder( pageBuilder: (_, __, ___) => const ProfileScreen(), transitionsBuilder: (_, animation, __, child) { return FadeTransition(opacity: animation, child: child); }, transitionDuration: const Duration(milliseconds: 350), // Adjusted duration ), ); }, ), ), ), ), ], ), // Use multiple StreamBuilders for different data points (income, expenses, categories) body: MultiStreamBuilder( streams: [ _watchCategoryTotals(), // Stream> for pie chart (expenses only) _totalIncomeStream, // Stream for total income _totalExpensesStream, // Stream for total expenses _categoriesStream, // Stream> for filter chips ], builder: (context, snapshots) { // Check if all streams have data (or handle loading/error states individually) if (snapshots.any((s) => s.connectionState == ConnectionState.waiting && !s.hasData)) { return const Center(child: CircularProgressIndicator()); } if (snapshots.any((s) => s.hasError)) { // Find the first error and display it final errorSnapshot = snapshots.firstWhere((s) => s.hasError); return Center(child: Text('Error loading data: ${errorSnapshot.error}')); } // Safely extract data with defaults final expenseCategoriesForPie = snapshots[0].data as List? ?? []; final totalIncome = snapshots[1].data as double? ?? 0.0; final totalExpenses = snapshots[2].data as double? ?? 0.0; final allDbCategories = snapshots[3].data as List? ?? []; // Filter out the 'Income' category for display in expense filters/pie chart final expenseDbCategories = allDbCategories.where((c) => c.name != 'Income').toList(); // Main column layout return Column( crossAxisAlignment: CrossAxisAlignment.stretch, // Stretch children horizontally children: [ Expanded( // Use SingleChildScrollView for content that might overflow child: SingleChildScrollView( physics: const BouncingScrollPhysics(), // iOS-like scroll physics child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ // --- Summary Card --- Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Card( elevation: 2, // Subtle shadow child: Padding( padding: const EdgeInsets.all(16.0), // Adjusted padding child: Column( children: [ Text( 'Financial Summary', // More general title style: theme.textTheme.titleMedium, // Use theme style ), const SizedBox(height: 8), // Display Net Balance (Income - Expenses) Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, // Align baseline children: [ Text( 'Net Balance: ', style: TextStyle( fontSize: 18, // Smaller label fontWeight: FontWeight.w500, color: theme.textTheme.bodySmall?.color, ), ), Text( '${totalIncome >= totalExpenses ? '+' : '-'} \$', // Sign based on balance style: TextStyle( fontSize: 20, // Smaller dollar sign fontWeight: FontWeight.w500, // Medium weight color: (totalIncome - totalExpenses) >= 0 ? Colors.green : Colors.red, ), ), Text( NumberFormat.currency(symbol: '', decimalDigits: 2).format((totalIncome - totalExpenses).abs()), // Format number style: TextStyle( fontSize: 36, // Slightly smaller amount fontWeight: FontWeight.bold, color: (totalIncome - totalExpenses) >= 0 ? Colors.green : Colors.red, letterSpacing: -1, // Tighten spacing ), ), ], ), const SizedBox(height: 16), // Divider line Divider(height: 1, thickness: 1, indent: 20, endIndent: 20, color: theme.dividerColor.withOpacity(0.5)), const SizedBox(height: 16), // Income / Expenses summary items Row( mainAxisAlignment: MainAxisAlignment.spaceAround, // Distribute space children: [ SummaryItem( // Display total income from stream icon: Icons.arrow_downward_rounded, title: 'Income', amount: '\$${NumberFormat.currency(symbol: '', decimalDigits: 2).format(totalIncome)}', color: Colors.green, ), // Vertical divider Container( height: 35, width: 1, color: theme.dividerColor.withOpacity(0.5), ), SummaryItem( // Display total expenses from stream icon: Icons.arrow_upward_rounded, title: 'Expenses', amount: '\$${NumberFormat.currency(symbol: '', decimalDigits: 2).format(totalExpenses)}', color: Colors.red, ), ], ), ], ), ), ), ), // --- Pie Chart Section (Shows EXPENSE Breakdown) --- ExpandableSection( title: 'Expense Breakdown', // Clarified title icon: Icons.pie_chart_outline_rounded, // Updated icon isExpanded: _isPieChartExpanded, onTap: _togglePieChartVisibility, heightFactor: _pieChartHeightFactor, // Animation controller child: SpendingPieChart( // Use the categories calculated specifically for the pie chart categories: expenseCategoriesForPie, totalExpenses: totalExpenses, // Pass calculated total expenses animation: _pieChartAnimation, // Appearance animation ), ), // --- Transaction List Section --- Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Header with Title and Filter/See All buttons Padding( padding: const EdgeInsets.fromLTRB(16, 20, 16, 4), // Adjusted padding child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Recent Transactions', style: theme.textTheme.titleLarge?.copyWith( // Use theme style fontWeight: FontWeight.w600, // Bold weight ), ), // Filter button Row( children: [ InkWell( // Use InkWell for ripple effect onTap: _toggleFilterVisibility, borderRadius: BorderRadius.circular(16), child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: isDark ? Colors.grey.shade800 : Colors.grey.shade200, borderRadius: BorderRadius.circular(16), ), child: Row( children: [ Text( _selectedFilter == 'All' ? 'All Types' : _selectedFilter, // Display current filter style: TextStyle( fontSize: 13, // Smaller font color: isDark ? Colors.white70 : Colors.black87, ), ), const SizedBox(width: 4), Icon( Icons.filter_list_alt, // Updated icon size: 18, // Slightly larger icon color: isDark ? Colors.white70 : Colors.black87, ), ], ), ), ), ], ), ], ), ), // --- Filter Chips Row (Animated Visibility) --- // Shows 'All' and EXPENSE categories from the database stream AnimatedContainer( duration: const Duration(milliseconds: 300), // Animation duration curve: Curves.easeInOut, // Animation curve height: _isFilterVisible ? 50 : 0, // Animate height clipBehavior: Clip.hardEdge, // Prevent overflow during animation decoration: const BoxDecoration(), // Needed for clipBehavior padding: EdgeInsets.symmetric( vertical: _isFilterVisible ? 8 : 0, // Animate padding ), child: ListView( // Use ListView for horizontal scrolling scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), children: [ // "All" filter chip (shows income and expenses) FilterChipWidget( label: 'All Types', isSelected: _selectedFilter == 'All', onTap: () => _applyFilter('All') ), // Dynamically generate filter chips from EXPENSE categories (from DB) ...expenseDbCategories.map((category) => FilterChipWidget( label: category.name, // Use name from CategoryDb isSelected: _selectedFilter == category.name, onTap: () => _applyFilter(category.name) // Applies expense category filter ) ).toList(), ], ), ), // --- Transactions List (using StreamBuilder) --- Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), child: Card( // Wrap list in a Card for background/border margin: EdgeInsets.zero, elevation: 0, // No shadow for inner card shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: BorderSide(color: theme.dividerColor.withOpacity(0.5), width: 1) // Subtle border ), clipBehavior: Clip.antiAlias, // Clip list items to card shape child: StreamBuilder>( // Use the stream instance from the state (_transactionsStream is updated by _applyFilter) stream: _transactionsStream, builder: (context, transactionSnapshot) { // Handle loading state for transactions if (transactionSnapshot.connectionState == ConnectionState.waiting) { if (!transactionSnapshot.hasData) { return const SizedBox( height: 150, // Placeholder height child: Center(child: CircularProgressIndicator(strokeWidth: 2)), ); } } // Handle error state for transactions if (transactionSnapshot.hasError) { return SizedBox( height: 150, child: Center(child: Text('Error: ${transactionSnapshot.error}')), ); } // Get transaction data (or empty list) final transactions = transactionSnapshot.data ?? []; // Display message if no transactions match the filter if (transactions.isEmpty && transactionSnapshot.connectionState != ConnectionState.waiting) { return SizedBox( height: 150, child: Center( child: Text( _selectedFilter == 'All' ? 'No transactions yet.' : 'No expenses found for $_selectedFilter.', // Updated message style: theme.textTheme.bodyMedium?.copyWith(color: Colors.grey), ), ), ); } // Use ListView.builder to display transactions efficiently return ListView.separated( itemCount: transactions.length, physics: const NeverScrollableScrollPhysics(), // Disable inner scrolling shrinkWrap: true, // Fit content height padding: const EdgeInsets.symmetric(vertical: 8.0), // Padding inside the card separatorBuilder: (context, index) => Divider( height: 1, thickness: 1, indent: 16, endIndent: 16, color: theme.dividerColor.withOpacity(0.3), ), itemBuilder: (context, index) { // Get the db.Transaction object from the stream final dbTransaction = transactions[index]; // Find the corresponding CategoryDb object for details (icon, color) // This assumes category names are unique. Handle potential null if category deleted. final categoryDb = allDbCategories.firstWhere( (c) => c.name == dbTransaction.categoryName, orElse: () => db.CategoryDb( // Provide a default if not found id: -1, name: dbTransaction.categoryName, icon: 'help_outline', // Default icon name (string) color: Colors.grey.value // Default color ), ); // Get icon data from string name using the new utility function final iconData = CategoryUtils.getIconFromString(categoryDb.icon); final colorData = Color(categoryDb.color); // Create the model.TransactionRecord needed by TransactionListItem final transactionRecord = model.TransactionRecord( id: dbTransaction.id, type: dbTransaction.type, amount: dbTransaction.amount, // Create the UI Category model only for expenses category: dbTransaction.type == 'expense' ? Category( dbTransaction.categoryName, dbTransaction.amount, // Amount here might be redundant? colorData, iconData, ) : null, // No UI Category for income type date: dbTransaction.date, merchant: dbTransaction.merchant, ); // Use the TransactionListItem widget return TransactionListItem( key: ValueKey(dbTransaction.id), // Use the real ID for the key transaction: transactionRecord, // Pass the model.TransactionRecord // Pass the original db.Transaction object for editing/deleting onEdit: () => _showEditTransactionDialog(dbTransaction), onDelete: () => _deleteTransaction(dbTransaction.id), ); }, ); }, ), ), ), const SizedBox(height: 16), // Bottom padding inside scroll view ], ), const SizedBox(height: 80), // Extra bottom padding below list to avoid FAB overlap ], ), ), ), ], ); }, ), // Bottom Navigation Bar bottomNavigationBar: BottomNavigationBar( currentIndex: _selectedNavIndex, onTap: _onItemTapped, // Use the new handler function items: const [ // Use const for static items BottomNavigationBarItem( icon: Icon(Icons.home_filled), // Use filled icon for selected state label: 'Home', ), BottomNavigationBarItem( icon: Icon(Icons.bar_chart_rounded), label: 'Reports', ), BottomNavigationBarItem( icon: Icon(Icons.settings_outlined), activeIcon: Icon(Icons.settings), // Filled icon when active label: 'Settings', ), ], ), // Floating Action Button to add new transaction floatingActionButton: FloatingActionButton.extended( // Use extended FAB onPressed: _showAddTransactionSheet, // Show the modal sheet on press tooltip: 'Add Transaction', icon: const Icon(Icons.add), label: const Text('Add'), ), floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, // Standard location ); } } // Helper widget to manage multiple streams for the main body class MultiStreamBuilder extends StatelessWidget { final List> streams; final Widget Function(BuildContext, List>) builder; const MultiStreamBuilder({ Key? key, required this.streams, required this.builder, }) : super(key: key); // Use super constructor @override Widget build(BuildContext context) { // Combine streams ensuring all emit at least one value (or handle initial nulls) // Using StreamZip might wait until all streams emit. Behavior depends on stream types. // Consider using combineLatest or similar if waiting isn't desired. return StreamBuilder>( // Use List and check types later stream: StreamZip(streams), // StreamZip waits for all streams to emit at least once builder: (context, combinedSnapshot) { if (combinedSnapshot.connectionState == ConnectionState.waiting && !combinedSnapshot.hasData) { // Show loading only if waiting AND no data has arrived yet return const Center(child: CircularProgressIndicator()); } if (combinedSnapshot.hasError) { return Center(child: Text('Error combining streams: ${combinedSnapshot.error}')); } // Create AsyncSnapshot objects manually for the builder // This allows handling individual stream states if needed, though StreamZip simplifies it final snapshots = List>.generate( streams.length, (index) { if (combinedSnapshot.hasData) { // If combined stream has data, assume individual streams are done (or active with data) return AsyncSnapshot.withData(ConnectionState.active, combinedSnapshot.data![index]); } else if (combinedSnapshot.hasError) { // Propagate error to individual snapshots (might need refinement) return AsyncSnapshot.withError(ConnectionState.active, combinedSnapshot.error!); } else { // Default to waiting state if combined stream is waiting return const AsyncSnapshot.waiting(); } }, ); // Call the original builder function with the list of snapshots return builder(context, snapshots); }, ); } // This helper function is no longer needed as StreamZip handles the combination // Stream>> _combineStreams() { ... } } // --- Widget for the Add Transaction Form --- class _AddTransactionForm extends StatefulWidget { final db.AppDatabase database; final Stream> categoriesStream; // Receive stream const _AddTransactionForm({ Key? key, required this.database, required this.categoriesStream, // Require stream }) : super(key: key); @override State<_AddTransactionForm> createState() => _AddTransactionFormState(); } class _AddTransactionFormState extends State<_AddTransactionForm> { final _formKey = GlobalKey(); // Key for form validation final _amountController = TextEditingController(); final _merchantController = TextEditingController(); // Label changes based on type String? _selectedCategoryName; // Store the NAME of the selected category DateTime _selectedDate = DateTime.now(); // Default to today, includes time TransactionType _selectedType = TransactionType.expense; // Default to expense // No longer need static list: final List _categories = CategoryUtils.getAllCategoryNames(); @override void initState() { super.initState(); // Set the initial category if the list is not empty and type is expense // We need to listen to the stream for the initial value // Setting initial value here is tricky with streams, better handle in StreamBuilder // if (_selectedType == TransactionType.expense && _categories.isNotEmpty) { // _selectedCategoryName = _categories[0]; // } } @override void dispose() { _amountController.dispose(); _merchantController.dispose(); super.dispose(); } // Function to show the date and time pickers Future _selectDateTime(BuildContext context) async { // 1. Pick Date final DateTime? pickedDate = await showDatePicker( context: context, initialDate: _selectedDate, firstDate: DateTime(2000), // Allow dates from year 2000 lastDate: DateTime.now().add(const Duration(days: 365)), // Allow up to one year in future ); 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), // Use current time from state ); 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 ); }); } } // If date picker was cancelled (pickedDate == null), do nothing. } // Function to handle form submission void _submitTransaction() async { // Validate the form if (_formKey.currentState!.validate()) { // Parse amount final amount = double.tryParse(_amountController.text); if (amount == null || amount <= 0) { // Show error if amount is invalid ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Please enter a valid positive amount.'), backgroundColor: Colors.red, behavior: SnackBarBehavior.floating, ), ); return; } // Determine category and type string String categoryToSave; String typeString = _selectedType == TransactionType.income ? 'income' : 'expense'; if (_selectedType == TransactionType.income) { categoryToSave = 'Income'; // Use a fixed category for income } else { // Ensure a category is selected for expenses if (_selectedCategoryName == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Please select a category for the expense.'), backgroundColor: Colors.red, behavior: SnackBarBehavior.floating, ), ); return; } categoryToSave = _selectedCategoryName!; } // Create the transaction companion including the type final merchantValue = _merchantController.text.isNotEmpty ? _merchantController.text : (_selectedType == TransactionType.income ? 'Unknown Source' : 'Unknown Merchant'); final newTransaction = db.TransactionsCompanion( categoryName: Value(categoryToSave), amount: Value(amount), date: Value(_selectedDate), merchant: Value(merchantValue), type: Value(typeString), ); try { // Add transaction to the database await widget.database.addTransaction(newTransaction); // Close the bottom sheet if (mounted) Navigator.pop(context); // Show success message if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('${_selectedType == TransactionType.income ? "Income" : "Expense"} added: ${typeString == 'income' ? '' : '$categoryToSave - '}\$${amount.toStringAsFixed(2)}'), duration: const Duration(seconds: 2), behavior: SnackBarBehavior.floating, ), ); } } catch (e) { print('Error adding transaction: $e'); // Show error message if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Error adding transaction: $e'), backgroundColor: Colors.red, behavior: SnackBarBehavior.floating, ), ); } } } } // --- Function to handle Add Category button press --- void _showAddCategoryDialog() async { // Show the dialog and wait for the result final newCategory = await showDialog( // Expecting CategoryDb or null context: context, builder: (context) => AddCategoryDialog(database: widget.database), ); // If a new category was created and returned if (newCategory != null) { // Set the newly created category as selected in the dropdown setState(() { _selectedCategoryName = newCategory.name; }); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Категория "${newCategory.name}" создана и выбрана.'), duration: const Duration(seconds: 2), ), ); } } } // --- End of Add Category Function --- @override Widget build(BuildContext context) { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; final bool isIncome = _selectedType == TransactionType.income; return Padding( padding: const EdgeInsets.all(20.0), child: Form( key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, // Take minimum space needed crossAxisAlignment: CrossAxisAlignment.start, children: [ // --- Header --- Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Add New Transaction', style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600), ), IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context), // Close button tooltip: 'Close', ) ], ), const SizedBox(height: 16), // --- Transaction Type Selector --- Center( child: ToggleButtons( isSelected: [!isIncome, isIncome], // [Expense selected, Income selected] onPressed: (int index) { setState(() { _selectedType = index == 0 ? TransactionType.expense : TransactionType.income; // Reset category selection if switching to income if (_selectedType == TransactionType.income) { _selectedCategoryName = null; } else { // Don't reset to default here, let StreamBuilder handle initial state // _selectedCategoryName = _categories[0]; // Remove this } }); }, borderRadius: BorderRadius.circular(12), constraints: BoxConstraints(minWidth: (MediaQuery.of(context).size.width - 60) / 2, minHeight: 40), // Adjust width based on screen 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 [ Padding( padding: EdgeInsets.symmetric(horizontal: 16.0), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.arrow_upward_rounded, size: 18), SizedBox(width: 8), Text('Expense'), ], ), ), Padding( padding: EdgeInsets.symmetric(horizontal: 16.0), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.arrow_downward_rounded, size: 18), SizedBox(width: 8), Text('Income'), ], ), ), ], ), ), const SizedBox(height: 20), // --- Amount Field --- TextFormField( controller: _amountController, decoration: InputDecoration( labelText: 'Amount', 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 'Please enter an amount'; } if (double.tryParse(value) == null || double.parse(value) <= 0) { return 'Please enter a valid positive number'; } return null; }, ), const SizedBox(height: 16), // --- Category Dropdown and Add Button (Only for Expenses, uses StreamBuilder) --- if (!isIncome) StreamBuilder>( 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('Error loading categories: ${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 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 Row( crossAxisAlignment: CrossAxisAlignment.start, // Align items to the top children: [ // Dropdown takes most space Expanded( child: DropdownButtonFormField( value: _selectedCategoryName, // Use the name state variable decoration: InputDecoration( labelText: 'Category', 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), // Adjust padding if needed ), // Map CategoryDb objects to DropdownMenuItem items: expenseCategories.map((db.CategoryDb category) { // Get icon data using the new utility function final iconData = CategoryUtils.getIconFromString(category.icon); final colorData = Color(category.color); return DropdownMenuItem( value: category.name, // Value is the category name (String) child: Row( children: [ Icon(iconData, color: colorData, size: 20), const SizedBox(width: 10), Text(category.name), ], ), ); }).toList(), onChanged: (String? newValue) { setState(() { _selectedCategoryName = newValue; // Update the selected name }); }, validator: (value) { // Only validate if it's an expense if (_selectedType == TransactionType.expense && value == null) { return 'Please select a category'; } return null; // No validation needed for income }, ), ), // Add Category Button Padding( padding: const EdgeInsets.only(left: 8.0, top: 8.0), // Add padding to space it out and align vertically child: IconButton( icon: Icon(Icons.add_circle_outline, color: theme.colorScheme.primary), tooltip: 'Создать категорию', // Tooltip in Russian as requested onPressed: _showAddCategoryDialog, // Call the function to show the dialog ), ), ], ); }, ), if (!isIncome) const SizedBox(height: 16), // Spacer only if category row is shown // --- Date and Time Picker --- InkWell( onTap: () => _selectDateTime(context), // Use the combined picker function child: InputDecorator( decoration: InputDecoration( labelText: 'Date & Time', // Updated label 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( // Format date and time nicely DateFormat.yMMMd().add_jm().format(_selectedDate), style: theme.textTheme.bodyLarge, ), ), ), const SizedBox(height: 16), // --- Merchant / Source Field --- TextFormField( controller: _merchantController, decoration: InputDecoration( labelText: isIncome ? 'Source' : 'Merchant / Store', // Dynamic label prefixIcon: Icon(isIncome ? Icons.source_outlined : Icons.storefront_outlined), // Dynamic icon border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), filled: true, fillColor: isDark ? Colors.grey.shade800.withOpacity(0.5) : Colors.grey.shade100, ), textCapitalization: TextCapitalization.words, // No validator needed, can be empty ), const SizedBox(height: 24), // --- Save Button --- SizedBox( width: double.infinity, // Make button full width child: ElevatedButton.icon( onPressed: _submitTransaction, icon: const Icon(Icons.save_alt_rounded), label: Text(isIncome ? 'Save Income' : 'Save Expense'), // Dynamic label style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 14), textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), backgroundColor: isIncome ? Colors.green : theme.colorScheme.primary, // Dynamic color foregroundColor: theme.colorScheme.onPrimary, // Text color on primary ), ), ), const SizedBox(height: 10), // Padding at the bottom ], ), ), ); } }