feat: Redesign Dashboard Overview Widgets with enhanced metrics display and loading states

This commit is contained in:
Vaibhav Surve 2025-09-25 17:04:22 +05:30
parent 781a8dabaf
commit b5d8d41e42

View File

@ -1,268 +1,414 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
// Assuming these exist in the project
import 'package:marco/controller/dashboard/dashboard_controller.dart';
import 'package:marco/helpers/widgets/my_card.dart'; import 'package:marco/helpers/widgets/my_card.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/controller/dashboard/dashboard_controller.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:marco/helpers/widgets/my_text.dart'; // import MyText
import 'package:intl/intl.dart';
// Redesigned Dashboard Overview Widgets
class DashboardOverviewWidgets { class DashboardOverviewWidgets {
static final DashboardController dashboardController = static final DashboardController dashboardController =
Get.find<DashboardController>(); Get.find<DashboardController>();
static const _titleTextStyle = TextStyle( // Design tokens
static const _titleStyle = TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.black87,
letterSpacing: 0.2,
);
static const _subtitleStyle = TextStyle(
fontSize: 12,
color: Colors.black54,
letterSpacing: 0.1,
);
static const _metricStyle = TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
color: Colors.black87,
);
static const _percentStyle = TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Colors.black87, color: Colors.black87,
); );
static const _subtitleTextStyle = TextStyle( static final NumberFormat _comma = NumberFormat.decimalPattern();
fontSize: 14,
color: Colors.grey,
);
static const _infoNumberTextStyle = TextStyle( // Color palette with accessible contrast
fontSize: 20, static const Color _primaryA = Color(0xFF1565C0); // Blue 800
fontWeight: FontWeight.bold, static const Color _accentA = Color(0xFF2E7D32); // Green 800
color: Colors.black87, static const Color _warnA = Color(0xFFC62828); // Red 800
); static const Color _muted = Color(0xFF9E9E9E); // Grey 500
static const Color _hint = Color(0xFFBDBDBD); // Grey 400
static const Color _bgSoft = Color(0xFFF7F8FA);
static const _infoNumberGreenTextStyle = TextStyle( // Public API: Teams overview card
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
);
static final NumberFormat _commaFormatter = NumberFormat.decimalPattern();
/// Teams Overview Card without chart, labels & values in rows
static Widget teamsOverview() { static Widget teamsOverview() {
return Obx(() { return Obx(() {
if (dashboardController.isTeamsLoading.value) { if (dashboardController.isTeamsLoading.value) {
return _loadingSkeletonCard("Teams"); return _skeletonCard(title: "Teams");
} }
final total = dashboardController.totalEmployees.value; final total = dashboardController.totalEmployees.value;
final inToday = dashboardController.inToday.value; final inToday = dashboardController.inToday.value.clamp(0, total);
final absent = (total - inToday).clamp(0, total);
final percent = total > 0 ? inToday / total : 0.0;
return LayoutBuilder( final hasData = total > 0;
builder: (context, constraints) {
final cardWidth = constraints.maxWidth > 400
? (constraints.maxWidth / 2) - 10
: constraints.maxWidth;
return SizedBox( final data = hasData
width: cardWidth, ? [
child: MyCard( _ChartData('In Today', inToday.toDouble(), _accentA),
borderRadiusAll: 5, _ChartData('Absent', absent.toDouble(), _muted),
paddingAll: 20, ]
child: Column( : [
crossAxisAlignment: CrossAxisAlignment.start, _ChartData('No Data', 1.0, _hint),
children: [ _ChartData('No Data', 1.0, _hint),
Row( ];
children: [
const Icon(Icons.group, return _MetricCard(
color: Colors.blueAccent, size: 26), icon: Icons.group,
MySpacing.width(8), iconColor: _primaryA,
MyText("Teams", style: _titleTextStyle), title: "Teams",
], subtitle: hasData ? "Attendance today" : "Awaiting data",
chart: _SemiDonutChart(
percentLabel: "${(percent * 100).toInt()}%",
data: data,
startAngle: 270,
endAngle: 90,
showLegend: false,
), ),
MySpacing.height(16), footer: _TwoColumnKpis(
// Labels in one row leftLabel: "Total",
Row( leftValue: _comma.format(total),
mainAxisAlignment: MainAxisAlignment.spaceBetween, rightLabel: "In Today",
children: [ rightValue: _comma.format(inToday),
MyText("Total Employees", style: _subtitleTextStyle), rightColor: _accentA,
MyText("In Today", style: _subtitleTextStyle),
],
), ),
MySpacing.height(4),
// Values in one row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText(_commaFormatter.format(total),
style: _infoNumberTextStyle),
MyText(_commaFormatter.format(inToday),
style: _infoNumberGreenTextStyle.copyWith(
color: Colors.green[700])),
],
),
],
),
),
);
},
); );
}); });
} }
/// Tasks Overview Card // Public API: Tasks overview card
static Widget tasksOverview() { static Widget tasksOverview() {
return Obx(() { return Obx(() {
if (dashboardController.isTasksLoading.value) { if (dashboardController.isTasksLoading.value) {
return _loadingSkeletonCard("Tasks"); return _skeletonCard(title: "Tasks");
} }
final total = dashboardController.totalTasks.value; final total = dashboardController.totalTasks.value;
final completed = dashboardController.completedTasks.value; final completed =
final remaining = total - completed; dashboardController.completedTasks.value.clamp(0, total);
final double percent = total > 0 ? completed / total : 0.0; final remaining = (total - completed).clamp(0, total);
final percent = total > 0 ? completed / total : 0.0;
// Task colors final hasData = total > 0;
const completedColor = Color(0xFF64B5F6);
const remainingColor =Color(0xFFE57373);
final List<_ChartData> pieData = [ final data = hasData
_ChartData('Completed', completed.toDouble(), completedColor), ? [
_ChartData('Remaining', remaining.toDouble(), remainingColor), _ChartData('Completed', completed.toDouble(), _primaryA),
_ChartData('Remaining', remaining.toDouble(), _warnA),
]
: [
_ChartData('Completed', 1.0, _hint),
_ChartData('Remaining', 1.0, _hint),
]; ];
return LayoutBuilder( return _MetricCard(
builder: (context, constraints) { icon: Icons.task_alt,
final cardWidth = iconColor: _primaryA,
constraints.maxWidth < 300 ? constraints.maxWidth : 300.0; title: "Tasks",
subtitle: hasData ? "Completion status" : "Awaiting data",
chart: _SemiDonutChart(
percentLabel: "${(percent * 100).toInt()}%",
data: data,
startAngle: 270,
endAngle: 90,
showLegend: true,
),
footer: _TwoColumnKpis(
leftLabel: "Total",
leftValue: _comma.format(total),
rightLabel: "Completed",
rightValue: _comma.format(completed),
rightColor: _primaryA,
),
);
});
}
// Skeleton loading card
static Widget _skeletonCard({required String title}) {
return LayoutBuilder(builder: (context, constraints) {
final width = constraints.maxWidth.clamp(220.0, 480.0);
return SizedBox( return SizedBox(
width: cardWidth, width: width,
child: MyCard( child: MyCard(
borderRadiusAll: 5, borderRadiusAll: 5,
paddingAll: 20, paddingAll: 16,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Icon + Title _Skeleton.line(width: 120, height: 16),
Row( MySpacing.height(12),
children: [ _Skeleton.line(width: 80, height: 12),
const Icon(Icons.task_alt, MySpacing.height(16),
color: completedColor, size: 26), _Skeleton.block(height: 120),
MySpacing.width(8), MySpacing.height(16),
MyText("Tasks", style: _titleTextStyle), _Skeleton.line(width: double.infinity, height: 12),
], ],
), ),
MySpacing.height(16), ),
);
});
}
}
// Main Row: Bigger Pie Chart + Full-Color Info Boxes // Composable: Metric Card scaffold
class _MetricCard extends StatelessWidget {
final IconData icon;
final Color iconColor;
final String title;
final String subtitle;
final Widget chart;
final Widget footer;
const _MetricCard({
required this.icon,
required this.iconColor,
required this.title,
required this.subtitle,
required this.chart,
required this.footer,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
final maxW = constraints.maxWidth;
final clampedW = maxW.clamp(260.0, 560.0);
final dense = clampedW < 340;
return SizedBox(
width: clampedW,
child: MyCard(
borderRadiusAll: 5,
paddingAll: dense ? 14 : 16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
_IconBadge(icon: icon, color: iconColor),
MySpacing.width(10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText(title,
style: DashboardOverviewWidgets._titleStyle),
MySpacing.height(2),
MyText(subtitle,
style: DashboardOverviewWidgets._subtitleStyle),
MySpacing.height(12),
],
),
),
],
),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Pie Chart Column (Bigger)
SizedBox(
height: 140,
width: 140,
child: SfCircularChart(
annotations: <CircularChartAnnotation>[
CircularChartAnnotation(
widget: MyText(
"${(percent * 100).toInt()}%",
style: _infoNumberGreenTextStyle.copyWith(
fontSize: 20),
),
),
],
series: <PieSeries<_ChartData, String>>[
PieSeries<_ChartData, String>(
dataSource: pieData,
xValueMapper: (_ChartData data, _) =>
data.category,
yValueMapper: (_ChartData data, _) => data.value,
pointColorMapper: (_ChartData data, _) =>
data.color,
dataLabelSettings:
const DataLabelSettings(isVisible: false),
radius: '100%',
),
],
),
),
MySpacing.width(16),
// Info Boxes Column (Full Color)
Expanded( Expanded(
flex: 2,
child: SizedBox(
height: dense ? 110 : 80,
child: chart,
),
),
MySpacing.width(12),
Expanded(
flex: 1,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [
_infoBoxFullColor(
"Completed", completed, completedColor),
MySpacing.height(8),
_infoBoxFullColor(
"Remaining", remaining, remainingColor),
],
),
),
],
),
],
),
),
);
},
);
});
}
/// Full-color info box
static Widget _infoBoxFullColor(String label, int value, Color bgColor) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
decoration: BoxDecoration(
color: bgColor, // full color
borderRadius: BorderRadius.circular(5),
),
child: Column(
children: [
MyText(_commaFormatter.format(value),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
)),
MySpacing.height(2),
MyText(label,
style: const TextStyle(
fontSize: 12,
color: Colors.white, // text in white for contrast
)),
],
),
);
}
/// Loading Skeleton Card
static Widget _loadingSkeletonCard(String title) {
return LayoutBuilder(builder: (context, constraints) {
final cardWidth =
constraints.maxWidth < 200 ? constraints.maxWidth : 200.0;
return SizedBox(
width: cardWidth,
child: MyCard(
borderRadiusAll: 5,
paddingAll: 20,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_loadingBar(width: 100), footer,
MySpacing.height(12), ],
_loadingBar(width: 80), ),
MySpacing.height(12), ),
_loadingBar(width: double.infinity, height: 12), ],
),
], ],
), ),
), ),
); );
}); });
} }
}
static Widget _loadingBar( // Composable: Two-column KPIs
{double width = double.infinity, double height = 16}) { class _TwoColumnKpis extends StatelessWidget {
final String leftLabel;
final String leftValue;
final String rightLabel;
final String rightValue;
final Color rightColor;
const _TwoColumnKpis({
required this.leftLabel,
required this.leftValue,
required this.rightLabel,
required this.rightValue,
required this.rightColor,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText(leftLabel, style: DashboardOverviewWidgets._subtitleStyle),
MyText(rightLabel, style: DashboardOverviewWidgets._subtitleStyle),
],
),
MySpacing.height(4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText(leftValue, style: DashboardOverviewWidgets._metricStyle),
MyText(
rightValue,
style: DashboardOverviewWidgets._metricStyle
.copyWith(color: rightColor),
),
],
),
],
);
}
}
// Composable: Semi-donut chart with annotation
class _SemiDonutChart extends StatelessWidget {
final String percentLabel;
final List<_ChartData> data;
final int startAngle;
final int endAngle;
final bool showLegend;
const _SemiDonutChart({
required this.percentLabel,
required this.data,
required this.startAngle,
required this.endAngle,
this.showLegend = false,
});
bool get _hasData =>
data.isNotEmpty &&
data.any((d) => d.color != DashboardOverviewWidgets._hint);
@override
Widget build(BuildContext context) {
final chartData = _hasData
? data
: [
// Single grey slice for empty data
_ChartData('No Data', 1.0, DashboardOverviewWidgets._hint),
];
return SfCircularChart(
margin: EdgeInsets.zero,
centerX: '50%',
centerY: '50%',
legend: Legend(
isVisible: showLegend && _hasData,
position: LegendPosition.bottom,
overflowMode: LegendItemOverflowMode.wrap,
// Optional: tighter legend text reduces needed width
textStyle: const TextStyle(fontSize: 12, color: Colors.black87),
),
annotations: <CircularChartAnnotation>[
CircularChartAnnotation(
// Keep small to avoid pushing layout
height: '0%',
width: '0%',
widget: Center(
child: MyText(
percentLabel,
style: DashboardOverviewWidgets._percentStyle,
),
),
),
],
series: <DoughnutSeries<_ChartData, String>>[
DoughnutSeries<_ChartData, String>(
dataSource: chartData,
xValueMapper: (_ChartData d, _) => d.category,
yValueMapper: (_ChartData d, _) => d.value,
pointColorMapper: (_ChartData d, _) => d.color,
startAngle: startAngle,
endAngle: endAngle,
radius: '100%',
innerRadius: '72%',
strokeWidth: 0, // avoids white stroke showing as gap
dataLabelSettings: const DataLabelSettings(isVisible: false),
),
],
);
}
}
// Small UI parts
class _IconBadge extends StatelessWidget {
final IconData icon;
final Color color;
const _IconBadge({required this.icon, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: DashboardOverviewWidgets._bgSoft,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: color, size: 22),
);
}
}
class _Skeleton {
static Widget line({double width = double.infinity, double height = 14}) {
return Container( return Container(
height: height,
width: width, width: width,
height: height,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(6),
),
);
}
static Widget block({double height = 120}) {
return Container(
width: double.infinity,
height: height,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
), ),
); );
} }
@ -272,6 +418,5 @@ class _ChartData {
final String category; final String category;
final double value; final double value;
final Color color; final Color color;
_ChartData(this.category, this.value, this.color); _ChartData(this.category, this.value, this.color);
} }