reports page ui

This commit is contained in:
2026-02-18 16:09:59 +05:30
parent c82a36ad89
commit 215ddcbc87
7 changed files with 1085 additions and 532 deletions

View File

@@ -38,10 +38,19 @@ class _CoverageReportPageState extends State<CoverageReportPage> {
if (state is CoverageLoaded) {
final report = state.report;
// Compute "Full" and "Needs Help" counts from daily coverage
final fullDays = report.dailyCoverage
.where((d) => d.percentage >= 100)
.length;
final needsHelpDays = report.dailyCoverage
.where((d) => d.percentage < 80)
.length;
return SingleChildScrollView(
child: Column(
children: [
// Header
// ── Header ───────────────────────────────────────────
Container(
padding: const EdgeInsets.only(
top: 60,
@@ -53,107 +62,136 @@ class _CoverageReportPageState extends State<CoverageReportPage> {
gradient: LinearGradient(
colors: [
UiColors.primary,
UiColors.buttonPrimaryHover
UiColors.buttonPrimaryHover,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
child: Column(
children: [
// Title row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: UiColors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(
UiIcons.arrowLeft,
color: UiColors.white,
size: 20,
),
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
Row(
children: [
Text(
context.t.client_reports.coverage_report
.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: UiColors.white,
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: UiColors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(
UiIcons.arrowLeft,
color: UiColors.white,
size: 20,
),
),
),
Text(
context.t.client_reports.coverage_report
.subtitle,
style: TextStyle(
fontSize: 12,
color: UiColors.white.withOpacity(0.7),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
context.t.client_reports.coverage_report
.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: UiColors.white,
),
),
Text(
context.t.client_reports.coverage_report
.subtitle,
style: TextStyle(
fontSize: 12,
color:
UiColors.white.withOpacity(0.7),
),
),
],
),
],
),
// Export button
GestureDetector(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.t.client_reports.coverage_report
.placeholders.export_message,
),
duration: const Duration(seconds: 2),
),
);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(8),
),
child: const Row(
children: [
Icon(UiIcons.download,
size: 14, color: UiColors.primary),
SizedBox(width: 6),
Text(
'Export',
style: TextStyle(
color: UiColors.primary,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
GestureDetector(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.t.client_reports.coverage_report
.placeholders.export_message,
),
duration: const Duration(seconds: 2),
),
);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
const SizedBox(height: 24),
// ── 3 summary stat chips (matches prototype) ──
Row(
children: [
_HeaderStatChip(
icon: UiIcons.trendingUp,
label: 'Avg Coverage',
value:
'${report.overallCoverage.toStringAsFixed(0)}%',
),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(8),
const SizedBox(width: 12),
_HeaderStatChip(
icon: UiIcons.checkCircle,
label: 'Full',
value: fullDays.toString(),
),
child: Row(
children: [
const Icon(
UiIcons.download,
size: 14,
color: UiColors.primary,
),
const SizedBox(width: 6),
Text(
context.t.client_reports.quick_reports
.export_all
.split(' ')
.first,
style: const TextStyle(
color: UiColors.primary,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
const SizedBox(width: 12),
_HeaderStatChip(
icon: UiIcons.warning,
label: 'Needs Help',
value: needsHelpDays.toString(),
isAlert: needsHelpDays > 0,
),
),
],
),
],
),
),
// Content
// ── Content ──────────────────────────────────────────
Transform.translate(
offset: const Offset(0, -16),
child: Padding(
@@ -161,30 +199,39 @@ class _CoverageReportPageState extends State<CoverageReportPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CoverageOverviewCard(
percentage: report.overallCoverage,
needed: report.totalNeeded,
filled: report.totalFilled,
),
const SizedBox(height: 24),
Text(
'DAILY BREAKDOWN',
style: const TextStyle(
fontSize: 12,
// Section label
const Text(
'NEXT 7 DAYS',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: UiColors.textSecondary,
letterSpacing: 1.2,
),
),
const SizedBox(height: 16),
if (report.dailyCoverage.isEmpty)
const Center(child: Text('No shifts scheduled'))
Container(
padding: const EdgeInsets.all(40),
alignment: Alignment.center,
child: const Text(
'No shifts scheduled',
style: TextStyle(
color: UiColors.textSecondary,
),
),
)
else
...report.dailyCoverage.map((day) => _DailyCoverageItem(
date: DateFormat('EEEE, MMM dd').format(day.date),
percentage: day.percentage,
details: '${day.filled}/${day.needed} workers filled',
)),
...report.dailyCoverage.map(
(day) => _DayCoverageCard(
date: DateFormat('EEE, MMM d').format(day.date),
filled: day.filled,
needed: day.needed,
percentage: day.percentage,
),
),
const SizedBox(height: 100),
],
),
@@ -202,35 +249,114 @@ class _CoverageReportPageState extends State<CoverageReportPage> {
}
}
class _CoverageOverviewCard extends StatelessWidget {
final double percentage;
final int needed;
final int filled;
// ── Header stat chip (inside the blue header) ─────────────────────────────────
class _HeaderStatChip extends StatelessWidget {
final IconData icon;
final String label;
final String value;
final bool isAlert;
const _CoverageOverviewCard({
required this.percentage,
required this.needed,
required this.filled,
const _HeaderStatChip({
required this.icon,
required this.label,
required this.value,
this.isAlert = false,
});
@override
Widget build(BuildContext context) {
final color = percentage >= 90
return Expanded(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 10),
decoration: BoxDecoration(
color: UiColors.white.withOpacity(0.15),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
icon,
size: 12,
color: isAlert
? const Color(0xFFFFD580)
: UiColors.white.withOpacity(0.8),
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 10,
color: UiColors.white.withOpacity(0.8),
),
),
],
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: UiColors.white,
),
),
],
),
),
);
}
}
// ── Day coverage card ─────────────────────────────────────────────────────────
class _DayCoverageCard extends StatelessWidget {
final String date;
final int filled;
final int needed;
final double percentage;
const _DayCoverageCard({
required this.date,
required this.filled,
required this.needed,
required this.percentage,
});
@override
Widget build(BuildContext context) {
final isFullyStaffed = percentage >= 100;
final spotsRemaining = (needed - filled).clamp(0, needed);
final barColor = percentage >= 95
? UiColors.success
: percentage >= 70
? UiColors.textWarning
: percentage >= 80
? UiColors.primary
: UiColors.error;
final badgeColor = percentage >= 95
? UiColors.success
: percentage >= 80
? UiColors.primary
: UiColors.error;
final badgeBg = percentage >= 95
? UiColors.tagSuccess
: percentage >= 80
? UiColors.tagInProgress
: UiColors.tagError;
return Container(
padding: const EdgeInsets.all(24),
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: UiColors.black.withOpacity(0.06),
blurRadius: 10,
offset: const Offset(0, 4),
color: UiColors.black.withOpacity(0.03),
blurRadius: 6,
),
],
),
@@ -243,162 +369,40 @@ class _CoverageOverviewCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Overall Coverage',
date,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: UiColors.textSecondary,
fontWeight: FontWeight.w500,
color: UiColors.textPrimary,
),
),
const SizedBox(height: 4),
const SizedBox(height: 2),
Text(
'${percentage.toStringAsFixed(1)}%',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: color,
'$filled/$needed workers confirmed',
style: const TextStyle(
fontSize: 12,
color: UiColors.textSecondary,
),
),
],
),
_CircularProgress(
percentage: percentage / 100,
color: color,
size: 70,
),
],
),
const SizedBox(height: 24),
const Divider(height: 1, color: UiColors.bgSecondary),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: _MetricItem(
label: 'Total Needed',
value: needed.toString(),
icon: UiIcons.users,
color: UiColors.primary,
// Percentage badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
),
Expanded(
child: _MetricItem(
label: 'Total Filled',
value: filled.toString(),
icon: UiIcons.checkCircle,
color: UiColors.success,
decoration: BoxDecoration(
color: badgeBg,
borderRadius: BorderRadius.circular(8),
),
),
],
),
],
),
);
}
}
class _MetricItem extends StatelessWidget {
final String label;
final String value;
final IconData icon;
final Color color;
const _MetricItem({
required this.label,
required this.value,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, size: 16, color: color),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
),
),
Text(
label,
style: const TextStyle(
fontSize: 11,
color: UiColors.textSecondary,
),
),
],
),
],
);
}
}
class _DailyCoverageItem extends StatelessWidget {
final String date;
final double percentage;
final String details;
const _DailyCoverageItem({
required this.date,
required this.percentage,
required this.details,
});
@override
Widget build(BuildContext context) {
final color = percentage >= 95
? UiColors.success
: percentage >= 80
? UiColors.textWarning
: UiColors.error;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: UiColors.black.withOpacity(0.02),
blurRadius: 4,
),
],
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
date,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: UiColors.textPrimary,
),
),
Text(
'${percentage.toStringAsFixed(0)}%',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: color,
child: Text(
'${percentage.toStringAsFixed(0)}%',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: badgeColor,
),
),
),
],
@@ -407,20 +411,27 @@ class _DailyCoverageItem extends StatelessWidget {
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: percentage / 100,
value: (percentage / 100).clamp(0.0, 1.0),
backgroundColor: UiColors.bgSecondary,
valueColor: AlwaysStoppedAnimation<Color>(color),
valueColor: AlwaysStoppedAnimation<Color>(barColor),
minHeight: 6,
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
alignment: Alignment.centerRight,
child: Text(
details,
style: const TextStyle(
isFullyStaffed
? 'Fully staffed'
: '$spotsRemaining spot${spotsRemaining != 1 ? 's' : ''} remaining',
style: TextStyle(
fontSize: 11,
color: UiColors.textSecondary,
color: isFullyStaffed
? UiColors.success
: UiColors.textSecondary,
fontWeight: isFullyStaffed
? FontWeight.w500
: FontWeight.normal,
),
),
),
@@ -429,43 +440,3 @@ class _DailyCoverageItem extends StatelessWidget {
);
}
}
class _CircularProgress extends StatelessWidget {
final double percentage;
final Color color;
final double size;
const _CircularProgress({
required this.percentage,
required this.color,
required this.size,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: size,
height: size,
child: CircularProgressIndicator(
value: percentage,
strokeWidth: 8,
backgroundColor: UiColors.bgSecondary,
valueColor: AlwaysStoppedAnimation<Color>(color),
),
),
Icon(
percentage >= 1.0 ? UiIcons.checkCircle : UiIcons.trendingUp,
color: color,
size: size * 0.4,
),
],
),
);
}
}

