1238 lines
56 KiB
Dart
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
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|