refactor: Improve Attendance and Project Progress Charts with enhanced styling and tooltip formatting

This commit is contained in:
Vaibhav Surve 2025-11-03 16:51:51 +05:30
parent 4f0261bf0b
commit 817672c8b2
2 changed files with 136 additions and 113 deletions

View File

@ -60,7 +60,6 @@ class AttendanceDashboardChart extends StatelessWidget {
final filteredData = _getFilteredData(); final filteredData = _getFilteredData();
return Container( return Container(
decoration: _containerDecoration, decoration: _containerDecoration,
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
@ -254,7 +253,7 @@ class _AttendanceChart extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final dateFormat = DateFormat('d MMMM'); final dateFormat = DateFormat('d MMM');
final uniqueDates = data final uniqueDates = data
.map((e) => DateTime.parse(e['date'] as String)) .map((e) => DateTime.parse(e['date'] as String))
.toSet() .toSet()
@ -273,10 +272,6 @@ class _AttendanceChart extends StatelessWidget {
if (allZero) { if (allZero) {
return Container( return Container(
height: 600, height: 600,
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(5),
),
child: const Center( child: const Center(
child: Text( child: Text(
'No attendance data for the selected range.', 'No attendance data for the selected range.',
@ -302,14 +297,22 @@ class _AttendanceChart extends StatelessWidget {
height: 600, 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(enable: true, shared: true), tooltipBehavior: TooltipBehavior(enable: true, shared: true),
legend: Legend(isVisible: true, position: LegendPosition.bottom), legend: Legend(isVisible: true, position: LegendPosition.bottom),
primaryXAxis: CategoryAxis(labelRotation: 45), primaryXAxis: CategoryAxis(
primaryYAxis: NumericAxis(minimum: 0, interval: 1), labelRotation: 45,
majorGridLines:
const MajorGridLines(width: 0), // removes vertical grid lines
),
primaryYAxis: NumericAxis(
minimum: 0,
interval: 1,
majorGridLines:
const MajorGridLines(width: 0), // removes horizontal grid lines
),
series: rolesWithData.map((role) { series: rolesWithData.map((role) {
final seriesData = filteredDates final seriesData = filteredDates
.map((date) { .map((date) {
@ -317,7 +320,7 @@ class _AttendanceChart extends StatelessWidget {
return {'date': date, 'present': formattedMap[key] ?? 0}; return {'date': date, 'present': formattedMap[key] ?? 0};
}) })
.where((d) => (d['present'] ?? 0) > 0) .where((d) => (d['present'] ?? 0) > 0)
.toList(); // remove 0 bars .toList();
return StackedColumnSeries<Map<String, dynamic>, String>( return StackedColumnSeries<Map<String, dynamic>, String>(
dataSource: seriesData, dataSource: seriesData,
@ -358,7 +361,7 @@ class _AttendanceTable extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final dateFormat = DateFormat('d MMMM'); final dateFormat = DateFormat('d MMM');
final uniqueDates = data final uniqueDates = data
.map((e) => DateTime.parse(e['date'] as String)) .map((e) => DateTime.parse(e['date'] as String))
.toSet() .toSet()
@ -377,10 +380,6 @@ class _AttendanceTable extends StatelessWidget {
if (allZero) { if (allZero) {
return Container( return Container(
height: 300, height: 300,
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(5),
),
child: const Center( child: const Center(
child: Text( child: Text(
'No attendance data for the selected range.', 'No attendance data for the selected range.',
@ -402,38 +401,49 @@ class _AttendanceTable extends StatelessWidget {
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.grey.shade50,
), ),
child: SingleChildScrollView( child: Scrollbar(
scrollDirection: Axis.horizontal, thumbVisibility: true,
child: DataTable( trackVisibility: true,
columnSpacing: screenWidth < 600 ? 20 : 36, child: SingleChildScrollView(
headingRowHeight: 44, scrollDirection: Axis.horizontal,
headingRowColor: child: ConstrainedBox(
MaterialStateProperty.all(Colors.blueAccent.withOpacity(0.08)), constraints:
headingTextStyle: const TextStyle( BoxConstraints(minWidth: MediaQuery.of(context).size.width),
fontWeight: FontWeight.bold, color: Colors.black87), child: SingleChildScrollView(
columns: [ scrollDirection: Axis.vertical,
const DataColumn(label: Text('Role')), child: DataTable(
...filteredDates.map((d) => DataColumn(label: Text(d))), columnSpacing: 20,
], headingRowHeight: 44,
rows: filteredRoles.map((role) { headingRowColor: MaterialStateProperty.all(
return DataRow( Colors.blueAccent.withOpacity(0.08)),
cells: [ headingTextStyle: const TextStyle(
DataCell(_RolePill(role: role, color: getRoleColor(role))), fontWeight: FontWeight.bold, color: Colors.black87),
...filteredDates.map((date) { columns: [
final key = '${role}_$date'; const DataColumn(label: Text('Role')),
return DataCell( ...filteredDates.map((d) => DataColumn(label: Text(d))),
Text( ],
NumberFormat.decimalPattern() rows: filteredRoles.map((role) {
.format(formattedMap[key] ?? 0), return DataRow(
style: const TextStyle(fontSize: 13), cells: [
), DataCell(
_RolePill(role: role, color: getRoleColor(role))),
...filteredDates.map((date) {
final key = '${role}_$date';
return DataCell(
Text(
NumberFormat.decimalPattern()
.format(formattedMap[key] ?? 0),
style: const TextStyle(fontSize: 13),
),
);
}),
],
); );
}), }).toList(),
], ),
); ),
}).toList(), ),
), ),
), ),
); );

View File

@ -5,6 +5,7 @@ import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:marco/model/dashboard/project_progress_model.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 ProjectProgressChart extends StatelessWidget {
final List<ChartTaskData> data; final List<ChartTaskData> data;
@ -47,7 +48,6 @@ class ProjectProgressChart extends StatelessWidget {
Color(0xFFFFB74D), Color(0xFFFFB74D),
Color(0xFF64B5F6), Color(0xFF64B5F6),
]; ];
static final NumberFormat _commaFormatter = NumberFormat.decimalPattern();
Color _getTaskColor(String taskName) { Color _getTaskColor(String taskName) {
final index = taskName.hashCode % _flatColors.length; final index = taskName.hashCode % _flatColors.length;
@ -71,7 +71,7 @@ class ProjectProgressChart extends StatelessWidget {
color: Colors.grey.withOpacity(0.04), color: Colors.grey.withOpacity(0.04),
blurRadius: 6, blurRadius: 6,
spreadRadius: 1, spreadRadius: 1,
offset: Offset(0, 2), offset: const Offset(0, 2),
), ),
], ],
), ),
@ -102,6 +102,7 @@ class ProjectProgressChart extends StatelessWidget {
}); });
} }
// ================= HEADER =================
Widget _buildHeader( Widget _buildHeader(
String selectedRange, bool isChartView, double screenWidth) { String selectedRange, bool isChartView, double screenWidth) {
return Column( return Column(
@ -129,7 +130,7 @@ class ProjectProgressChart extends StatelessWidget {
color: Colors.grey, color: Colors.grey,
constraints: BoxConstraints( constraints: BoxConstraints(
minHeight: 30, minHeight: 30,
minWidth: (screenWidth < 400 ? 28 : 36), minWidth: screenWidth < 400 ? 28 : 36,
), ),
isSelected: [isChartView, !isChartView], isSelected: [isChartView, !isChartView],
onPressed: (index) { onPressed: (index) {
@ -185,50 +186,64 @@ class ProjectProgressChart extends StatelessWidget {
); );
} }
// ================= CHART =================
Widget _buildChart(double height) { Widget _buildChart(double height) {
final nonZeroData = final nonZeroData =
data.where((d) => d.planned != 0 || d.completed != 0).toList(); data.where((d) => d.planned != 0 || d.completed != 0).toList();
if (nonZeroData.isEmpty) { if (nonZeroData.isEmpty) return _buildNoDataContainer(height);
return _buildNoDataContainer(height);
}
return Container( return Container(
height: height > 280 ? 280 : height, height: height > 280 ? 280 : height,
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blueGrey.shade50, color: Colors.transparent,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
), ),
child: SfCartesianChart( child: SfCartesianChart(
tooltipBehavior: TooltipBehavior(enable: true), tooltipBehavior: TooltipBehavior(
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),
// Use CategoryAxis so only nonZeroData dates show up
primaryXAxis: CategoryAxis( primaryXAxis: CategoryAxis(
majorGridLines: const MajorGridLines(width: 0), majorGridLines: const MajorGridLines(width: 0),
axisLine: const AxisLine(width: 0), axisLine: const AxisLine(width: 0),
labelRotation: 0, labelRotation: 45,
), ),
primaryYAxis: NumericAxis( primaryYAxis: NumericAxis(
labelFormat: '{value}',
axisLine: const AxisLine(width: 0), axisLine: const AxisLine(width: 0),
majorTickLines: const MajorTickLines(size: 0), majorGridLines: const MajorGridLines(width: 0),
labelFormat: '{value}',
numberFormat: NumberFormat.compact(),
), ),
series: <CartesianSeries>[ series: <ColumnSeries<ChartTaskData, String>>[
ColumnSeries<ChartTaskData, String>( ColumnSeries<ChartTaskData, String>(
name: 'Planned', name: 'Planned',
dataSource: nonZeroData, dataSource: nonZeroData,
xValueMapper: (d, _) => DateFormat('MMM d').format(d.date), xValueMapper: (d, _) => DateFormat('d MMM').format(d.date),
yValueMapper: (d, _) => d.planned, yValueMapper: (d, _) => d.planned,
color: _getTaskColor('Planned'), color: _getTaskColor('Planned'),
dataLabelSettings: DataLabelSettings( dataLabelSettings: DataLabelSettings(
isVisible: true, isVisible: true,
builder: (data, point, series, pointIndex, seriesIndex) { builder: (data, _, __, ___, ____) {
final value = seriesIndex == 0 final value = (data as ChartTaskData).planned;
? (data as ChartTaskData).planned
: (data as ChartTaskData).completed;
return Text( return Text(
_commaFormatter.format(value), Utils.formatCurrency(value),
style: const TextStyle(fontSize: 11), style: const TextStyle(fontSize: 11),
); );
}, },
@ -237,17 +252,15 @@ class ProjectProgressChart extends StatelessWidget {
ColumnSeries<ChartTaskData, String>( ColumnSeries<ChartTaskData, String>(
name: 'Completed', name: 'Completed',
dataSource: nonZeroData, dataSource: nonZeroData,
xValueMapper: (d, _) => DateFormat('MMM d').format(d.date), xValueMapper: (d, _) => DateFormat('d MMM').format(d.date),
yValueMapper: (d, _) => d.completed, yValueMapper: (d, _) => d.completed,
color: _getTaskColor('Completed'), color: _getTaskColor('Completed'),
dataLabelSettings: DataLabelSettings( dataLabelSettings: DataLabelSettings(
isVisible: true, isVisible: true,
builder: (data, point, series, pointIndex, seriesIndex) { builder: (data, _, __, ___, ____) {
final value = seriesIndex == 0 final value = (data as ChartTaskData).completed;
? (data as ChartTaskData).planned
: (data as ChartTaskData).completed;
return Text( return Text(
_commaFormatter.format(value), Utils.formatCurrency(value),
style: const TextStyle(fontSize: 11), style: const TextStyle(fontSize: 11),
); );
}, },
@ -258,14 +271,13 @@ class ProjectProgressChart extends StatelessWidget {
); );
} }
// ================= TABLE =================
Widget _buildTable(double maxHeight, double screenWidth) { Widget _buildTable(double maxHeight, double screenWidth) {
final containerHeight = maxHeight > 300 ? 300.0 : maxHeight; final containerHeight = maxHeight > 300 ? 300.0 : maxHeight;
final nonZeroData = final nonZeroData =
data.where((d) => d.planned != 0 || d.completed != 0).toList(); data.where((d) => d.planned != 0 || d.completed != 0).toList();
if (nonZeroData.isEmpty) { if (nonZeroData.isEmpty) return _buildNoDataContainer(containerHeight);
return _buildNoDataContainer(containerHeight);
}
return Container( return Container(
height: containerHeight, height: containerHeight,
@ -273,57 +285,58 @@ class ProjectProgressChart extends StatelessWidget {
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.grey.shade50, color: Colors.transparent,
), ),
child: LayoutBuilder( child: Scrollbar(
builder: (context, constraints) { thumbVisibility: true,
return SingleChildScrollView( trackVisibility: true,
scrollDirection: Axis.horizontal, child: SingleChildScrollView(
child: ConstrainedBox( scrollDirection: Axis.horizontal,
constraints: BoxConstraints(minWidth: constraints.maxWidth), child: ConstrainedBox(
child: SingleChildScrollView( constraints: BoxConstraints(minWidth: screenWidth),
scrollDirection: Axis.vertical, child: SingleChildScrollView(
child: DataTable( scrollDirection: Axis.vertical,
columnSpacing: screenWidth < 600 ? 16 : 36, child: DataTable(
headingRowHeight: 44, columnSpacing: screenWidth < 600 ? 16 : 36,
headingRowColor: MaterialStateProperty.all( headingRowHeight: 44,
Colors.blueAccent.withOpacity(0.08)), headingRowColor: MaterialStateProperty.all(
headingTextStyle: const TextStyle( Colors.blueAccent.withOpacity(0.08)),
fontWeight: FontWeight.bold, color: Colors.black87), headingTextStyle: const TextStyle(
columns: const [ fontWeight: FontWeight.bold, color: Colors.black87),
DataColumn(label: Text('Date')), columns: const [
DataColumn(label: Text('Planned')), DataColumn(label: Text('Date')),
DataColumn(label: Text('Completed')), DataColumn(label: Text('Planned')),
], DataColumn(label: Text('Completed')),
rows: nonZeroData.map((task) { ],
return DataRow( rows: nonZeroData.map((task) {
cells: [ return DataRow(
DataCell(Text(DateFormat('d MMM').format(task.date))), cells: [
DataCell(Text( DataCell(Text(DateFormat('d MMM').format(task.date))),
'${task.planned}', DataCell(Text(
style: TextStyle(color: _getTaskColor('Planned')), Utils.formatCurrency(task.planned),
)), style: TextStyle(color: _getTaskColor('Planned')),
DataCell(Text( )),
'${task.completed}', DataCell(Text(
style: TextStyle(color: _getTaskColor('Completed')), Utils.formatCurrency(task.completed),
)), style: TextStyle(color: _getTaskColor('Completed')),
], )),
); ],
}).toList(), );
), }).toList(),
), ),
), ),
); ),
}, ),
), ),
); );
} }
// ================= NO DATA WIDGETS =================
Widget _buildNoDataContainer(double height) { Widget _buildNoDataContainer(double height) {
return Container( return Container(
height: height > 280 ? 280 : height, height: height > 280 ? 280 : height,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blueGrey.shade50, color: Colors.transparent,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
), ),
child: const Center( child: const Center(