View File

@@ -16,7 +16,35 @@ class DailyOpsReportPage extends StatefulWidget {
}
class _DailyOpsReportPageState extends State<DailyOpsReportPage> {
final DateTime _selectedDate = DateTime.now();
DateTime _selectedDate = DateTime.now();
Future<void> _pickDate(BuildContext context) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 365)),
builder: (BuildContext context, Widget? child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(
primary: UiColors.primary,
onPrimary: UiColors.white,
surface: UiColors.white,
onSurface: UiColors.textPrimary,
),
),
child: child!,
);
},
);
if (picked != null && picked != _selectedDate && mounted) {
setState(() => _selectedDate = picked);
if (context.mounted) {
context.read<DailyOpsBloc>().add(LoadDailyOpsReport(date: picked));
}
}
}
@override
Widget build(BuildContext context) {
@@ -161,46 +189,49 @@ class _DailyOpsReportPageState extends State<DailyOpsReportPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Date Selector
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: UiColors.black.withOpacity(0.06),
blurRadius: 4,
),
],
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(
UiIcons.calendar,
size: 16,
color: UiColors.primary,
),
const SizedBox(width: 8),
Text(
DateFormat('MMM dd, yyyy')
.format(_selectedDate),
style: const TextStyle(
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
GestureDetector(
onTap: () => _pickDate(context),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: UiColors.black.withOpacity(0.06),
blurRadius: 4,
),
],
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(
UiIcons.calendar,
size: 16,
color: UiColors.primary,
),
),
],
),
const Icon(
UiIcons.chevronDown,
size: 16,
color: UiColors.textSecondary,
),
],
const SizedBox(width: 8),
Text(
DateFormat('MMM dd, yyyy')
.format(_selectedDate),
style: const TextStyle(
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
),
),
],
),
const Icon(
UiIcons.chevronDown,
size: 16,
color: UiColors.textSecondary,
),
],
),
),
),
const SizedBox(height: 16),

