Files
FinanceApp/finance_app/lib/screens/expenses_screen.dart
T
2025-05-03 16:33:14 +03:00

586 lines
27 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 'dart:math'; // For random data generation in sample add
import '../database/database.dart' as db; // Import database with prefix 'db'
import '../models/category.dart'; // Keep Category model for UI structure (SpendingPieChart)
import '../models/transaction_record.dart' as db;
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
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 _selectedPieIndex = -1; // Index of the selected pie chart slice
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
@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,
);
// 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 transactions based on the selected filter
Stream<List<db.TransactionRecord>> _watchTransactions() {
return widget.database.watchFilteredTransactions(_selectedFilter);
}
// Stream that provides category totals calculated from transactions
Stream<List<Category>> _watchCategoryTotals() {
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;
});
}
// Handles selection of a pie chart slice
void _selectPieCategory(int index) {
setState(() {
// If the same index is selected, deselect (-1), otherwise select the new index
_selectedPieIndex = (_selectedPieIndex == index) ? -1 : index;
});
// Optionally apply filter when pie slice is selected/deselected
// This requires getting the category name from the index, potentially async
// _applyFilterBasedOnPieSelection(index);
}
// Applies the selected filter to the transaction list
void _applyFilter(String filter) {
setState(() {
_selectedFilter = filter;
// The StreamBuilder listening to _watchTransactions() will automatically
// rebuild with the new stream based on the updated _selectedFilter.
});
}
// --- Function to add a sample transaction (for testing/demo) ---
void _addSampleTransaction() async {
final random = Random();
// Get available category names from our utility class
final categories = CategoryUtils.getAllCategoryNames();
// Pick a random category, amount, date, and merchant
final randomCategory = categories[random.nextInt(categories.length)];
final randomAmount = (random.nextDouble() * 100) + 5; // Amount between 5 and 105
final randomDay = random.nextInt(7); // Within the last week
final randomHour = random.nextInt(24);
final randomMerchant = ['Amazon', 'Local Cafe', 'Gas Station', 'Online Store', 'Supermarket'][random.nextInt(5)];
// Create a companion object for inserting into the Drift table
final newTransaction = db.TransactionsCompanion.insert(
categoryName: randomCategory,
amount: randomAmount,
date: DateTime.now().subtract(Duration(days: randomDay, hours: randomHour)),
merchant: randomMerchant,
);
try {
// Insert the transaction into the database
await widget.database.addTransaction(newTransaction);
print('Sample transaction added: $randomCategory - \$${randomAmount.toStringAsFixed(2)}');
// Show a confirmation message
if (mounted) { // Check if the widget is still in the tree
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Added ${randomCategory} transaction'),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
}
} catch (e) {
print('Error adding transaction: $e');
// Show an error message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error adding transaction: $e'),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
),
);
}
}
}
// --- End of add sample transaction 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 StreamBuilder to get category totals for the summary and pie chart
body: StreamBuilder<List<Category>>(
stream: _watchCategoryTotals(),
builder: (context, categorySnapshot) {
// Handle loading state
if (categorySnapshot.connectionState == ConnectionState.waiting && !categorySnapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
// Handle error state
if (categorySnapshot.hasError) {
return Center(child: Text('Error loading categories: ${categorySnapshot.error}'));
}
// Get categories data (or empty list if null)
final categories = categorySnapshot.data ?? [];
// Calculate total expenses from the categories stream data
final totalExpenses = categories.fold(0.0, (sum, item) => sum + item.amount);
// 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(
'Total Expenses This Period', // More descriptive title
style: theme.textTheme.titleMedium, // Use theme style
),
const SizedBox(height: 8),
// Display total expenses amount
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center, // Align baseline
children: [
Text(
'\$',
style: TextStyle(
fontSize: 20, // Smaller dollar sign
fontWeight: FontWeight.w500, // Medium weight
color: theme.colorScheme.primary,
),
),
Text(
NumberFormat.currency(symbol: '', decimalDigits: 2).format(totalExpenses), // Format number
style: TextStyle(
fontSize: 36, // Slightly smaller amount
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
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: [
const SummaryItem( // Example Income - Replace with real data
icon: Icons.arrow_downward_rounded,
title: 'Income',
amount: '\$2,450.00',
color: Colors.green,
),
// Vertical divider
Container(
height: 35,
width: 1,
color: theme.dividerColor.withOpacity(0.5),
),
SummaryItem( // Expenses using calculated total
icon: Icons.arrow_upward_rounded,
title: 'Expenses',
amount: '\$${NumberFormat.currency(symbol: '', decimalDigits: 2).format(totalExpenses)}',
color: Colors.red,
),
],
),
],
),
),
),
),
// --- Pie Chart Section ---
ExpandableSection(
title: 'Spending Breakdown',
icon: Icons.pie_chart_outline_rounded, // Updated icon
isExpanded: _isPieChartExpanded,
onTap: _togglePieChartVisibility,
heightFactor: _pieChartHeightFactor, // Animation controller
child: SpendingPieChart(
categories: categories, // Pass categories from stream snapshot
totalExpenses: totalExpenses, // Pass calculated total
selectedPieIndex: _selectedPieIndex,
onSelectPieCategory: _selectPieCategory, // Callback for selection
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, // 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,
),
],
),
),
),
// "See All" button (optional)
// TextButton(
// onPressed: () { /* Navigate to full list */ },
// child: Text('See All'),
// ),
],
),
],
),
),
// --- Filter Chips Row (Animated Visibility) ---
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
FilterChipWidget(
label: 'All',
isSelected: _selectedFilter == 'All',
onTap: () => _applyFilter('All')
),
// Dynamically generate filter chips from categories
...CategoryUtils.getAllCategoryNames().map((name) =>
FilterChipWidget(
label: name,
isSelected: _selectedFilter == name,
onTap: () => _applyFilter(name)
)
).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.TransactionRecord>>(
stream: _watchTransactions(), // Stream based on _selectedFilter
builder: (context, transactionSnapshot) {
// Handle loading state for transactions
if (transactionSnapshot.connectionState == ConnectionState.waiting) {
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) {
return SizedBox(
height: 150,
child: Center(
child: Text(
_selectedFilter == 'All'
? 'No transactions yet.'
: 'No transactions found for $_selectedFilter.',
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) {
final transaction = transactions[index];
// Use the TransactionListItem widget
// Pass a dummy animation for FadeTransition inside item
return TransactionListItem(
transaction: transaction,
animation: kAlwaysCompleteAnimation, // Required by FadeTransition
);
},
);
},
),
),
),
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: (index) {
setState(() {
_selectedNavIndex = index;
// TODO: Handle navigation based on index (e.g., switch screens)
});
},
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',
),
// Example with Badge (replace with actual notification count)
// BottomNavigationBarItem(
// icon: Badge(
// label: Text('3'), // Example badge count
// child: Icon(Icons.notifications_none_rounded),
// ),
// activeIcon: Badge( // Optional: different badge style when active
// label: Text('3'),
// child: Icon(Icons.notifications_rounded),
// ),
// label: 'Notifications',
// ),
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: _addSampleTransaction, // Add sample data on press
tooltip: 'Add Transaction',
icon: const Icon(Icons.add),
label: const Text('Add'),
),
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, // Standard location
);
}
}