319 lines
14 KiB
Dart
319 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
import 'package:intl/intl.dart'; // For number formatting
|
|
|
|
import '../models/category.dart'; // Keep using the Category model for UI structure
|
|
|
|
// Changed to StatefulWidget to manage its own selection state
|
|
class SpendingPieChart extends StatefulWidget {
|
|
final List<Category> categories; // Expect List<Category> from database calculation
|
|
final double totalExpenses;
|
|
final Animation<double> animation; // For fade/scale animation
|
|
|
|
const SpendingPieChart({
|
|
Key? key,
|
|
required this.categories,
|
|
required this.totalExpenses,
|
|
required this.animation,
|
|
// Removed selectedPieIndex and onSelectPieCategory
|
|
}) : 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
|
|
Widget build(BuildContext context) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final theme = Theme.of(context);
|
|
final currencyFormatter = NumberFormat.currency(locale: 'en_US', symbol: '\$'); // Or your preferred locale/symbol
|
|
|
|
// Handle the case where there are no categories to display
|
|
if (widget.categories.isEmpty) {
|
|
return AnimatedBuilder( // Still use animation for consistency
|
|
animation: widget.animation,
|
|
builder: (context, child) => Opacity(
|
|
opacity: widget.animation.value,
|
|
child: Container(
|
|
height: 230, // Maintain similar height to the chart version
|
|
alignment: Alignment.center,
|
|
padding: const EdgeInsets.all(16),
|
|
child: Text(
|
|
'No spending data for this period.',
|
|
textAlign: TextAlign.center,
|
|
style: theme.textTheme.bodyMedium?.copyWith(color: Colors.grey),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Use AnimatedBuilder to apply the fade/scale animation
|
|
return AnimatedBuilder(
|
|
animation: widget.animation,
|
|
builder: (context, child) {
|
|
return Transform.scale(
|
|
scale: widget.animation.value, // Apply scale animation
|
|
child: Opacity(
|
|
opacity: widget.animation.value, // Apply fade animation
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 8.0), // Reduced vertical padding
|
|
height: 230, // Fixed height for the chart and legend area
|
|
child: Row(
|
|
children: [
|
|
// --- Pie Chart ---
|
|
Expanded(
|
|
flex: 5, // Give more space to the chart itself
|
|
child: Stack( // Use Stack to overlay text on the chart center
|
|
alignment: Alignment.center,
|
|
children: [
|
|
PieChart(
|
|
PieChartData(
|
|
// Handle touch events on the pie chart
|
|
pieTouchData: PieTouchData(
|
|
touchCallback: (FlTouchEvent event, PieTouchResponse? pieTouchResponse) {
|
|
// We are only interested in TapUp events to trigger selection changes
|
|
if (event is FlTapUpEvent) {
|
|
final section = pieTouchResponse?.touchedSection;
|
|
if (section != null) {
|
|
// Tap occurred ON a section
|
|
final touchedIndex = section.touchedSectionIndex;
|
|
// Use internal handler to update state
|
|
_handlePieTap(touchedIndex);
|
|
} else {
|
|
// Tap occurred OUTSIDE any section
|
|
// Deselect if something was selected
|
|
if (_selectedPieIndex != -1) {
|
|
_handlePieTap(-1); // Pass -1 to deselect
|
|
}
|
|
}
|
|
}
|
|
},
|
|
),
|
|
borderData: FlBorderData(show: false), // No border around the chart
|
|
sectionsSpace: 2, // Space between slices
|
|
centerSpaceRadius: 50, // Radius of the center hole
|
|
sections: _generatePieSections(context), // Generate slices data
|
|
startDegreeOffset: -90, // Start chart from the top (12 o'clock)
|
|
),
|
|
// Optional animation when data changes
|
|
swapAnimationDuration: const Duration(milliseconds: 250),
|
|
swapAnimationCurve: Curves.easeInOut,
|
|
),
|
|
// --- Center Text (Displayed when a slice is selected) ---
|
|
if (_selectedPieIndex != -1)
|
|
_buildCenterText(context, currencyFormatter)
|
|
else // Optional: Display total or default text when nothing is selected
|
|
_buildDefaultCenterText(context, currencyFormatter),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8), // Spacing between chart and legend
|
|
|
|
// --- Legend ---
|
|
Expanded(
|
|
flex: 4, // Allocate space for the legend
|
|
// Use ListView for scrollable legend if many categories
|
|
child: ListView.builder(
|
|
itemCount: widget.categories.length,
|
|
padding: const EdgeInsets.only(right: 8), // Padding for legend items
|
|
itemBuilder: (context, index) => _buildPieLegendItem(context, index),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
// Builds the text displayed in the center when a slice is selected
|
|
Widget _buildCenterText(BuildContext context, NumberFormat formatter) {
|
|
final theme = Theme.of(context);
|
|
// Check if selectedPieIndex is valid before accessing categories
|
|
if (_selectedPieIndex < 0 || _selectedPieIndex >= widget.categories.length) {
|
|
// Return an empty container or default text if index is invalid
|
|
return _buildDefaultCenterText(context, formatter);
|
|
}
|
|
final selectedCategory = widget.categories[_selectedPieIndex];
|
|
return Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
selectedCategory.name,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: theme.textTheme.bodyLarge?.color?.withOpacity(0.8),
|
|
),
|
|
textAlign: TextAlign.center,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
formatter.format(selectedCategory.amount), // Format the amount as currency
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: selectedCategory.colorCode, // Use category color for amount
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// Builds the default text displayed in the center when no slice is selected
|
|
Widget _buildDefaultCenterText(BuildContext context, NumberFormat formatter) {
|
|
final theme = Theme.of(context);
|
|
return Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
'Total', // Label for the total amount
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: theme.textTheme.bodyLarge?.color?.withOpacity(0.7),
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
formatter.format(widget.totalExpenses), // Display total expenses
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: theme.textTheme.bodyLarge?.color // Use default text color
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
|
|
// Builds a single item for the legend
|
|
Widget _buildPieLegendItem(BuildContext context, int index) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final theme = Theme.of(context);
|
|
final isSelected = index == _selectedPieIndex; // Check if this item is selected using internal state
|
|
// Check if index is valid before accessing categories
|
|
if (index < 0 || index >= widget.categories.length) {
|
|
return const SizedBox.shrink(); // Return empty if index is invalid
|
|
}
|
|
final category = widget.categories[index];
|
|
// Calculate percentage, handle totalExpenses being zero
|
|
final percentage = widget.totalExpenses > 0 ? (category.amount / widget.totalExpenses * 100) : 0.0;
|
|
|
|
// Use InkWell for tap feedback and GestureDetector for tap logic
|
|
return InkWell(
|
|
onTap: () => _handlePieTap(index), // Use internal handler on tap
|
|
borderRadius: BorderRadius.circular(8), // Match border radius
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200), // Animation for selection change
|
|
margin: const EdgeInsets.symmetric(vertical: 3.0), // Spacing between legend items
|
|
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 6.0), // Padding inside item
|
|
decoration: BoxDecoration(
|
|
// Highlight background if selected
|
|
color: isSelected
|
|
? category.colorCode.withOpacity(isDark ? 0.3 : 0.15)
|
|
: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(8),
|
|
// Add border if selected
|
|
border: Border.all(
|
|
color: isSelected ? category.colorCode.withOpacity(0.8) : Colors.transparent,
|
|
width: 1.5,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
// Color indicator dot
|
|
Container(
|
|
width: 10,
|
|
height: 10,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: category.colorCode.withOpacity(isDark ? 0.9 : 1.0), // Use category color
|
|
),
|
|
),
|
|
const SizedBox(width: 8), // Spacing
|
|
// Category name
|
|
Expanded(
|
|
child: Text(
|
|
category.name,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, // Bold if selected
|
|
color: theme.textTheme.bodyLarge?.color, // Use default body text color
|
|
),
|
|
overflow: TextOverflow.ellipsis, // Prevent long names from wrapping
|
|
),
|
|
),
|
|
const SizedBox(width: 8), // Spacing
|
|
// Percentage text
|
|
Text(
|
|
'${percentage.toStringAsFixed(1)}%', // Format percentage
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, // Bold if selected
|
|
color: theme.textTheme.bodyMedium?.color?.withOpacity(0.7), // Slightly faded color
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Generates the data for each slice (PieChartSectionData) of the pie chart
|
|
List<PieChartSectionData> _generatePieSections(BuildContext context) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
|
|
return List.generate(widget.categories.length, (i) {
|
|
// Check if index is valid before accessing categories
|
|
if (i < 0 || i >= widget.categories.length) {
|
|
// This should ideally not happen if List.generate is used correctly,
|
|
// but adding a safeguard.
|
|
return PieChartSectionData(); // Return an empty section
|
|
}
|
|
final isTouched = i == _selectedPieIndex; // Check if this slice is selected using internal state
|
|
// Make selected slice slightly larger
|
|
final double radius = isTouched ? 65 : 55;
|
|
// Make title font slightly larger when selected
|
|
final double titleFontSize = isTouched ? 14 : 12;
|
|
final category = widget.categories[i];
|
|
// Calculate percentage for the title
|
|
final percentage = widget.totalExpenses > 0 ? (category.amount / widget.totalExpenses * 100) : 0;
|
|
|
|
return PieChartSectionData(
|
|
color: category.colorCode.withOpacity(isDark ? 0.85 : 1.0), // Use category color
|
|
value: category.amount, // Value determines the slice size
|
|
title: '${percentage.toStringAsFixed(0)}%', // Display percentage as title
|
|
radius: radius, // Apply radius (larger if touched)
|
|
titleStyle: TextStyle(
|
|
fontSize: titleFontSize, // Apply font size (larger if touched)
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white.withOpacity(0.9), // White text for contrast on colored slices
|
|
shadows: const [Shadow(color: Colors.black38, blurRadius: 2)], // Subtle shadow for readability
|
|
),
|
|
// Add border to selected slice for emphasis
|
|
borderSide: isTouched
|
|
? BorderSide(color: isDark ? Colors.white60 : Colors.black54, width: 2)
|
|
: BorderSide(color: category.colorCode.withOpacity(0.5), width: 1),
|
|
// Optional: Add badge (icon) to the selected slice
|
|
// badgeWidget: isTouched ? Icon(category.icon, color: Colors.white, size: 16) : null,
|
|
// badgePositionPercentageOffset: .98,
|
|
);
|
|
});
|
|
}
|
|
}
|