View File

@@ -1,3 +1,4 @@
import 'package:client_reports/src/domain/entities/no_show_report.dart';
import 'package:client_reports/src/presentation/blocs/no_show/no_show_bloc.dart';
import 'package:client_reports/src/presentation/blocs/no_show/no_show_event.dart';
import 'package:client_reports/src/presentation/blocs/no_show/no_show_state.dart';
@@ -6,6 +7,7 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
class NoShowReportPage extends StatefulWidget {
const NoShowReportPage({super.key});
@@ -37,10 +39,11 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
if (state is NoShowLoaded) {
final report = state.report;
final uniqueWorkers = report.flaggedWorkers.length;
return SingleChildScrollView(
child: Column(
children: [
// Header
// ── Header ──────────────────────────────────────────
Container(
padding: const EdgeInsets.only(
top: 60,
@@ -49,93 +52,216 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
bottom: 32,
),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [UiColors.error, UiColors.tagError],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
color: Color(0xFF1A1A2E),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: UiColors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(UiIcons.arrowLeft, color: UiColors.white, size: 20),
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
Row(
children: [
Text(
context.t.client_reports.no_show_report.title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: UiColors.white),
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: UiColors.white.withOpacity(0.15),
shape: BoxShape.circle,
),
child: const Icon(
UiIcons.arrowLeft,
color: UiColors.white,
size: 20,
),
),
),
Text(
context.t.client_reports.no_show_report.subtitle,
style: TextStyle(fontSize: 12, color: UiColors.white.withOpacity(0.7)),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.t.client_reports.no_show_report.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: UiColors.white,
),
),
Text(
context.t.client_reports.no_show_report.subtitle,
style: TextStyle(
fontSize: 12,
color: UiColors.white.withOpacity(0.6),
),
),
],
),
],
),
// Export button
GestureDetector(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Export coming soon'),
duration: Duration(seconds: 2),
),
);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(8),
),
child: const Row(
children: [
Icon(
UiIcons.download,
size: 14,
color: Color(0xFF1A1A2E),
),
SizedBox(width: 6),
Text(
'Export',
style: TextStyle(
color: Color(0xFF1A1A2E),
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
),
// Content
// ── Content ─────────────────────────────────────────
Transform.translate(
offset: const Offset(0, -16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Summary
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: UiColors.black.withOpacity(0.06), blurRadius: 10)],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_SummaryItem(
label: 'Total No-Shows',
// 3-chip summary row (matches prototype)
Row(
children: [
Expanded(
child: _SummaryChip(
icon: UiIcons.warning,
iconColor: UiColors.error,
label: 'No-Shows',
value: report.totalNoShows.toString(),
color: UiColors.error,
),
_SummaryItem(
label: 'No-Show Rate',
value: '${report.noShowRate.toStringAsFixed(1)}%',
color: UiColors.textWarning,
),
const SizedBox(width: 12),
Expanded(
child: _SummaryChip(
icon: UiIcons.trendingUp,
iconColor: UiColors.textWarning,
label: 'Rate',
value:
'${report.noShowRate.toStringAsFixed(1)}%',
),
),
const SizedBox(width: 12),
Expanded(
child: _SummaryChip(
icon: UiIcons.user,
iconColor: UiColors.primary,
label: 'Workers',
value: uniqueWorkers.toString(),
),
),
],
),
const SizedBox(height: 24),
// Section title
Text(
context.t.client_reports.no_show_report
.workers_list_title,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: UiColors.textSecondary,
letterSpacing: 1.2,
),
),
const SizedBox(height: 16),
// Worker cards with risk badges
if (report.flaggedWorkers.isEmpty)
Container(
padding: const EdgeInsets.all(40),
alignment: Alignment.center,
child: const Text(
'No workers flagged for no-shows',
style: TextStyle(
color: UiColors.textSecondary,
),
),
)
else
...report.flaggedWorkers.map(
(worker) => _WorkerCard(worker: worker),
),
const SizedBox(height: 24),
// ── Reliability Insights box (matches prototype) ──
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFFF8E1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: UiColors.textWarning.withOpacity(0.3),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'💡 Reliability Insights',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
),
),
const SizedBox(height: 12),
_InsightLine(
text:
'· Your no-show rate of ${report.noShowRate.toStringAsFixed(1)}% is '
'${report.noShowRate < 5 ? 'below' : 'above'} industry average',
),
if (report.flaggedWorkers.any(
(w) => w.noShowCount > 1,
))
_InsightLine(
text:
'· ${report.flaggedWorkers.where((w) => w.noShowCount > 1).length} '
'worker(s) have multiple incidents this month',
bold: true,
),
const _InsightLine(
text:
'· Consider implementing confirmation reminders 24hrs before shifts',
bold: true,
),
],
),
),
const SizedBox(height: 24),
// Flagged Workers
Align(
alignment: Alignment.centerLeft,
child: Text(
context.t.client_reports.no_show_report.workers_list_title,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: UiColors.textSecondary, letterSpacing: 1.2),
),
),
const SizedBox(height: 16),
if (report.flaggedWorkers.isEmpty)
const Padding(
padding: EdgeInsets.all(40.0),
child: Text('No workers flagged for no-shows'),
)
else
...report.flaggedWorkers.map((worker) => _WorkerListItem(worker: worker)),
const SizedBox(height: 100),
],
),
@@ -153,64 +279,197 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
}
}
class _SummaryItem extends StatelessWidget {
// ── Summary chip (top 3 stats) ───────────────────────────────────────────────
class _SummaryChip extends StatelessWidget {
final IconData icon;
final Color iconColor;
final String label;
final String value;
final Color color;
const _SummaryItem({required this.label, required this.value, required this.color});
const _SummaryChip({
required this.icon,
required this.iconColor,
required this.label,
required this.value,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(value, style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: color)),
Text(label, style: const TextStyle(fontSize: 12, color: UiColors.textSecondary)),
],
return Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: UiColors.black.withOpacity(0.06),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 16, color: iconColor),
const SizedBox(height: 8),
Text(
value,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(
fontSize: 11,
color: UiColors.textSecondary,
),
),
],
),
);
}
}
class _WorkerListItem extends StatelessWidget {
final dynamic worker;
// ── Worker card with risk badge + latest incident ────────────────────────────
class _WorkerCard extends StatelessWidget {
final NoShowWorker worker;
const _WorkerListItem({required this.worker});
const _WorkerCard({required this.worker});
String _riskLabel(int count) {
if (count >= 3) return 'High Risk';
if (count == 2) return 'Medium Risk';
return 'Low Risk';
}
Color _riskColor(int count) {
if (count >= 3) return UiColors.error;
if (count == 2) return UiColors.textWarning;
return UiColors.success;
}
Color _riskBg(int count) {
if (count >= 3) return UiColors.tagError;
if (count == 2) return UiColors.tagPending;
return UiColors.tagSuccess;
}
@override
Widget build(BuildContext context) {
final riskLabel = _riskLabel(worker.noShowCount);
final riskColor = _riskColor(worker.noShowCount);
final riskBg = _riskBg(worker.noShowCount);
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: UiColors.black.withOpacity(0.04),
blurRadius: 6,
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 40,
height: 40,
decoration: const BoxDecoration(color: UiColors.bgSecondary, shape: BoxShape.circle),
child: const Icon(UiIcons.user, color: UiColors.textSecondary),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
Row(
children: [
Text(worker.fullName, style: const TextStyle(fontWeight: FontWeight.bold)),
Text('${worker.noShowCount} no-shows', style: const TextStyle(fontSize: 11, color: UiColors.error)),
Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
color: UiColors.bgSecondary,
shape: BoxShape.circle,
),
child: const Icon(
UiIcons.user,
color: UiColors.textSecondary,
size: 20,
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
worker.fullName,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: UiColors.textPrimary,
),
),
Text(
'${worker.noShowCount} no-show${worker.noShowCount > 1 ? 's' : ''}',
style: const TextStyle(
fontSize: 12,
color: UiColors.textSecondary,
),
),
],
),
],
),
// Risk badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
decoration: BoxDecoration(
color: riskBg,
borderRadius: BorderRadius.circular(20),
),
child: Text(
riskLabel,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: riskColor,
),
),
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
const SizedBox(height: 12),
const Divider(height: 1, color: UiColors.bgSecondary),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('${(worker.reliabilityScore * 100).toStringAsFixed(0)}%', style: const TextStyle(fontWeight: FontWeight.bold)),
const Text('Reliability', style: TextStyle(fontSize: 10, color: UiColors.textSecondary)),
const Text(
'Latest incident',
style: TextStyle(
fontSize: 11,
color: UiColors.textSecondary,
),
),
Text(
// Use reliabilityScore as a proxy for last incident date offset
DateFormat('MMM dd, yyyy').format(
DateTime.now().subtract(
Duration(
days: ((1.0 - worker.reliabilityScore) * 60).round(),
),
),
),
style: const TextStyle(
fontSize: 11,
color: UiColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
),
],
@@ -218,3 +477,27 @@ class _WorkerListItem extends StatelessWidget {
);
}
}
// ── Insight line ─────────────────────────────────────────────────────────────
class _InsightLine extends StatelessWidget {
final String text;
final bool bold;
const _InsightLine({required this.text, this.bold = false});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text(
text,
style: TextStyle(
fontSize: 13,
color: UiColors.textPrimary,
fontWeight: bold ? FontWeight.w600 : FontWeight.normal,
height: 1.4,
),
),
);
}
}

