Init
This commit is contained in:
commit
295625c70f
151 files changed
+10677
No files matched your search
@@ -0,0 +1,318 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user