import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:syncfusion_flutter_charts/charts.dart'; import 'package:on_field_work/controller/dashboard/dashboard_controller.dart'; import 'package:on_field_work/helpers/widgets/my_text.dart'; import 'package:on_field_work/helpers/utils/utils.dart'; import 'package:intl/intl.dart'; // ========================= // CONSTANTS // ========================= class _ChartConstants { static const List flatColors = [ Color(0xFFE57373), Color(0xFF64B5F6), Color(0xFF81C784), Color(0xFFFFB74D), Color(0xFFBA68C8), Color(0xFFFF8A65), Color(0xFF4DB6AC), Color(0xFFA1887F), Color(0xFFDCE775), Color(0xFF9575CD), Color(0xFF7986CB), Color(0xFFAED581), Color(0xFFFF7043), Color(0xFF4FC3F7), Color(0xFFFFD54F), Color(0xFF90A4AE), Color(0xFFE573BB), Color(0xFF81D4FA), Color(0xFFBCAAA4), Color(0xFFA5D6A7), Color(0xFFCE93D8), Color(0xFFFF8A65), Color(0xFF80CBC4), Color(0xFFFFF176), Color(0xFF90CAF9), Color(0xFFE0E0E0), Color(0xFFF48FB1), Color(0xFFA1887F), Color(0xFFB0BEC5), Color(0xFF81C784), Color(0xFFFFB74D), Color(0xFF64B5F6), ]; static const Map durationLabels = { MonthlyExpenseDuration.oneMonth: "1M", MonthlyExpenseDuration.threeMonths: "3M", MonthlyExpenseDuration.sixMonths: "6M", MonthlyExpenseDuration.twelveMonths: "12M", MonthlyExpenseDuration.all: "All", }; static const double mobileBreakpoint = 600; static const double mobileChartHeight = 350; static const double desktopChartHeight = 400; static const double mobilePadding = 12; static const double desktopPadding = 20; static const double mobileVerticalPadding = 16; static const double desktopVerticalPadding = 20; static const double noDataIconSize = 48; static const double noDataContainerHeight = 220; static const double labelRotation = 45; static const int tooltipAnimationDuration = 300; } // ========================= // MAIN CHART WIDGET // ========================= class MonthlyExpenseDashboardChart extends StatelessWidget { MonthlyExpenseDashboardChart({Key? key}) : super(key: key); final DashboardController _controller = Get.find(); Color _getColorForIndex(int index) => _ChartConstants.flatColors[index % _ChartConstants.flatColors.length]; BoxDecoration get _containerDecoration => 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), ), ], ); bool _isMobileLayout(double screenWidth) => screenWidth < _ChartConstants.mobileBreakpoint; double _calculateTotalExpense(List data) => data.fold(0, (sum, item) => sum + item.total); @override Widget build(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; final isMobile = _isMobileLayout(screenWidth); return Obx(() { final isLoading = _controller.isMonthlyExpenseLoading.value; final expenseData = _controller.monthlyExpenseList; final selectedDuration = _controller.selectedMonthlyExpenseDuration.value; final totalExpense = _calculateTotalExpense(expenseData); return Container( decoration: _containerDecoration, padding: EdgeInsets.symmetric( vertical: isMobile ? _ChartConstants.mobileVerticalPadding : _ChartConstants.desktopVerticalPadding, horizontal: isMobile ? _ChartConstants.mobilePadding : _ChartConstants.desktopPadding, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _ChartHeader( controller: _controller, // pass controller explicitly selectedDuration: selectedDuration, onDurationChanged: _controller.updateMonthlyExpenseDuration, totalExpense: totalExpense, ), const SizedBox(height: 12), SizedBox( height: isMobile ? _ChartConstants.mobileChartHeight : _ChartConstants.desktopChartHeight, child: _buildChartContent( isLoading: isLoading, data: expenseData, isMobile: isMobile, totalExpense: totalExpense, ), ), ], ), ); }); } Widget _buildChartContent({ required bool isLoading, required List data, required bool isMobile, required double totalExpense, }) { if (isLoading) { return const Center(child: CircularProgressIndicator()); } if (data.isEmpty) { return const _EmptyDataWidget(); } return _MonthlyExpenseChart( data: data, getColor: _getColorForIndex, isMobile: isMobile, totalExpense: totalExpense, ); } } // ========================= // HEADER WIDGET // ========================= class _ChartHeader extends StatelessWidget { const _ChartHeader({ Key? key, required this.controller, // added required this.selectedDuration, required this.onDurationChanged, required this.totalExpense, }) : super(key: key); final DashboardController controller; // added final MonthlyExpenseDuration selectedDuration; final ValueChanged onDurationChanged; final double totalExpense; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildTitle(), const SizedBox(height: 2), _buildSubtitle(), const SizedBox(height: 8), // ========================== // Row with popup menu on the right // ========================== Row( mainAxisAlignment: MainAxisAlignment.end, children: [ Obx(() { final selectedType = controller.selectedExpenseType.value; return PopupMenuButton( tooltip: 'Filter by Expense Category', onSelected: (String value) { if (value == 'all') { controller.updateSelectedExpenseType(null); } else { final type = controller.expenseTypes .firstWhere((t) => t.id == value); controller.updateSelectedExpenseType(type); } }, itemBuilder: (context) { final types = controller.expenseTypes; return [ PopupMenuItem( value: 'all', child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text('All Types'), if (selectedType == null) const Icon(Icons.check, size: 16, color: Colors.blueAccent), ], ), ), ...types.map((type) => PopupMenuItem( value: type.id, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(type.name), if (selectedType?.id == type.id) const Icon(Icons.check, size: 16, color: Colors.blueAccent), ], ), )), ]; }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(6), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: Text( selectedType?.name ?? 'All Types', overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 14), ), ), const SizedBox(width: 4), const Icon(Icons.arrow_drop_down, size: 20), ], ), ), ); }), ], ), const SizedBox(height: 8), _buildDurationSelector(), ], ); } Widget _buildTitle() => MyText.bodyMedium('Monthly Expense Overview', fontWeight: 700); Widget _buildSubtitle() => MyText.bodySmall('Month-wise total expense', color: Colors.grey); Widget _buildDurationSelector() { return Row( children: _ChartConstants.durationLabels.entries .map((entry) => _DurationChip( label: entry.value, duration: entry.key, isSelected: selectedDuration == entry.key, onSelected: onDurationChanged, )) .toList(), ); } } // ========================= // DURATION CHIP WIDGET // ========================= class _DurationChip extends StatelessWidget { const _DurationChip({ Key? key, required this.label, required this.duration, required this.isSelected, required this.onSelected, }) : super(key: key); final String label; final MonthlyExpenseDuration duration; final bool isSelected; final ValueChanged onSelected; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(right: 4), child: ChoiceChip( label: Text(label, style: const TextStyle(fontSize: 12)), padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 0), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: VisualDensity.compact, selected: isSelected, onSelected: (_) => onSelected(duration), selectedColor: Colors.blueAccent.withOpacity(0.15), backgroundColor: Colors.grey.shade200, labelStyle: TextStyle( color: isSelected ? Colors.blueAccent : Colors.black87, fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(5), side: BorderSide( color: isSelected ? Colors.blueAccent : Colors.grey.shade300, ), ), ), ); } } // ========================= // EMPTY DATA WIDGET // ========================= class _EmptyDataWidget extends StatelessWidget { const _EmptyDataWidget({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return SizedBox( height: _ChartConstants.noDataContainerHeight, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.info_outline, color: Colors.grey.shade400, size: _ChartConstants.noDataIconSize, ), const SizedBox(height: 10), MyText.bodyMedium( 'No monthly expense data available.', textAlign: TextAlign.center, color: Colors.grey.shade500, ), ], ), ), ); } } // ========================= // CHART WIDGET // ========================= class _MonthlyExpenseChart extends StatelessWidget { const _MonthlyExpenseChart({ Key? key, required this.data, required this.getColor, required this.isMobile, required this.totalExpense, }) : super(key: key); final List data; final Color Function(int index) getColor; final bool isMobile; final double totalExpense; @override Widget build(BuildContext context) { return SfCartesianChart( tooltipBehavior: _buildTooltipBehavior(), primaryXAxis: _buildXAxis(), primaryYAxis: _buildYAxis(), series: [_buildColumnSeries()], ); } TooltipBehavior _buildTooltipBehavior() { return TooltipBehavior( enable: true, builder: _tooltipBuilder, animationDuration: _ChartConstants.tooltipAnimationDuration, ); } Widget _tooltipBuilder( dynamic data, dynamic point, dynamic series, int pointIndex, int seriesIndex, ) { final value = data.total as double; final percentage = totalExpense > 0 ? (value / totalExpense * 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.monthName} ${data.year}', 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, ), ), ], ), ); } CategoryAxis _buildXAxis() { return CategoryAxis( labelRotation: _ChartConstants.labelRotation.toInt(), majorGridLines: const MajorGridLines(width: 0), // removes X-axis grid lines ); } NumericAxis _buildYAxis() { return NumericAxis( numberFormat: NumberFormat.simpleCurrency( locale: 'en_IN', name: '₹', decimalDigits: 0, ), axisLabelFormatter: (AxisLabelRenderDetails args) { return ChartAxisLabel(Utils.formatCurrency(args.value), null); }, majorGridLines: const MajorGridLines(width: 0), // removes Y-axis grid lines ); } ColumnSeries _buildColumnSeries() { return ColumnSeries( dataSource: data, xValueMapper: (d, _) => _ChartFormatter.formatMonthYear(d), yValueMapper: (d, _) => d.total, pointColorMapper: (_, index) => getColor(index), name: 'Monthly Expense', borderRadius: BorderRadius.circular(4), dataLabelSettings: _buildDataLabelSettings(), ); } DataLabelSettings _buildDataLabelSettings() { return DataLabelSettings( isVisible: true, builder: (data, _, __, ___, ____) => Text( Utils.formatCurrency(data.total), style: const TextStyle(fontSize: 11), ), ); } } // ========================= // FORMATTER HELPER // ========================= class _ChartFormatter { static String formatMonthYear(dynamic data) { try { final month = data.month ?? 1; final year = data.year ?? DateTime.now().year; final date = DateTime(year, month, 1); final monthName = DateFormat('MMM').format(date); final shortYear = year % 100; return '$shortYear $monthName'; } catch (e) { return '${data.monthName} ${data.year}'; } } }