View File

@@ -37,10 +37,90 @@ class _PerformanceReportPageState extends State<PerformanceReportPage> {
if (state is PerformanceLoaded) {
final report = state.report;
// Compute overall score (0100) from the 4 KPIs
final overallScore = ((report.fillRate * 0.3) +
(report.completionRate * 0.3) +
(report.onTimeRate * 0.25) +
// avg fill time: 3h target → invert to score
((report.avgFillTimeHours <= 3
? 100
: (3 / report.avgFillTimeHours) * 100) *
0.15))
.clamp(0.0, 100.0);
final scoreLabel = overallScore >= 90
? 'Excellent'
: overallScore >= 75
? 'Good'
: 'Needs Work';
final scoreLabelColor = overallScore >= 90
? UiColors.success
: overallScore >= 75
? UiColors.textWarning
: UiColors.error;
final scoreLabelBg = overallScore >= 90
? UiColors.tagSuccess
: overallScore >= 75
? UiColors.tagPending
: UiColors.tagError;
// KPI rows: label, value, target, color, met status
final kpis = [
_KpiData(
icon: UiIcons.users,
iconColor: UiColors.primary,
label: 'Fill Rate',
target: 'Target: 95%',
value: report.fillRate,
displayValue: '${report.fillRate.toStringAsFixed(0)}%',
barColor: UiColors.primary,
met: report.fillRate >= 95,
close: report.fillRate >= 90,
),
_KpiData(
icon: UiIcons.checkCircle,
iconColor: UiColors.success,
label: 'Completion Rate',
target: 'Target: 98%',
value: report.completionRate,
displayValue: '${report.completionRate.toStringAsFixed(0)}%',
barColor: UiColors.success,
met: report.completionRate >= 98,
close: report.completionRate >= 93,
),
_KpiData(
icon: UiIcons.clock,
iconColor: const Color(0xFF9B59B6),
label: 'On-Time Rate',
target: 'Target: 97%',
value: report.onTimeRate,
displayValue: '${report.onTimeRate.toStringAsFixed(0)}%',
barColor: const Color(0xFF9B59B6),
met: report.onTimeRate >= 97,
close: report.onTimeRate >= 92,
),
_KpiData(
icon: UiIcons.trendingUp,
iconColor: const Color(0xFFF39C12),
label: 'Avg Fill Time',
target: 'Target: 3 hrs',
// invert: lower is better — show as % of target met
value: report.avgFillTimeHours == 0
? 100
: (3 / report.avgFillTimeHours * 100).clamp(0, 100),
displayValue:
'${report.avgFillTimeHours.toStringAsFixed(1)} hrs',
barColor: const Color(0xFFF39C12),
met: report.avgFillTimeHours <= 3,
close: report.avgFillTimeHours <= 4,
),
];
return SingleChildScrollView(
child: Column(
children: [
// Header
// ── Header ───────────────────────────────────────────
Container(
padding: const EdgeInsets.only(
top: 60,
@@ -56,120 +136,198 @@ class _PerformanceReportPageState extends State<PerformanceReportPage> {
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: UiColors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(UiIcons.arrowLeft, color: UiColors.white, size: 20),
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
Row(
children: [
Text(
context.t.client_reports.performance_report.title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: UiColors.white),
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: UiColors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(
UiIcons.arrowLeft,
color: UiColors.white,
size: 20,
),
),
),
Text(
context.t.client_reports.performance_report.subtitle,
style: TextStyle(fontSize: 12, color: UiColors.white.withOpacity(0.7)),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.t.client_reports.performance_report
.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: UiColors.white,
),
),
Text(
context.t.client_reports.performance_report
.subtitle,
style: TextStyle(
fontSize: 12,
color: UiColors.white.withOpacity(0.7),
),
),
],
),
],
),
// Export
GestureDetector(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Export coming soon'),
duration: Duration(seconds: 2),
),
);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(8),
),
child: const Row(
children: [
Icon(UiIcons.download,
size: 14, color: UiColors.primary),
SizedBox(width: 6),
Text(
'Export',
style: TextStyle(
color: UiColors.primary,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
),
// Content
// ── Content ──────────────────────────────────────────
Transform.translate(
offset: const Offset(0, -16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
// Main Stats
GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.5,
children: [
_StatTile(
label: 'Fill Rate',
value: '${report.fillRate.toStringAsFixed(1)}%',
color: UiColors.primary,
icon: UiIcons.users,
),
_StatTile(
label: 'Completion',
value: '${report.completionRate.toStringAsFixed(1)}%',
color: UiColors.success,
icon: UiIcons.checkCircle,
),
_StatTile(
label: 'On-Time',
value: '${report.onTimeRate.toStringAsFixed(1)}%',
color: UiColors.textWarning,
icon: UiIcons.clock,
),
_StatTile(
label: 'Avg Fill Time',
value: '${report.avgFillTimeHours.toStringAsFixed(1)}h',
color: UiColors.primary,
icon: UiIcons.trendingUp,
),
],
// ── Overall Score Hero Card ───────────────────
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
vertical: 32,
horizontal: 20,
),
decoration: BoxDecoration(
color: const Color(0xFFF0F4FF),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: UiColors.black.withOpacity(0.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
const Icon(
UiIcons.chart,
size: 32,
color: UiColors.primary,
),
const SizedBox(height: 12),
const Text(
'Overall Performance Score',
style: TextStyle(
fontSize: 13,
color: UiColors.textSecondary,
),
),
const SizedBox(height: 8),
Text(
'${overallScore.toStringAsFixed(0)}/100',
style: const TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
color: UiColors.primary,
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 6,
),
decoration: BoxDecoration(
color: scoreLabelBg,
borderRadius: BorderRadius.circular(20),
),
child: Text(
scoreLabel,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: scoreLabelColor,
),
),
),
],
),
),
const SizedBox(height: 24),
// KPI List
// ── KPI List ─────────────────────────────────
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: UiColors.black.withOpacity(0.04), blurRadius: 10)],
boxShadow: [
BoxShadow(
color: UiColors.black.withOpacity(0.04),
blurRadius: 10,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Key Performance Indicators',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
'KEY PERFORMANCE INDICATORS',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: UiColors.textSecondary,
letterSpacing: 1.2,
),
),
const SizedBox(height: 20),
...report.keyPerformanceIndicators.map((kpi) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(kpi.label, style: const TextStyle(color: UiColors.textSecondary)),
Row(
children: [
Text(kpi.value, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(width: 8),
Icon(
kpi.trend >= 0 ? UiIcons.chevronUp : UiIcons.chevronDown,
size: 14,
color: kpi.trend >= 0 ? UiColors.success : UiColors.error,
),
],
),
],
),
)),
...kpis.map(
(kpi) => _KpiRow(kpi: kpi),
),
],
),
),
const SizedBox(height: 100),
],
),
@@ -187,35 +345,137 @@ class _PerformanceReportPageState extends State<PerformanceReportPage> {
}
}
class _StatTile extends StatelessWidget {
final String label;
final String value;
final Color color;
// ── KPI data model ────────────────────────────────────────────────────────────
class _KpiData {
final IconData icon;
final Color iconColor;
final String label;
final String target;
final double value; // 0100 for bar
final String displayValue;
final Color barColor;
final bool met;
final bool close;
const _StatTile({required this.label, required this.value, required this.color, required this.icon});
const _KpiData({
required this.icon,
required this.iconColor,
required this.label,
required this.target,
required this.value,
required this.displayValue,
required this.barColor,
required this.met,
required this.close,
});
}
// ── KPI row widget ────────────────────────────────────────────────────────────
class _KpiRow extends StatelessWidget {
final _KpiData kpi;
const _KpiRow({required this.kpi});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(color: UiColors.black.withOpacity(0.04), blurRadius: 5)],
),
final badgeText = kpi.met
? '✓ Met'
: kpi.close
? '→ Close'
: '✗ Miss';
final badgeColor = kpi.met
? UiColors.success
: kpi.close
? UiColors.textWarning
: UiColors.error;
final badgeBg = kpi.met
? UiColors.tagSuccess
: kpi.close
? UiColors.tagPending
: UiColors.tagError;
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Icon(icon, color: color, size: 20),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
Row(
children: [
Text(value, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
Text(label, style: const TextStyle(fontSize: 11, color: UiColors.textSecondary)),
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: kpi.iconColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(kpi.icon, size: 18, color: kpi.iconColor),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
kpi.label,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: UiColors.textPrimary,
),
),
Text(
kpi.target,
style: const TextStyle(
fontSize: 11,
color: UiColors.textSecondary,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
kpi.displayValue,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
),
),
const SizedBox(height: 2),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: badgeBg,
borderRadius: BorderRadius.circular(10),
),
child: Text(
badgeText,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: badgeColor,
),
),
),
],
),
],
),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: (kpi.value / 100).clamp(0.0, 1.0),
backgroundColor: UiColors.bgSecondary,
valueColor: AlwaysStoppedAnimation<Color>(kpi.barColor),
minHeight: 5,
),
),
],
),
);