refactor: Rename ProjectProgressChart to AttendanceDashboardChart and update data handling for attendance overview

This commit is contained in:
Vaibhav Surve 2025-11-03 17:40:18 +05:30
parent 817672c8b2
commit b33b3da6c0

View File

@ -2,55 +2,51 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:syncfusion_flutter_charts/charts.dart'; import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:marco/model/dashboard/project_progress_model.dart';
import 'package:marco/controller/dashboard/dashboard_controller.dart'; import 'package:marco/controller/dashboard/dashboard_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/utils/utils.dart';
class ProjectProgressChart extends StatelessWidget { class AttendanceDashboardChart extends StatelessWidget {
final List<ChartTaskData> data; AttendanceDashboardChart({Key? key}) : super(key: key);
final DashboardController controller = Get.find<DashboardController>();
ProjectProgressChart({super.key, required this.data}); final DashboardController _controller = Get.find<DashboardController>();
// ================= Flat Colors =================
static const List<Color> _flatColors = [ static const List<Color> _flatColors = [
Color(0xFFE57373), Color(0xFFE57373), // Red 300
Color(0xFF64B5F6), Color(0xFF64B5F6), // Blue 300
Color(0xFF81C784), Color(0xFF81C784), // Green 300
Color(0xFFFFB74D), Color(0xFFFFB74D), // Orange 300
Color(0xFFBA68C8), Color(0xFFBA68C8), // Purple 300
Color(0xFFFF8A65), Color(0xFFFF8A65), // Deep Orange 300
Color(0xFF4DB6AC), Color(0xFF4DB6AC), // Teal 300
Color(0xFFA1887F), Color(0xFFA1887F), // Brown 400
Color(0xFFDCE775), Color(0xFFDCE775), // Lime 300
Color(0xFF9575CD), Color(0xFF9575CD), // Deep Purple 300
Color(0xFF7986CB), Color(0xFF7986CB), // Indigo 300
Color(0xFFAED581), Color(0xFFAED581), // Light Green 300
Color(0xFFFF7043), Color(0xFFFF7043), // Deep Orange 400
Color(0xFF4FC3F7), Color(0xFF4FC3F7), // Light Blue 300
Color(0xFFFFD54F), Color(0xFFFFD54F), // Amber 300
Color(0xFF90A4AE), Color(0xFF90A4AE), // Blue Grey 300
Color(0xFFE573BB), Color(0xFFE573BB), // Pink 300
Color(0xFF81D4FA), Color(0xFF81D4FA), // Light Blue 200
Color(0xFFBCAAA4), Color(0xFFBCAAA4), // Brown 300
Color(0xFFA5D6A7), Color(0xFFA5D6A7), // Green 300
Color(0xFFCE93D8), Color(0xFFCE93D8), // Purple 200
Color(0xFFFF8A65), Color(0xFFFF8A65), // Deep Orange 300 (repeat to fill)
Color(0xFF80CBC4), Color(0xFF80CBC4), // Teal 200
Color(0xFFFFF176), Color(0xFFFFF176), // Yellow 300
Color(0xFF90CAF9), Color(0xFF90CAF9), // Blue 200
Color(0xFFE0E0E0), Color(0xFFE0E0E0), // Grey 300
Color(0xFFF48FB1), Color(0xFFF48FB1), // Pink 200
Color(0xFFA1887F), Color(0xFFA1887F), // Brown 400 (repeat)
Color(0xFFB0BEC5), Color(0xFFB0BEC5), // Blue Grey 200
Color(0xFF81C784), Color(0xFF81C784), // Green 300 (repeat)
Color(0xFFFFB74D), Color(0xFFFFB74D), // Orange 300 (repeat)
Color(0xFF64B5F6), Color(0xFF64B5F6), // Blue 300 (repeat)
]; ];
Color _getTaskColor(String taskName) { Color _getRoleColor(String role) {
final index = taskName.hashCode % _flatColors.length; final index = role.hashCode.abs() % _flatColors.length;
return _flatColors[index]; return _flatColors[index];
} }
@ -59,42 +55,39 @@ class ProjectProgressChart extends StatelessWidget {
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
return Obx(() { return Obx(() {
final isChartView = controller.projectIsChartView.value; final isChartView = _controller.attendanceIsChartView.value;
final selectedRange = controller.projectSelectedRange.value; final selectedRange = _controller.attendanceSelectedRange.value;
final filteredData = _getFilteredData();
return Container( return Container(
decoration: BoxDecoration( decoration: _containerDecoration,
color: Colors.white,
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.04),
blurRadius: 6,
spreadRadius: 1,
offset: const Offset(0, 2),
),
],
),
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
vertical: 16, vertical: 16,
horizontal: screenWidth < 600 ? 8 : 24, horizontal: screenWidth < 600 ? 8 : 20,
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildHeader(selectedRange, isChartView, screenWidth), _Header(
const SizedBox(height: 14), selectedRange: selectedRange,
isChartView: isChartView,
screenWidth: screenWidth,
onToggleChanged: (isChart) =>
_controller.attendanceIsChartView.value = isChart,
onRangeChanged: _controller.updateAttendanceRange,
),
const SizedBox(height: 12),
Expanded( Expanded(
child: LayoutBuilder( child: filteredData.isEmpty
builder: (context, constraints) => AnimatedSwitcher( ? _NoDataMessage()
duration: const Duration(milliseconds: 300), : isChartView
child: data.isEmpty ? _AttendanceChart(
? _buildNoDataMessage() data: filteredData, getRoleColor: _getRoleColor)
: isChartView : _AttendanceTable(
? _buildChart(constraints.maxHeight) data: filteredData,
: _buildTable(constraints.maxHeight, screenWidth), getRoleColor: _getRoleColor,
), screenWidth: screenWidth),
),
), ),
], ],
), ),
@ -102,22 +95,62 @@ class ProjectProgressChart extends StatelessWidget {
}); });
} }
// ================= HEADER ================= BoxDecoration get _containerDecoration => BoxDecoration(
Widget _buildHeader( color: Colors.white,
String selectedRange, bool isChartView, double screenWidth) { borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.05),
blurRadius: 6,
spreadRadius: 1,
offset: const Offset(0, 2),
),
],
);
List<Map<String, dynamic>> _getFilteredData() {
final now = DateTime.now();
final daysBack = _controller.getAttendanceDays();
return _controller.roleWiseData.where((entry) {
final date = DateTime.parse(entry['date'] as String);
return date.isAfter(now.subtract(Duration(days: daysBack))) &&
!date.isAfter(now);
}).toList();
}
}
// Header
class _Header extends StatelessWidget {
const _Header({
Key? key,
required this.selectedRange,
required this.isChartView,
required this.screenWidth,
required this.onToggleChanged,
required this.onRangeChanged,
}) : super(key: key);
final String selectedRange;
final bool isChartView;
final double screenWidth;
final ValueChanged<bool> onToggleChanged;
final ValueChanged<String> onRangeChanged;
@override
Widget build(BuildContext context) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyText.bodyMedium('Project Progress', fontWeight: 700), MyText.bodyMedium('Attendance Overview', fontWeight: 700),
MyText.bodySmall('Planned vs Completed', const SizedBox(height: 2),
color: Colors.grey.shade700), MyText.bodySmall('Role-wise present count',
color: Colors.grey),
], ],
), ),
), ),
@ -133,9 +166,7 @@ class ProjectProgressChart extends StatelessWidget {
minWidth: screenWidth < 400 ? 28 : 36, minWidth: screenWidth < 400 ? 28 : 36,
), ),
isSelected: [isChartView, !isChartView], isSelected: [isChartView, !isChartView],
onPressed: (index) { onPressed: (index) => onToggleChanged(index == 0),
controller.projectIsChartView.value = index == 0;
},
children: const [ children: const [
Icon(Icons.bar_chart_rounded, size: 15), Icon(Icons.bar_chart_rounded, size: 15),
Icon(Icons.table_chart, size: 15), Icon(Icons.table_chart, size: 15),
@ -143,149 +174,233 @@ class ProjectProgressChart extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 6), const SizedBox(height: 8),
Row( Row(
children: [ children: ["7D", "15D", "30D"]
_buildRangeButton("7D", selectedRange), .map(
_buildRangeButton("15D", selectedRange), (label) => Padding(
_buildRangeButton("30D", selectedRange), padding: const EdgeInsets.only(right: 4),
_buildRangeButton("3M", selectedRange), child: ChoiceChip(
_buildRangeButton("6M", selectedRange), label: Text(label, style: const TextStyle(fontSize: 12)),
], padding:
const EdgeInsets.symmetric(horizontal: 5, vertical: 0),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
selected: selectedRange == label,
onSelected: (_) => onRangeChanged(label),
selectedColor: Colors.blueAccent.withOpacity(0.15),
backgroundColor: Colors.grey.shade200,
labelStyle: TextStyle(
color: selectedRange == label
? Colors.blueAccent
: Colors.black87,
fontWeight: selectedRange == label
? FontWeight.w600
: FontWeight.normal,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
side: BorderSide(
color: selectedRange == label
? Colors.blueAccent
: Colors.grey.shade300,
),
),
),
),
)
.toList(),
), ),
], ],
); );
} }
}
Widget _buildRangeButton(String label, String selectedRange) { // No Data
return Padding( class _NoDataMessage extends StatelessWidget {
padding: const EdgeInsets.only(right: 4.0), @override
child: ChoiceChip( Widget build(BuildContext context) {
label: Text(label, style: const TextStyle(fontSize: 12)), return SizedBox(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 0), height: 180,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, child: Center(
visualDensity: VisualDensity.compact, child: Column(
selected: selectedRange == label, mainAxisAlignment: MainAxisAlignment.center,
onSelected: (_) => controller.updateProjectRange(label), children: [
selectedColor: Colors.blueAccent.withOpacity(0.15), Icon(Icons.info_outline, color: Colors.grey.shade400, size: 48),
backgroundColor: Colors.grey.shade200, const SizedBox(height: 10),
labelStyle: TextStyle( MyText.bodyMedium(
color: selectedRange == label ? Colors.blueAccent : Colors.black87, 'No attendance data available for this range.',
fontWeight: textAlign: TextAlign.center,
selectedRange == label ? FontWeight.w600 : FontWeight.normal, color: Colors.grey.shade500,
), ),
shape: RoundedRectangleBorder( ],
borderRadius: BorderRadius.circular(5),
side: BorderSide(
color: selectedRange == label
? Colors.blueAccent
: Colors.grey.shade300,
),
), ),
), ),
); );
} }
}
// ================= CHART ================= // Chart
Widget _buildChart(double height) { class _AttendanceChart extends StatelessWidget {
final nonZeroData = const _AttendanceChart({
data.where((d) => d.planned != 0 || d.completed != 0).toList(); Key? key,
required this.data,
required this.getRoleColor,
}) : super(key: key);
if (nonZeroData.isEmpty) return _buildNoDataContainer(height); final List<Map<String, dynamic>> data;
final Color Function(String role) getRoleColor;
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat('d MMM');
final uniqueDates = data
.map((e) => DateTime.parse(e['date'] as String))
.toSet()
.toList()
..sort();
final filteredDates = uniqueDates.map(dateFormat.format).toList();
final filteredRoles = data.map((e) => e['role'] as String).toSet().toList();
final allZero = filteredRoles.every((role) {
return data
.where((entry) => entry['role'] == role)
.every((entry) => (entry['present'] ?? 0) == 0);
});
if (allZero) {
return Container(
height: 600,
child: const Center(
child: Text(
'No attendance data for the selected range.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey),
),
),
);
}
final formattedMap = {
for (var e in data)
'${e['role']}_${dateFormat.format(DateTime.parse(e['date'] as String))}':
e['present'],
};
final rolesWithData = filteredRoles.where((role) {
return data
.any((entry) => entry['role'] == role && (entry['present'] ?? 0) > 0);
}).toList();
return Container( return Container(
height: height > 280 ? 280 : height, height: 600,
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
), ),
child: SfCartesianChart( child: SfCartesianChart(
tooltipBehavior: TooltipBehavior( tooltipBehavior: TooltipBehavior(enable: true, shared: true),
enable: true,
builder: (data, point, series, pointIndex, seriesIndex) {
final task = data as ChartTaskData;
final value = seriesIndex == 0 ? task.planned : task.completed;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: Colors.blueAccent,
borderRadius: BorderRadius.circular(4),
),
child: Text(
Utils.formatCurrency(value),
style: const TextStyle(color: Colors.white),
),
);
},
),
legend: Legend(isVisible: true, position: LegendPosition.bottom), legend: Legend(isVisible: true, position: LegendPosition.bottom),
primaryXAxis: CategoryAxis( primaryXAxis: CategoryAxis(
majorGridLines: const MajorGridLines(width: 0),
axisLine: const AxisLine(width: 0),
labelRotation: 45, labelRotation: 45,
majorGridLines:
const MajorGridLines(width: 0), // removes vertical grid lines
), ),
primaryYAxis: NumericAxis( primaryYAxis: NumericAxis(
axisLine: const AxisLine(width: 0), minimum: 0,
majorGridLines: const MajorGridLines(width: 0), interval: 1,
labelFormat: '{value}', majorGridLines:
numberFormat: NumberFormat.compact(), const MajorGridLines(width: 0), // removes horizontal grid lines
), ),
series: <ColumnSeries<ChartTaskData, String>>[ series: rolesWithData.map((role) {
ColumnSeries<ChartTaskData, String>( final seriesData = filteredDates
name: 'Planned', .map((date) {
dataSource: nonZeroData, final key = '${role}_$date';
xValueMapper: (d, _) => DateFormat('d MMM').format(d.date), return {'date': date, 'present': formattedMap[key] ?? 0};
yValueMapper: (d, _) => d.planned, })
color: _getTaskColor('Planned'), .where((d) => (d['present'] ?? 0) > 0)
.toList();
return StackedColumnSeries<Map<String, dynamic>, String>(
dataSource: seriesData,
xValueMapper: (d, _) => d['date'],
yValueMapper: (d, _) => d['present'],
name: role,
color: getRoleColor(role),
dataLabelSettings: DataLabelSettings( dataLabelSettings: DataLabelSettings(
isVisible: true, isVisible: true,
builder: (data, _, __, ___, ____) { builder: (dynamic data, _, __, ___, ____) {
final value = (data as ChartTaskData).planned; return (data['present'] ?? 0) > 0
return Text( ? Text(
Utils.formatCurrency(value), NumberFormat.decimalPattern().format(data['present']),
style: const TextStyle(fontSize: 11), style: const TextStyle(fontSize: 11),
); )
: const SizedBox.shrink();
}, },
), ),
), );
ColumnSeries<ChartTaskData, String>( }).toList(),
name: 'Completed',
dataSource: nonZeroData,
xValueMapper: (d, _) => DateFormat('d MMM').format(d.date),
yValueMapper: (d, _) => d.completed,
color: _getTaskColor('Completed'),
dataLabelSettings: DataLabelSettings(
isVisible: true,
builder: (data, _, __, ___, ____) {
final value = (data as ChartTaskData).completed;
return Text(
Utils.formatCurrency(value),
style: const TextStyle(fontSize: 11),
);
},
),
),
],
), ),
); );
} }
}
// ================= TABLE ================= // Table
Widget _buildTable(double maxHeight, double screenWidth) { class _AttendanceTable extends StatelessWidget {
final containerHeight = maxHeight > 300 ? 300.0 : maxHeight; const _AttendanceTable({
final nonZeroData = Key? key,
data.where((d) => d.planned != 0 || d.completed != 0).toList(); required this.data,
required this.getRoleColor,
required this.screenWidth,
}) : super(key: key);
if (nonZeroData.isEmpty) return _buildNoDataContainer(containerHeight); final List<Map<String, dynamic>> data;
final Color Function(String role) getRoleColor;
final double screenWidth;
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat('d MMM');
final uniqueDates = data
.map((e) => DateTime.parse(e['date'] as String))
.toSet()
.toList()
..sort();
final filteredDates = uniqueDates.map(dateFormat.format).toList();
final filteredRoles = data.map((e) => e['role'] as String).toSet().toList();
final allZero = filteredRoles.every((role) {
return data
.where((entry) => entry['role'] == role)
.every((entry) => (entry['present'] ?? 0) == 0);
});
if (allZero) {
return Container(
height: 300,
child: const Center(
child: Text(
'No attendance data for the selected range.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey),
),
),
);
}
final formattedMap = {
for (var e in data)
'${e['role']}_${dateFormat.format(DateTime.parse(e['date'] as String))}':
e['present'],
};
return Container( return Container(
height: containerHeight, height: 300,
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300), border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
color: Colors.transparent,
), ),
child: Scrollbar( child: Scrollbar(
thumbVisibility: true, thumbVisibility: true,
@ -293,33 +408,40 @@ class ProjectProgressChart extends StatelessWidget {
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints(minWidth: screenWidth), constraints:
BoxConstraints(minWidth: MediaQuery.of(context).size.width),
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.vertical, scrollDirection: Axis.vertical,
child: DataTable( child: DataTable(
columnSpacing: screenWidth < 600 ? 16 : 36, columnSpacing: 20,
headingRowHeight: 44, headingRowHeight: 44,
headingRowColor: MaterialStateProperty.all( headingRowColor: MaterialStateProperty.all(
Colors.blueAccent.withOpacity(0.08)), Colors.blueAccent.withOpacity(0.08)),
headingTextStyle: const TextStyle( headingTextStyle: const TextStyle(
fontWeight: FontWeight.bold, color: Colors.black87), fontWeight: FontWeight.bold, color: Colors.black87),
columns: const [ columns: [
DataColumn(label: Text('Date')), const DataColumn(label: Text('Role')),
DataColumn(label: Text('Planned')), ...filteredDates
DataColumn(label: Text('Completed')), .map((d) => DataColumn(label: Center(child: Text(d)))),
], ],
rows: nonZeroData.map((task) { rows: filteredRoles.map((role) {
return DataRow( return DataRow(
cells: [ cells: [
DataCell(Text(DateFormat('d MMM').format(task.date))), DataCell(
DataCell(Text( _RolePill(role: role, color: getRoleColor(role))),
Utils.formatCurrency(task.planned), ...filteredDates.map((date) {
style: TextStyle(color: _getTaskColor('Planned')), final key = '${role}_$date';
)), return DataCell(
DataCell(Text( Center(
Utils.formatCurrency(task.completed), child: Text(
style: TextStyle(color: _getTaskColor('Completed')), NumberFormat.decimalPattern()
)), .format(formattedMap[key] ?? 0),
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13),
),
),
);
}),
], ],
); );
}).toList(), }).toList(),
@ -330,42 +452,24 @@ class ProjectProgressChart extends StatelessWidget {
), ),
); );
} }
}
// ================= NO DATA WIDGETS ================= class _RolePill extends StatelessWidget {
Widget _buildNoDataContainer(double height) { const _RolePill({Key? key, required this.role, required this.color})
: super(key: key);
final String role;
final Color color;
@override
Widget build(BuildContext context) {
return Container( return Container(
height: height > 280 ? 280 : height, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.transparent, color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
), ),
child: const Center( child: MyText.labelSmall(role, fontWeight: 500),
child: Text(
'No project progress data for the selected range.',
style: TextStyle(fontSize: 14, color: Colors.grey),
textAlign: TextAlign.center,
),
),
);
}
Widget _buildNoDataMessage() {
return SizedBox(
height: 180,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.info_outline, color: Colors.grey.shade400, size: 54),
const SizedBox(height: 10),
MyText.bodyMedium(
'No project progress data available for the selected range.',
textAlign: TextAlign.center,
color: Colors.grey.shade500,
),
],
),
),
); );
} }
} }