marco.pms.mobileapp/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart
Vaibhav Surve 177f8c32e2 feat: Add Expense By Status Widget and related models
- Implemented ExpenseByStatusWidget to display expenses categorized by status.
- Added ExpenseReportResponse, ExpenseTypeReportResponse, and related models for handling expense data.
- Introduced Skeleton loaders for expense status and charts for better UI experience during data loading.
- Updated DashboardScreen to include the new ExpenseByStatusWidget and ensure proper integration with existing components.
2025-11-01 17:29:16 +05:30

654 lines
21 KiB
Dart

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:marco/controller/dashboard/dashboard_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/model/dashboard/expense_type_report_model.dart';
import 'package:marco/helpers/utils/utils.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
class ExpenseTypeReportChart extends StatelessWidget {
ExpenseTypeReportChart({Key? key}) : super(key: key);
final DashboardController _controller = Get.find<DashboardController>();
// Extended color palette for multiple projects
static const List<Color> _flatColors = [
Color(0xFFE57373), // Red 300
Color(0xFF64B5F6), // Blue 300
Color(0xFF81C784), // Green 300
Color(0xFFFFB74D), // Orange 300
Color(0xFFBA68C8), // Purple 300
Color(0xFFFF8A65), // Deep Orange 300
Color(0xFF4DB6AC), // Teal 300
Color(0xFFA1887F), // Brown 400
Color(0xFFDCE775), // Lime 300
Color(0xFF9575CD), // Deep Purple 300
Color(0xFF7986CB), // Indigo 300
Color(0xFFAED581), // Light Green 300
Color(0xFFFF7043), // Deep Orange 400
Color(0xFF4FC3F7), // Light Blue 300
Color(0xFFFFD54F), // Amber 300
Color(0xFF90A4AE), // Blue Grey 300
Color(0xFFE573BB), // Pink 300
Color(0xFF81D4FA), // Light Blue 200
Color(0xFFBCAAA4), // Brown 300
Color(0xFFA5D6A7), // Green 300
Color(0xFFCE93D8), // Purple 200
Color(0xFFFF8A65), // Deep Orange 300 (repeat to fill)
Color(0xFF80CBC4), // Teal 200
Color(0xFFFFF176), // Yellow 300
Color(0xFF90CAF9), // Blue 200
Color(0xFFE0E0E0), // Grey 300
Color(0xFFF48FB1), // Pink 200
Color(0xFFA1887F), // Brown 400 (repeat)
Color(0xFFB0BEC5), // Blue Grey 200
Color(0xFF81C784), // Green 300 (repeat)
Color(0xFFFFB74D), // Orange 300 (repeat)
Color(0xFF64B5F6), // Blue 300 (repeat)
];
Color _getSeriesColor(int index) => _flatColors[index % _flatColors.length];
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final isMobile = screenWidth < 600;
return Obx(() {
final isLoading = _controller.isExpenseTypeReportLoading.value;
final data = _controller.expenseTypeReportData.value;
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.05),
blurRadius: 6,
spreadRadius: 1,
offset: const Offset(0, 2),
),
],
),
padding: EdgeInsets.symmetric(
vertical: isMobile ? 16 : 20,
horizontal: isMobile ? 12 : 20,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Chart Header
isLoading
? SkeletonLoaders.dateSkeletonLoader()
: _ChartHeader(controller: _controller),
const SizedBox(height: 12),
// Date Range Picker
isLoading
? Row(
children: [
Expanded(child: SkeletonLoaders.dateSkeletonLoader()),
const SizedBox(width: 8),
Expanded(child: SkeletonLoaders.dateSkeletonLoader()),
],
)
: _DateRangePicker(controller: _controller),
const SizedBox(height: 16),
// Chart Area
SizedBox(
height: isMobile ? 350 : 400,
child: isLoading
? SkeletonLoaders.chartSkeletonLoader()
: (data == null || data.report.isEmpty)
? const _NoDataMessage()
: _ExpenseDonutChart(
data: data,
getSeriesColor: _getSeriesColor,
isMobile: isMobile,
),
),
],
),
);
});
}
}
// -----------------------------------------------------------------------------
// Chart Header
// -----------------------------------------------------------------------------
class _ChartHeader extends StatelessWidget {
const _ChartHeader({Key? key, required this.controller}) : super(key: key);
final DashboardController controller;
@override
Widget build(BuildContext context) {
return Obx(() {
final data = controller.expenseTypeReportData.value;
// Calculate total from totalApprovedAmount only
final total = data?.report.fold<double>(
0,
(sum, e) => sum + e.totalApprovedAmount,
) ??
0;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium('Project Expense Analytics', fontWeight: 700),
const SizedBox(height: 2),
MyText.bodySmall('Approved expenses by project',
color: Colors.grey),
],
),
),
if (total > 0)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.blueAccent.withOpacity(0.15),
borderRadius: BorderRadius.circular(5),
border: Border.all(color: Colors.blueAccent, width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
MyText.bodySmall(
'Total Approved',
color: Colors.blueAccent,
fontSize: 10,
),
MyText.bodyMedium(
Utils.formatCurrency(total),
color: Colors.blueAccent,
fontWeight: 700,
fontSize: 14,
),
],
),
),
],
);
});
}
}
// -----------------------------------------------------------------------------
// Date Range Picker
// -----------------------------------------------------------------------------
class _DateRangePicker extends StatelessWidget {
const _DateRangePicker({Key? key, required this.controller})
: super(key: key);
final DashboardController controller;
Future<void> _selectDate(
BuildContext context, bool isStartDate, DateTime currentDate) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: currentDate,
firstDate: DateTime(2020),
lastDate: DateTime.now(),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(
primary: Colors.blueAccent,
onPrimary: Colors.white,
onSurface: Colors.black,
),
),
child: child!,
);
},
);
if (picked != null) {
if (isStartDate) {
controller.expenseReportStartDate.value = picked;
} else {
controller.expenseReportEndDate.value = picked;
}
}
}
@override
Widget build(BuildContext context) {
return Obx(() {
final startDate = controller.expenseReportStartDate.value;
final endDate = controller.expenseReportEndDate.value;
return Row(
children: [
_DateBox(
label: 'Start Date',
date: startDate,
onTap: () => _selectDate(context, true, startDate),
icon: Icons.calendar_today_outlined,
),
const SizedBox(width: 8),
_DateBox(
label: 'End Date',
date: endDate,
onTap: () => _selectDate(context, false, endDate),
icon: Icons.event_outlined,
),
],
);
});
}
}
class _DateBox extends StatelessWidget {
final String label;
final DateTime date;
final VoidCallback onTap;
final IconData icon;
const _DateBox({
Key? key,
required this.label,
required this.date,
required this.onTap,
required this.icon,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Expanded(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(5),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: Colors.blueAccent.withOpacity(0.08),
border: Border.all(color: Colors.blueAccent.withOpacity(0.3)),
borderRadius: BorderRadius.circular(5),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.blueAccent.withOpacity(0.15),
borderRadius: BorderRadius.circular(4),
),
child: Icon(
icon,
size: 14,
color: Colors.blueAccent,
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 10,
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
Utils.formatDate(date),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.blueAccent,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
),
),
);
}
}
// -----------------------------------------------------------------------------
// No Data Message
// -----------------------------------------------------------------------------
class _NoDataMessage extends StatelessWidget {
const _NoDataMessage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.donut_large_outlined,
color: Colors.grey.shade400, size: 48),
const SizedBox(height: 10),
MyText.bodyMedium(
'No expense data available for this range.',
textAlign: TextAlign.center,
color: Colors.grey.shade500,
),
],
),
);
}
}
// -----------------------------------------------------------------------------
// Donut Chart
// -----------------------------------------------------------------------------
class _ExpenseDonutChart extends StatefulWidget {
const _ExpenseDonutChart({
Key? key,
required this.data,
required this.getSeriesColor,
required this.isMobile,
}) : super(key: key);
final ExpenseTypeReportData data;
final Color Function(int index) getSeriesColor;
final bool isMobile;
@override
State<_ExpenseDonutChart> createState() => _ExpenseDonutChartState();
}
class _ExpenseDonutChartState extends State<_ExpenseDonutChart> {
late TooltipBehavior _tooltipBehavior;
late SelectionBehavior _selectionBehavior;
@override
void initState() {
super.initState();
_tooltipBehavior = TooltipBehavior(
enable: true,
builder: (dynamic data, dynamic point, dynamic series, int pointIndex,
int seriesIndex) {
final total = widget.data.report
.fold<double>(0, (sum, e) => sum + e.totalApprovedAmount);
final value = data.value as double;
final percentage = total > 0 ? (value / total * 100) : 0;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: Colors.blueAccent,
borderRadius: BorderRadius.circular(4),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
data.label,
style: const TextStyle(
color: Colors.white, fontWeight: FontWeight.w600),
),
const SizedBox(height: 2),
Text(
Utils.formatCurrency(value),
style: const TextStyle(
color: Colors.white, fontWeight: FontWeight.w600),
),
Text(
'${percentage.toStringAsFixed(1)}%',
style: const TextStyle(
color: Colors.white70,
fontWeight: FontWeight.w500,
fontSize: 10),
),
],
),
);
},
elevation: 4,
animationDuration: 300,
);
_selectionBehavior = SelectionBehavior(
enable: true,
selectedColor: Colors.white,
selectedBorderColor: Colors.blueAccent,
selectedBorderWidth: 3,
unselectedOpacity: 0.5,
);
}
@override
Widget build(BuildContext context) {
// Create donut data from project items using totalApprovedAmount
final List<_DonutData> donutData = widget.data.report
.asMap()
.entries
.map((entry) => _DonutData(
entry.value.projectName.isEmpty
? 'Project ${entry.key + 1}'
: entry.value.projectName,
entry.value.totalApprovedAmount,
widget.getSeriesColor(entry.key),
Icons.folder_outlined,
))
.toList();
// Filter out zero values for cleaner visualization
final filteredData = donutData.where((data) => data.value > 0).toList();
if (filteredData.isEmpty) {
return const Center(
child: Text(
'No approved expense data for the selected range.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey),
),
);
}
// Calculate total for center display
final total = filteredData.fold<double>(0, (sum, item) => sum + item.value);
return Column(
children: [
Expanded(
child: SfCircularChart(
margin: EdgeInsets.zero,
legend: Legend(
isVisible: true,
position: LegendPosition.bottom,
overflowMode: LegendItemOverflowMode.wrap,
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
iconHeight: 10,
iconWidth: 10,
itemPadding: widget.isMobile ? 6 : 10,
padding: widget.isMobile ? 10 : 14,
),
tooltipBehavior: _tooltipBehavior,
// Center annotation showing total approved amount
annotations: <CircularChartAnnotation>[
CircularChartAnnotation(
widget: Container(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check_circle_outline,
color: Colors.green.shade600,
size: widget.isMobile ? 28 : 32,
),
const SizedBox(height: 6),
Text(
'Total Approved',
style: TextStyle(
fontSize: widget.isMobile ? 11 : 12,
color: Colors.grey.shade600,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
Utils.formatCurrency(total),
style: TextStyle(
fontSize: widget.isMobile ? 16 : 18,
color: Colors.green.shade700,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
'${filteredData.length} ${filteredData.length == 1 ? 'Project' : 'Projects'}',
style: TextStyle(
fontSize: widget.isMobile ? 9 : 10,
color: Colors.grey.shade500,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
],
series: <DoughnutSeries<_DonutData, String>>[
DoughnutSeries<_DonutData, String>(
dataSource: filteredData,
xValueMapper: (datum, _) => datum.label,
yValueMapper: (datum, _) => datum.value,
pointColorMapper: (datum, _) => datum.color,
dataLabelMapper: (datum, _) {
final amount = Utils.formatCurrency(datum.value);
return widget.isMobile
? '$amount'
: '${datum.label}\n$amount';
},
dataLabelSettings: DataLabelSettings(
isVisible: true,
labelPosition: ChartDataLabelPosition.outside,
connectorLineSettings: ConnectorLineSettings(
type: ConnectorType.curve,
length: widget.isMobile ? '15%' : '18%',
width: 1.5,
color: Colors.grey.shade400,
),
textStyle: TextStyle(
fontSize: widget.isMobile ? 10 : 11,
fontWeight: FontWeight.w700,
color: Colors.black87,
),
labelIntersectAction: LabelIntersectAction.shift,
),
innerRadius: widget.isMobile ? '40%' : '45%',
radius: widget.isMobile ? '75%' : '80%',
explode: true,
explodeAll: false,
explodeIndex: 0,
explodeOffset: '5%',
explodeGesture: ActivationMode.singleTap,
startAngle: 90,
endAngle: 450,
strokeColor: Colors.white,
strokeWidth: 2.5,
enableTooltip: true,
animationDuration: 1000,
selectionBehavior: _selectionBehavior,
opacity: 0.95,
),
],
),
),
if (!widget.isMobile) ...[
const SizedBox(height: 12),
_ProjectSummary(donutData: filteredData),
],
],
);
}
}
// -----------------------------------------------------------------------------
// Project Summary (Desktop only)
// -----------------------------------------------------------------------------
class _ProjectSummary extends StatelessWidget {
const _ProjectSummary({Key? key, required this.donutData}) : super(key: key);
final List<_DonutData> donutData;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: donutData.map((data) {
return Container(
constraints: const BoxConstraints(minWidth: 120),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: data.color.withOpacity(0.15),
borderRadius: BorderRadius.circular(5),
border: Border.all(
color: data.color.withOpacity(0.4),
width: 1,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(data.icon, color: data.color, size: 18),
const SizedBox(height: 4),
Text(
data.label,
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade700,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
Utils.formatCurrency(data.value),
style: TextStyle(
fontSize: 12,
color: data.color,
fontWeight: FontWeight.w700,
),
),
],
),
);
}).toList(),
);
}
}
class _DonutData {
final String label;
final double value;
final Color color;
final IconData icon;
_DonutData(this.label, this.value, this.color, this.icon);
}