Files
FinanceApp2/lib/widgets/spending_pie_chart.dart
2025-05-12 19:08:22 +03:00

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,
);
});
}
}