586 lines
27 KiB
Dart
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
|
|
);
|
|
}
|
|
}
|