5 Commits
3 changed files with 152 additions and 117 deletions
+3 -13
View File
@@ -41,7 +41,7 @@ class _ExpensesScreenState extends State<ExpensesScreen> with TickerProviderStat
late Animation<double> _pieChartHeightFactor; late Animation<double> _pieChartHeightFactor;
// State variables // State variables
int _selectedPieIndex = -1; // Index of the selected pie chart slice // Removed _selectedPieIndex as it's now managed by SpendingPieChart
int _selectedNavIndex = 0; // Index for bottom navigation bar int _selectedNavIndex = 0; // Index for bottom navigation bar
bool _isPieChartExpanded = true; // Controls visibility of the pie chart section bool _isPieChartExpanded = true; // Controls visibility of the pie chart section
bool _isFilterVisible = false; // Controls visibility of the filter chips bool _isFilterVisible = false; // Controls visibility of the filter chips
@@ -113,16 +113,7 @@ class _ExpensesScreenState extends State<ExpensesScreen> with TickerProviderStat
}); });
} }
// Handles selection of a pie chart slice // Removed _selectPieCategory method as it's now handled internally by SpendingPieChart
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 // Applies the selected filter to the transaction list
void _applyFilter(String filter) { void _applyFilter(String filter) {
@@ -358,8 +349,7 @@ class _ExpensesScreenState extends State<ExpensesScreen> with TickerProviderStat
child: SpendingPieChart( child: SpendingPieChart(
categories: categories, // Pass categories from stream snapshot categories: categories, // Pass categories from stream snapshot
totalExpenses: totalExpenses, // Pass calculated total totalExpenses: totalExpenses, // Pass calculated total
selectedPieIndex: _selectedPieIndex, // Removed selectedPieIndex and onSelectPieCategory props
onSelectPieCategory: _selectPieCategory, // Callback for selection
animation: _pieChartAnimation, // Appearance animation animation: _pieChartAnimation, // Appearance animation
), ),
), ),
+44 -30
View File
@@ -4,22 +4,36 @@ import 'package:intl/intl.dart'; // For number formatting
import '../models/category.dart'; // Keep using the Category model for UI structure import '../models/category.dart'; // Keep using the Category model for UI structure
class SpendingPieChart extends StatelessWidget { // Changed to StatefulWidget to manage its own selection state
class SpendingPieChart extends StatefulWidget {
final List<Category> categories; // Expect List<Category> from database calculation final List<Category> categories; // Expect List<Category> from database calculation
final double totalExpenses; final double totalExpenses;
final int selectedPieIndex;
final Function(int) onSelectPieCategory; // Callback when a slice is selected/deselected
final Animation<double> animation; // For fade/scale animation final Animation<double> animation; // For fade/scale animation
const SpendingPieChart({ const SpendingPieChart({
Key? key, Key? key,
required this.categories, required this.categories,
required this.totalExpenses, required this.totalExpenses,
required this.selectedPieIndex,
required this.onSelectPieCategory,
required this.animation, required this.animation,
// Removed selectedPieIndex and onSelectPieCategory
}) : super(key: key); }) : super(key: key);
@override
State<SpendingPieChart> createState() => _SpendingPieChartState();
}
class _SpendingPieChartState extends State<SpendingPieChart> {
// State for the selected index is now managed internally
int _selectedPieIndex = -1;
// Handles selection logic internally
void _handlePieTap(int index) {
setState(() {
// If the same index is selected, deselect (-1), otherwise select the new index
_selectedPieIndex = (_selectedPieIndex == index) ? -1 : index;
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
@@ -27,11 +41,11 @@ class SpendingPieChart extends StatelessWidget {
final currencyFormatter = NumberFormat.currency(locale: 'en_US', symbol: '\$'); // Or your preferred locale/symbol final currencyFormatter = NumberFormat.currency(locale: 'en_US', symbol: '\$'); // Or your preferred locale/symbol
// Handle the case where there are no categories to display // Handle the case where there are no categories to display
if (categories.isEmpty) { if (widget.categories.isEmpty) {
return AnimatedBuilder( // Still use animation for consistency return AnimatedBuilder( // Still use animation for consistency
animation: animation, animation: widget.animation,
builder: (context, child) => Opacity( builder: (context, child) => Opacity(
opacity: animation.value, opacity: widget.animation.value,
child: Container( child: Container(
height: 230, // Maintain similar height to the chart version height: 230, // Maintain similar height to the chart version
alignment: Alignment.center, alignment: Alignment.center,
@@ -48,12 +62,12 @@ class SpendingPieChart extends StatelessWidget {
// Use AnimatedBuilder to apply the fade/scale animation // Use AnimatedBuilder to apply the fade/scale animation
return AnimatedBuilder( return AnimatedBuilder(
animation: animation, animation: widget.animation,
builder: (context, child) { builder: (context, child) {
return Transform.scale( return Transform.scale(
scale: animation.value, // Apply scale animation scale: widget.animation.value, // Apply scale animation
child: Opacity( child: Opacity(
opacity: animation.value, // Apply fade animation opacity: widget.animation.value, // Apply fade animation
child: Container( child: Container(
padding: const EdgeInsets.symmetric(vertical: 8.0), // Reduced vertical padding padding: const EdgeInsets.symmetric(vertical: 8.0), // Reduced vertical padding
height: 230, // Fixed height for the chart and legend area height: 230, // Fixed height for the chart and legend area
@@ -76,13 +90,13 @@ class SpendingPieChart extends StatelessWidget {
if (section != null) { if (section != null) {
// Tap occurred ON a section // Tap occurred ON a section
final touchedIndex = section.touchedSectionIndex; final touchedIndex = section.touchedSectionIndex;
// Toggle selection: if tapped section is already selected, deselect (-1), otherwise select it. // Use internal handler to update state
onSelectPieCategory(touchedIndex == selectedPieIndex ? -1 : touchedIndex); _handlePieTap(touchedIndex);
} else { } else {
// Tap occurred OUTSIDE any section // Tap occurred OUTSIDE any section
// Deselect if something was selected // Deselect if something was selected
if (selectedPieIndex != -1) { if (_selectedPieIndex != -1) {
onSelectPieCategory(-1); _handlePieTap(-1); // Pass -1 to deselect
} }
} }
} }
@@ -99,7 +113,7 @@ class SpendingPieChart extends StatelessWidget {
swapAnimationCurve: Curves.easeInOut, swapAnimationCurve: Curves.easeInOut,
), ),
// --- Center Text (Displayed when a slice is selected) --- // --- Center Text (Displayed when a slice is selected) ---
if (selectedPieIndex != -1) if (_selectedPieIndex != -1)
_buildCenterText(context, currencyFormatter) _buildCenterText(context, currencyFormatter)
else // Optional: Display total or default text when nothing is selected else // Optional: Display total or default text when nothing is selected
_buildDefaultCenterText(context, currencyFormatter), _buildDefaultCenterText(context, currencyFormatter),
@@ -113,7 +127,7 @@ class SpendingPieChart extends StatelessWidget {
flex: 4, // Allocate space for the legend flex: 4, // Allocate space for the legend
// Use ListView for scrollable legend if many categories // Use ListView for scrollable legend if many categories
child: ListView.builder( child: ListView.builder(
itemCount: categories.length, itemCount: widget.categories.length,
padding: const EdgeInsets.only(right: 8), // Padding for legend items padding: const EdgeInsets.only(right: 8), // Padding for legend items
itemBuilder: (context, index) => _buildPieLegendItem(context, index), itemBuilder: (context, index) => _buildPieLegendItem(context, index),
), ),
@@ -131,11 +145,11 @@ class SpendingPieChart extends StatelessWidget {
Widget _buildCenterText(BuildContext context, NumberFormat formatter) { Widget _buildCenterText(BuildContext context, NumberFormat formatter) {
final theme = Theme.of(context); final theme = Theme.of(context);
// Check if selectedPieIndex is valid before accessing categories // Check if selectedPieIndex is valid before accessing categories
if (selectedPieIndex < 0 || selectedPieIndex >= categories.length) { if (_selectedPieIndex < 0 || _selectedPieIndex >= widget.categories.length) {
// Return an empty container or default text if index is invalid // Return an empty container or default text if index is invalid
return _buildDefaultCenterText(context, formatter); return _buildDefaultCenterText(context, formatter);
} }
final selectedCategory = categories[selectedPieIndex]; final selectedCategory = widget.categories[_selectedPieIndex];
return Column( return Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@@ -177,7 +191,7 @@ class SpendingPieChart extends StatelessWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
formatter.format(totalExpenses), // Display total expenses formatter.format(widget.totalExpenses), // Display total expenses
style: theme.textTheme.titleMedium?.copyWith( style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: theme.textTheme.bodyLarge?.color // Use default text color color: theme.textTheme.bodyLarge?.color // Use default text color
@@ -193,18 +207,18 @@ class SpendingPieChart extends StatelessWidget {
Widget _buildPieLegendItem(BuildContext context, int index) { Widget _buildPieLegendItem(BuildContext context, int index) {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
final theme = Theme.of(context); final theme = Theme.of(context);
final isSelected = index == selectedPieIndex; // Check if this item is selected final isSelected = index == _selectedPieIndex; // Check if this item is selected using internal state
// Check if index is valid before accessing categories // Check if index is valid before accessing categories
if (index < 0 || index >= categories.length) { if (index < 0 || index >= widget.categories.length) {
return const SizedBox.shrink(); // Return empty if index is invalid return const SizedBox.shrink(); // Return empty if index is invalid
} }
final category = categories[index]; final category = widget.categories[index];
// Calculate percentage, handle totalExpenses being zero // Calculate percentage, handle totalExpenses being zero
final percentage = totalExpenses > 0 ? (category.amount / totalExpenses * 100) : 0.0; final percentage = widget.totalExpenses > 0 ? (category.amount / widget.totalExpenses * 100) : 0.0;
// Use InkWell for tap feedback and GestureDetector for tap logic // Use InkWell for tap feedback and GestureDetector for tap logic
return InkWell( return InkWell(
onTap: () => onSelectPieCategory(isSelected ? -1 : index), // Toggle selection on tap onTap: () => _handlePieTap(index), // Use internal handler on tap
borderRadius: BorderRadius.circular(8), // Match border radius borderRadius: BorderRadius.circular(8), // Match border radius
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 200), // Animation for selection change duration: const Duration(milliseconds: 200), // Animation for selection change
@@ -264,21 +278,21 @@ class SpendingPieChart extends StatelessWidget {
List<PieChartSectionData> _generatePieSections(BuildContext context) { List<PieChartSectionData> _generatePieSections(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
return List.generate(categories.length, (i) { return List.generate(widget.categories.length, (i) {
// Check if index is valid before accessing categories // Check if index is valid before accessing categories
if (i < 0 || i >= categories.length) { if (i < 0 || i >= widget.categories.length) {
// This should ideally not happen if List.generate is used correctly, // This should ideally not happen if List.generate is used correctly,
// but adding a safeguard. // but adding a safeguard.
return PieChartSectionData(); // Return an empty section return PieChartSectionData(); // Return an empty section
} }
final isTouched = i == selectedPieIndex; // Check if this slice is selected final isTouched = i == _selectedPieIndex; // Check if this slice is selected using internal state
// Make selected slice slightly larger // Make selected slice slightly larger
final double radius = isTouched ? 65 : 55; final double radius = isTouched ? 65 : 55;
// Make title font slightly larger when selected // Make title font slightly larger when selected
final double titleFontSize = isTouched ? 14 : 12; final double titleFontSize = isTouched ? 14 : 12;
final category = categories[i]; final category = widget.categories[i];
// Calculate percentage for the title // Calculate percentage for the title
final percentage = totalExpenses > 0 ? (category.amount / totalExpenses * 100) : 0; final percentage = widget.totalExpenses > 0 ? (category.amount / widget.totalExpenses * 100) : 0;
return PieChartSectionData( return PieChartSectionData(
color: category.colorCode.withOpacity(isDark ? 0.85 : 1.0), // Use category color color: category.colorCode.withOpacity(isDark ? 0.85 : 1.0), // Use category color
@@ -1,12 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../database/database.dart' as db; // Import database with prefix 'db' import '../database/database.dart' as db; // Keep alias if needed elsewhere, though not used here directly
import '../models/transaction_record.dart'; import '../models/transaction_record.dart';
import '../utils/category_utils.dart'; // Import category utils import '../utils/category_utils.dart';
class TransactionListItem extends StatelessWidget { class TransactionListItem extends StatelessWidget {
final TransactionRecord transaction; // Use the Drift-generated Transaction class final TransactionRecord transaction;
final Animation<double> animation; // Keep animation for potential future use final Animation<double> animation;
const TransactionListItem({ const TransactionListItem({
Key? key, Key? key,
@@ -19,10 +19,10 @@ class TransactionListItem extends StatelessWidget {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
final theme = Theme.of(context); final theme = Theme.of(context);
// Get category details (icon, color) using the utility // Get category details
final categoryDetails = CategoryUtils.getCategoryDetails(transaction.categoryName); final categoryDetails = CategoryUtils.getCategoryDetails(transaction.categoryName);
// Format the date and time using intl package // Format dates
final dateFormatter = DateFormat.MMMd(); // e.g., Sep 10 final dateFormatter = DateFormat.MMMd(); // e.g., Sep 10
final timeFormatter = DateFormat.jm(); // e.g., 5:08 PM final timeFormatter = DateFormat.jm(); // e.g., 5:08 PM
@@ -34,87 +34,118 @@ class TransactionListItem extends StatelessWidget {
String displayDate; String displayDate;
if (transactionDay == today) { if (transactionDay == today) {
displayDate = 'Today, ${timeFormatter.format(transaction.date)}'; displayDate = 'Today';
} else if (transactionDay == yesterday) { } else if (transactionDay == yesterday) {
displayDate = 'Yesterday, ${timeFormatter.format(transaction.date)}'; displayDate = 'Yesterday';
} else { } else {
// Format for other dates (e.g., "Sep 10, 5:08 PM") displayDate = dateFormatter.format(transaction.date);
displayDate = '${dateFormatter.format(transaction.date)}, ${timeFormatter.format(transaction.date)}';
} }
String displayTime = timeFormatter.format(transaction.date);
// Determine text colors based on theme // Determine text colors based on theme
Color primaryTextColor = theme.textTheme.bodyLarge?.color ?? (isDark ? Colors.white : Colors.black87);
Color secondaryTextColor = theme.textTheme.bodyMedium?.color ?? (isDark ? Colors.white70 : Colors.grey.shade600); Color secondaryTextColor = theme.textTheme.bodyMedium?.color ?? (isDark ? Colors.white70 : Colors.grey.shade600);
Color amountColor = isDark ? Colors.red.shade200 : Colors.red.shade700; // Expense color Color amountColor = isDark ? Colors.redAccent.shade100 : Colors.red.shade700; // Keep amount color distinct
// Use FadeTransition for item appearance (works with ListView.builder) // Use FadeTransition for item appearance
return FadeTransition( return FadeTransition(
opacity: animation, // Apply fade animation opacity: animation,
child: InkWell( // Make the item tappable // Use SizeTransition for a smoother vertical entrance/exit animation
onTap: () { child: SizeTransition(
// TODO: Implement navigation to transaction details screen or edit action sizeFactor: animation,
print('Tapped transaction: ${transaction.id}'); child: Card(
}, // Reduce vertical margin to decrease space between items
child: Padding( margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.5), // Reduced vertical margin
padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 16.0), // Consistent padding shape: RoundedRectangleBorder(
child: Row( borderRadius: BorderRadius.circular(12.0),
children: [ ),
// Icon container with category color/icon elevation: 1.5, // Subtle elevation
Container( child: ListTile(
padding: const EdgeInsets.all(10), // Reduce vertical padding and apply compact density to decrease height
decoration: BoxDecoration( contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 2.0), // Reduced vertical padding
color: categoryDetails.colorCode.withOpacity(isDark ? 0.25 : 0.15), // Use category color with opacity visualDensity: VisualDensity.compact, // Make the ListTile more compact vertically
borderRadius: BorderRadius.circular(12), // Rounded corners // Leading icon
), leading: Container(
child: Icon( width: 40, // Slightly larger icon background
categoryDetails.iconCode, // Use category icon height: 40,
color: categoryDetails.colorCode, // Use category color for icon decoration: BoxDecoration(
size: 20, // Icon size // Use a slightly less opaque background for better icon visibility
), color: categoryDetails.colorCode.withOpacity(isDark ? 0.25 : 0.18),
shape: BoxShape.circle,
), ),
const SizedBox(width: 12), // Spacing child: Icon(
categoryDetails.iconCode,
color: categoryDetails.colorCode.withOpacity(isDark ? 0.95 : 1.0),
size: 20, // Slightly larger icon
),
),
// Transaction details (Merchant/Category and Date/Time) // Title: Category Name
Expanded( title: Text(
child: Column( transaction.categoryName,
crossAxisAlignment: CrossAxisAlignment.start, style: const TextStyle(
children: [ fontWeight: FontWeight.w600,
// Display Merchant if available, otherwise Category Name fontSize: 14.5, // Slightly larger title font
Text(
transaction.merchant.isNotEmpty ? transaction.merchant : transaction.categoryName,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500, // Medium weight for primary text
color: primaryTextColor,
),
maxLines: 1, // Prevent wrapping
overflow: TextOverflow.ellipsis, // Handle long text
),
const SizedBox(height: 4), // Spacing between lines
// Display formatted date/time
Text(
displayDate,
style: theme.textTheme.bodyMedium?.copyWith(
color: secondaryTextColor, // Lighter color for secondary text
fontSize: 12, // Smaller font size
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
), ),
const SizedBox(width: 12), // Spacing before amount maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Transaction Amount // Subtitle: Merchant and Date
Text( subtitle: Row(
// Format amount as currency (negative for expense) mainAxisAlignment: MainAxisAlignment.spaceBetween, // Push date to the right
NumberFormat.currency(symbol: '-\$', decimalDigits: 2).format(transaction.amount), children: [
style: theme.textTheme.bodyLarge?.copyWith( Expanded( // Allow merchant name to take available space and ellipsis
fontWeight: FontWeight.w600, // Bold weight for amount child: Text(
color: amountColor, // Use expense color transaction.merchant,
style: TextStyle(
fontSize: 12.5, // Slightly larger subtitle font
color: secondaryTextColor,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
), ),
), // Optionally add a small spacer if needed
], // const SizedBox(width: 8),
Text(
displayDate, // Show only the date part in the subtitle
style: TextStyle(
fontSize: 11.5, // Smaller date font
color: secondaryTextColor.withOpacity(0.8), // Slightly faded date
),
),
],
),
// Trailing: Amount and Time
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center, // Center vertically
crossAxisAlignment: CrossAxisAlignment.end, // Align text to the right
children: [
Text(
'- \$${transaction.amount.toStringAsFixed(2)}',
style: TextStyle(
color: amountColor,
fontWeight: FontWeight.w600,
fontSize: 14, // Consistent font size with title
),
),
const SizedBox(height: 3), // Small space between amount and time
Text(
displayTime, // Show time below the amount
style: TextStyle(
fontSize: 11.5, // Smaller time font
color: secondaryTextColor.withOpacity(0.8), // Slightly faded time
),
),
],
),
onTap: () {
// TODO: Implement navigation to transaction details
print('Tapped transaction: ${transaction.id}');
},
), ),
), ),
), ),