Files
FinanceApp2/lib/screens/expenses_screen.dart
T
2025-05-12 19:08:22 +03:00

1238 lines
56 KiB
Dart

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<ExpensesScreen> createState() => _ExpensesScreenState();
}
class _ExpensesScreenState extends State<ExpensesScreen> with TickerProviderStateMixin {
// Animation controllers for UI elements
late AnimationController _pieChartAnimationController;
late Animation<double> _pieChartAnimation;
late AnimationController _pieChartExpandController;
late Animation<double> _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<List<db.Transaction>> _transactionsStream; // Stream for transactions
late Stream<double> _totalIncomeStream; // Stream for total income
late Stream<double> _totalExpensesStream; // Stream for total expenses
late Stream<List<db.CategoryDb>> _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<List<Category>> _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<db.Transaction?>(
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<List<Category>> for pie chart (expenses only)
_totalIncomeStream, // Stream<double> for total income
_totalExpensesStream, // Stream<double> for total expenses
_categoriesStream, // Stream<List<db.CategoryDb>> 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<Category>? ?? [];
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<db.CategoryDb>? ?? [];
// 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<List<db.Transaction>>(
// 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<Stream<dynamic>> streams;
final Widget Function(BuildContext, List<AsyncSnapshot<dynamic>>) 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<List<dynamic>>( // Use List<dynamic> 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<AsyncSnapshot<dynamic>>.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<List<AsyncSnapshot<dynamic>>> _combineStreams() { ... }
}
// --- Widget for the Add Transaction Form ---
class _AddTransactionForm extends StatefulWidget {
final db.AppDatabase database;
final Stream<List<db.CategoryDb>> 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<FormState>(); // 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<String> _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<void> _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<db.CategoryDb?>( // 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: <Widget>[
// --- 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 <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('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<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('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<String>(
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<String>
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<String>(
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
],
),
),
);
}
}