Refactor coverage widgets and improve localization

- Replaced custom navigation buttons with a new CalendarNavButton widget in coverage_calendar_selector.dart.
- Removed the CoverageHeader widget as it is no longer needed.
- Updated CoverageQuickStats to use CoverageStatCard for displaying statistics.
- Refactored CoverageShiftList to utilize ShiftHeader and WorkerRow for better structure.
- Added LateWorkersAlert with improved localization for late worker notifications.
- Introduced CoverageBadge and CoverageStatCard for better encapsulation of UI components.
- Created CoverageStatsHeader for displaying coverage metrics in a consistent format.
- Implemented ShiftHeader to manage shift-related information display.
- Developed WorkerRow to represent individual worker statuses with proper localization.
This commit is contained in:
Achintha Isuru
2026-03-10 12:27:27 -04:00
parent a22a092b56
commit 80b83a16f3
14 changed files with 707 additions and 761 deletions

View File

@@ -1692,9 +1692,44 @@
"todays_cost": "Today's Cost", "todays_cost": "Today's Cost",
"no_shifts_day": "No shifts scheduled for this day", "no_shifts_day": "No shifts scheduled for this day",
"no_workers_assigned": "No workers assigned yet", "no_workers_assigned": "No workers assigned yet",
"status_checked_in_at": "Checked In at $time",
"status_on_site": "On Site",
"status_en_route": "En Route",
"status_en_route_expected": "En Route - Expected $time",
"status_confirmed": "Confirmed",
"status_running_late": "Running Late",
"status_late": "Late",
"status_checked_out": "Checked Out",
"status_done": "Done",
"status_no_show": "No Show",
"status_completed": "Completed",
"worker_row": { "worker_row": {
"verify": "Verify", "verify": "Verify",
"verified_message": "Worker attire verified for $name" "verified_message": "Worker attire verified for $name"
},
"page": {
"daily_coverage": "Daily Coverage",
"coverage_status": "Coverage Status",
"workers": "Workers",
"error_occurred": "An error occurred",
"retry": "Retry",
"shifts": "Shifts"
},
"calendar": {
"prev_week": "\u2190 Prev Week",
"today": "Today",
"next_week": "Next Week \u2192"
},
"stats": {
"checked_in": "Checked In",
"en_route": "En Route"
},
"alert": {
"workers_running_late(count)": {
"one": "$count worker is running late",
"other": "$count workers are running late"
},
"auto_backup_searching": "Auto-backup system is searching for replacements."
} }
}, },
"client_reports_common": { "client_reports_common": {

View File

@@ -1692,9 +1692,44 @@
"todays_cost": "Costo de Hoy", "todays_cost": "Costo de Hoy",
"no_shifts_day": "No hay turnos programados para este día", "no_shifts_day": "No hay turnos programados para este día",
"no_workers_assigned": "Aún no hay trabajadores asignados", "no_workers_assigned": "Aún no hay trabajadores asignados",
"status_checked_in_at": "Registrado a las $time",
"status_on_site": "En Sitio",
"status_en_route": "En Camino",
"status_en_route_expected": "En Camino - Esperado $time",
"status_confirmed": "Confirmado",
"status_running_late": "Llegando Tarde",
"status_late": "Tarde",
"status_checked_out": "Salida Registrada",
"status_done": "Hecho",
"status_no_show": "No Se Presentó",
"status_completed": "Completado",
"worker_row": { "worker_row": {
"verify": "Verificar", "verify": "Verificar",
"verified_message": "Vestimenta del trabajador verificada para $name" "verified_message": "Vestimenta del trabajador verificada para $name"
},
"page": {
"daily_coverage": "Cobertura Diaria",
"coverage_status": "Estado de Cobertura",
"workers": "Trabajadores",
"error_occurred": "Ocurri\u00f3 un error",
"retry": "Reintentar",
"shifts": "Turnos"
},
"calendar": {
"prev_week": "\u2190 Semana Anterior",
"today": "Hoy",
"next_week": "Semana Siguiente \u2192"
},
"stats": {
"checked_in": "Registrado",
"en_route": "En Camino"
},
"alert": {
"workers_running_late(count)": {
"one": "$count trabajador est\u00e1 llegando tarde",
"other": "$count trabajadores est\u00e1n llegando tarde"
},
"auto_backup_searching": "El sistema de respaldo autom\u00e1tico est\u00e1 buscando reemplazos."
} }
}, },
"client_reports_common": { "client_reports_common": {

View File

@@ -11,6 +11,7 @@ import '../blocs/coverage_state.dart';
import '../widgets/coverage_calendar_selector.dart'; import '../widgets/coverage_calendar_selector.dart';
import '../widgets/coverage_quick_stats.dart'; import '../widgets/coverage_quick_stats.dart';
import '../widgets/coverage_shift_list.dart'; import '../widgets/coverage_shift_list.dart';
import '../widgets/coverage_stats_header.dart';
import '../widgets/late_workers_alert.dart'; import '../widgets/late_workers_alert.dart';
/// Page for displaying daily coverage information. /// Page for displaying daily coverage information.
@@ -83,7 +84,7 @@ class _CoveragePageState extends State<CoveragePage> {
child: Text( child: Text(
_isScrolled _isScrolled
? DateFormat('MMMM d').format(selectedDate) ? DateFormat('MMMM d').format(selectedDate)
: 'Daily Coverage', : context.t.client_coverage.page.daily_coverage,
key: ValueKey<bool>(_isScrolled), key: ValueKey<bool>(_isScrolled),
style: UiTypography.title2m.copyWith( style: UiTypography.title2m.copyWith(
color: UiColors.primaryForeground, color: UiColors.primaryForeground,
@@ -100,7 +101,7 @@ class _CoveragePageState extends State<CoveragePage> {
icon: Container( icon: Container(
padding: const EdgeInsets.all(UiConstants.space2), padding: const EdgeInsets.all(UiConstants.space2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UiColors.primaryForeground.withOpacity(0.2), color: UiColors.primaryForeground.withValues(alpha: 0.2),
borderRadius: UiConstants.radiusMd, borderRadius: UiConstants.radiusMd,
), ),
child: const Icon( child: const Icon(
@@ -143,57 +144,13 @@ class _CoveragePageState extends State<CoveragePage> {
}, },
), ),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
// Coverage Stats Container CoverageStatsHeader(
Container( coveragePercent:
padding: const EdgeInsets.all(UiConstants.space4), (state.stats?.coveragePercent ?? 0)
decoration: BoxDecoration( .toDouble(),
color: totalConfirmed:
UiColors.primaryForeground.withOpacity(0.1), state.stats?.totalConfirmed ?? 0,
borderRadius: UiConstants.radiusLg, totalNeeded: state.stats?.totalNeeded ?? 0,
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: <Widget>[
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Text(
'Coverage Status',
style: UiTypography.body2r.copyWith(
color: UiColors.primaryForeground
.withOpacity(0.7),
),
),
Text(
'${state.stats?.coveragePercent ?? 0}%',
style: UiTypography.display1b.copyWith(
color: UiColors.primaryForeground,
),
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
'Workers',
style: UiTypography.body2r.copyWith(
color: UiColors.primaryForeground
.withOpacity(0.7),
),
),
Text(
'${state.stats?.totalConfirmed ?? 0}/${state.stats?.totalNeeded ?? 0}',
style: UiTypography.title2m.copyWith(
color: UiColors.primaryForeground,
),
),
],
),
],
),
), ),
], ],
), ),
@@ -244,13 +201,13 @@ class _CoveragePageState extends State<CoveragePage> {
Text( Text(
state.errorMessage != null state.errorMessage != null
? translateErrorKey(state.errorMessage!) ? translateErrorKey(state.errorMessage!)
: 'An error occurred', : context.t.client_coverage.page.error_occurred,
style: UiTypography.body1m.textError, style: UiTypography.body1m.textError,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
UiButton.secondary( UiButton.secondary(
text: 'Retry', text: context.t.client_coverage.page.retry,
onPressed: () => BlocProvider.of<CoverageBloc>(context).add( onPressed: () => BlocProvider.of<CoverageBloc>(context).add(
const CoverageRefreshRequested(), const CoverageRefreshRequested(),
), ),
@@ -280,7 +237,7 @@ class _CoveragePageState extends State<CoveragePage> {
], ],
), ),
Text( Text(
'Shifts (${state.shifts.length})', '${context.t.client_coverage.page.shifts} (${state.shifts.length})',
style: UiTypography.title2b.copyWith( style: UiTypography.title2b.copyWith(
color: UiColors.textPrimary, color: UiColors.textPrimary,
), ),

View File

@@ -0,0 +1,41 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Navigation button used in the calendar selector for week navigation.
class CalendarNavButton extends StatelessWidget {
/// Creates a [CalendarNavButton].
const CalendarNavButton({
required this.text,
required this.onTap,
super.key,
});
/// The button label text.
final String text;
/// Callback when the button is tapped.
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space3,
vertical: UiConstants.space1,
),
decoration: BoxDecoration(
color: UiColors.primaryForeground.withValues(alpha: 0.2),
borderRadius: UiConstants.radiusMd,
),
child: Text(
text,
style: UiTypography.body3r.copyWith(
color: UiColors.primaryForeground,
),
),
),
);
}
}

View File

@@ -0,0 +1,59 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Badge showing worker count ratio with color-coded coverage status.
///
/// Green for 100%+, yellow for 80%+, red below 80%.
class CoverageBadge extends StatelessWidget {
/// Creates a [CoverageBadge].
const CoverageBadge({
required this.current,
required this.total,
required this.coveragePercent,
super.key,
});
/// Current number of assigned workers.
final int current;
/// Total workers needed.
final int total;
/// Coverage percentage used to determine badge color.
final int coveragePercent;
@override
Widget build(BuildContext context) {
Color bg;
Color text;
if (coveragePercent >= 100) {
bg = UiColors.textSuccess.withAlpha(40);
text = UiColors.textSuccess;
} else if (coveragePercent >= 80) {
bg = UiColors.textWarning.withAlpha(40);
text = UiColors.textWarning;
} else {
bg = UiColors.destructive.withAlpha(40);
text = UiColors.destructive;
}
return Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2 + UiConstants.space1,
vertical: UiConstants.space1 / 2,
),
decoration: BoxDecoration(
color: bg,
border: Border.all(color: text, width: 0.75),
borderRadius: UiConstants.radiusMd,
),
child: Text(
'$current/$total',
style: UiTypography.body3b.copyWith(
color: text,
),
),
);
}
}

View File

@@ -1,7 +1,10 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'calendar_nav_button.dart';
/// Calendar selector widget for choosing dates. /// Calendar selector widget for choosing dates.
/// ///
/// Displays a week view with navigation buttons and date selection. /// Displays a week view with navigation buttons and date selection.
@@ -74,16 +77,16 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[ children: <Widget>[
_NavButton( CalendarNavButton(
text: '← Prev Week', text: context.t.client_coverage.calendar.prev_week,
onTap: _navigatePrevWeek, onTap: _navigatePrevWeek,
), ),
_NavButton( CalendarNavButton(
text: 'Today', text: context.t.client_coverage.calendar.today,
onTap: _navigateToday, onTap: _navigateToday,
), ),
_NavButton( CalendarNavButton(
text: 'Next Week', text: context.t.client_coverage.calendar.next_week,
onTap: _navigateNextWeek, onTap: _navigateNextWeek,
), ),
], ],
@@ -145,41 +148,3 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
); );
} }
} }
/// Navigation button for calendar navigation.
class _NavButton extends StatelessWidget {
/// Creates a [_NavButton].
const _NavButton({
required this.text,
required this.onTap,
});
/// The button text.
final String text;
/// Callback when tapped.
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space3,
vertical: UiConstants.space1,
),
decoration: BoxDecoration(
color: UiColors.primaryForeground.withOpacity(0.2),
borderRadius: UiConstants.radiusMd,
),
child: Text(
text,
style: UiTypography.body3r.copyWith(
color: UiColors.primaryForeground,
),
),
),
);
}
}

View File

@@ -1,177 +0,0 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'coverage_calendar_selector.dart';
/// Header widget for the coverage page.
///
/// Displays:
/// - Back button and title
/// - Refresh button
/// - Calendar date selector
/// - Coverage summary statistics
class CoverageHeader extends StatelessWidget {
/// Creates a [CoverageHeader].
const CoverageHeader({
required this.selectedDate,
required this.coveragePercent,
required this.totalConfirmed,
required this.totalNeeded,
required this.onDateSelected,
required this.onRefresh,
super.key,
});
/// The currently selected date.
final DateTime selectedDate;
/// The coverage percentage.
final int coveragePercent;
/// The total number of confirmed workers.
final int totalConfirmed;
/// The total number of workers needed.
final int totalNeeded;
/// Callback when a date is selected.
final ValueChanged<DateTime> onDateSelected;
/// Callback when refresh is requested.
final VoidCallback onRefresh;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.only(
top: UiConstants.space14,
left: UiConstants.space5,
right: UiConstants.space5,
bottom: UiConstants.space6,
),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: <Color>[
UiColors.primary,
UiColors.primary,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Column(
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
children: <Widget>[
GestureDetector(
onTap: () => Modular.to.toClientHome(),
child: Container(
width: UiConstants.space10,
height: UiConstants.space10,
decoration: BoxDecoration(
color: UiColors.primaryForeground.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: const Icon(
UiIcons.arrowLeft,
color: UiColors.primaryForeground,
size: UiConstants.space5,
),
),
),
const SizedBox(width: UiConstants.space3),
Text(
'Daily Coverage',
style: UiTypography.title1m.copyWith(
color: UiColors.primaryForeground,
),
),
],
),
Container(
width: UiConstants.space8,
height: UiConstants.space8,
decoration: BoxDecoration(
color: UiColors.transparent,
borderRadius: UiConstants.radiusMd,
),
child: IconButton(
onPressed: onRefresh,
icon: const Icon(
UiIcons.rotateCcw,
color: UiColors.primaryForeground,
size: UiConstants.space4,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
style: IconButton.styleFrom(
hoverColor: UiColors.primaryForeground.withValues(alpha: 0.2),
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusMd,
),
),
),
),
],
),
const SizedBox(height: UiConstants.space4),
CoverageCalendarSelector(
selectedDate: selectedDate,
onDateSelected: onDateSelected,
),
const SizedBox(height: UiConstants.space4),
Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.primaryForeground.withValues(alpha: 0.1),
borderRadius: UiConstants.radiusLg,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Coverage Status',
style: UiTypography.body2r.copyWith(
color: UiColors.primaryForeground.withValues(alpha: 0.7),
),
),
Text(
'$coveragePercent%',
style: UiTypography.display1b.copyWith(
color: UiColors.primaryForeground,
),
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
'Workers',
style: UiTypography.body2r.copyWith(
color: UiColors.primaryForeground.withValues(alpha: 0.7),
),
),
Text(
'$totalConfirmed/$totalNeeded',
style: UiTypography.title2m.copyWith(
color: UiColors.primaryForeground,
),
),
],
),
],
),
),
],
),
);
}
}

View File

@@ -1,10 +1,13 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import 'coverage_stat_card.dart';
/// Quick statistics cards showing coverage metrics. /// Quick statistics cards showing coverage metrics.
/// ///
/// Displays checked-in, en-route, and late worker counts. /// Displays checked-in and en-route worker counts.
class CoverageQuickStats extends StatelessWidget { class CoverageQuickStats extends StatelessWidget {
/// Creates a [CoverageQuickStats]. /// Creates a [CoverageQuickStats].
const CoverageQuickStats({ const CoverageQuickStats({
@@ -21,17 +24,17 @@ class CoverageQuickStats extends StatelessWidget {
spacing: UiConstants.space2, spacing: UiConstants.space2,
children: <Widget>[ children: <Widget>[
Expanded( Expanded(
child: _StatCard( child: CoverageStatCard(
icon: UiIcons.success, icon: UiIcons.success,
label: 'Checked In', label: context.t.client_coverage.stats.checked_in,
value: stats.checkedIn.toString(), value: stats.checkedIn.toString(),
color: UiColors.iconSuccess, color: UiColors.iconSuccess,
), ),
), ),
Expanded( Expanded(
child: _StatCard( child: CoverageStatCard(
icon: UiIcons.clock, icon: UiIcons.clock,
label: 'En Route', label: context.t.client_coverage.stats.en_route,
value: stats.enRoute.toString(), value: stats.enRoute.toString(),
color: UiColors.textWarning, color: UiColors.textWarning,
), ),
@@ -40,64 +43,3 @@ class CoverageQuickStats extends StatelessWidget {
); );
} }
} }
/// Individual stat card widget.
class _StatCard extends StatelessWidget {
/// Creates a [_StatCard].
const _StatCard({
required this.icon,
required this.label,
required this.value,
required this.color,
});
/// The icon to display.
final IconData icon;
/// The label text.
final String label;
/// The value to display.
final String value;
/// The accent color for the card.
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: color.withAlpha(10),
borderRadius: UiConstants.radiusLg,
border: Border.all(
color: color,
width: 0.5,
),
),
child: Row(
spacing: UiConstants.space2,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(
icon,
color: color,
size: UiConstants.space6,
),
Text(
value,
style: UiTypography.title1b.copyWith(
color: color,
),
),
Text(
label,
style: UiTypography.body3r.copyWith(
color: color,
),
),
],
),
);
}
}

View File

@@ -4,6 +4,9 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import 'shift_header.dart';
import 'worker_row.dart';
/// List of shifts with their workers. /// List of shifts with their workers.
/// ///
/// Displays all shifts for the selected date, or an empty state if none exist. /// Displays all shifts for the selected date, or an empty state if none exist.
@@ -33,6 +36,8 @@ class CoverageShiftList extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsClientCoverageEn l10n = context.t.client_coverage;
if (shifts.isEmpty) { if (shifts.isEmpty) {
return Container( return Container(
padding: const EdgeInsets.all(UiConstants.space8), padding: const EdgeInsets.all(UiConstants.space8),
@@ -51,7 +56,7 @@ class CoverageShiftList extends StatelessWidget {
color: UiColors.textSecondary, color: UiColors.textSecondary,
), ),
Text( Text(
'No shifts scheduled for this day', l10n.no_shifts_day,
style: UiTypography.body2r.textSecondary, style: UiTypography.body2r.textSecondary,
), ),
], ],
@@ -71,7 +76,7 @@ class CoverageShiftList extends StatelessWidget {
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: Column( child: Column(
children: <Widget>[ children: <Widget>[
_ShiftHeader( ShiftHeader(
title: shift.title, title: shift.title,
location: shift.location, location: shift.location,
startTime: _formatTime(shift.startTime), startTime: _formatTime(shift.startTime),
@@ -91,7 +96,7 @@ class CoverageShiftList extends StatelessWidget {
padding: EdgeInsets.only( padding: EdgeInsets.only(
bottom: isLast ? 0 : UiConstants.space2, bottom: isLast ? 0 : UiConstants.space2,
), ),
child: _WorkerRow( child: WorkerRow(
worker: worker, worker: worker,
shiftStartTime: _formatTime(shift.startTime), shiftStartTime: _formatTime(shift.startTime),
formatTime: _formatTime, formatTime: _formatTime,
@@ -104,7 +109,7 @@ class CoverageShiftList extends StatelessWidget {
Padding( Padding(
padding: const EdgeInsets.all(UiConstants.space4), padding: const EdgeInsets.all(UiConstants.space4),
child: Text( child: Text(
'No workers assigned yet', l10n.no_workers_assigned,
style: UiTypography.body3r.copyWith( style: UiTypography.body3r.copyWith(
color: UiColors.mutedForeground, color: UiColors.mutedForeground,
), ),
@@ -117,414 +122,3 @@ class CoverageShiftList extends StatelessWidget {
); );
} }
} }
/// Header for a shift card.
class _ShiftHeader extends StatelessWidget {
/// Creates a [_ShiftHeader].
const _ShiftHeader({
required this.title,
required this.location,
required this.startTime,
required this.current,
required this.total,
required this.coveragePercent,
required this.shiftId,
});
/// The shift title.
final String title;
/// The shift location.
final String location;
/// The shift start time.
final String startTime;
/// Current number of workers.
final int current;
/// Total workers needed.
final int total;
/// Coverage percentage.
final int coveragePercent;
/// The shift ID.
final String shiftId;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: const BoxDecoration(
color: UiColors.muted,
border: Border(
bottom: BorderSide(
color: UiColors.border,
),
),
),
child: Row(
spacing: UiConstants.space4,
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: UiConstants.space2,
children: <Widget>[
Row(
spacing: UiConstants.space2,
children: <Widget>[
Container(
width: UiConstants.space2,
height: UiConstants.space2,
decoration: const BoxDecoration(
color: UiColors.primary,
shape: BoxShape.circle,
),
),
Text(
title,
style: UiTypography.body1b.textPrimary,
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
spacing: UiConstants.space1,
children: <Widget>[
const Icon(
UiIcons.mapPin,
size: UiConstants.space3,
color: UiColors.iconSecondary,
),
Expanded(
child: Text(
location,
style: UiTypography.body3r.textSecondary,
overflow: TextOverflow.ellipsis,
)),
],
),
Row(
spacing: UiConstants.space1,
children: <Widget>[
const Icon(
UiIcons.clock,
size: UiConstants.space3,
color: UiColors.iconSecondary,
),
Text(
startTime,
style: UiTypography.body3r.textSecondary,
),
],
),
],
),
],
),
),
_CoverageBadge(
current: current,
total: total,
coveragePercent: coveragePercent,
),
],
),
);
}
}
/// Coverage badge showing worker count and status.
class _CoverageBadge extends StatelessWidget {
/// Creates a [_CoverageBadge].
const _CoverageBadge({
required this.current,
required this.total,
required this.coveragePercent,
});
/// Current number of workers.
final int current;
/// Total workers needed.
final int total;
/// Coverage percentage.
final int coveragePercent;
@override
Widget build(BuildContext context) {
Color bg;
Color text;
if (coveragePercent >= 100) {
bg = UiColors.textSuccess.withAlpha(40);
text = UiColors.textSuccess;
} else if (coveragePercent >= 80) {
bg = UiColors.textWarning.withAlpha(40);
text = UiColors.textWarning;
} else {
bg = UiColors.destructive.withAlpha(40);
text = UiColors.destructive;
}
return Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2 + UiConstants.space1,
vertical: UiConstants.space1 / 2,
),
decoration: BoxDecoration(
color: bg,
border: Border.all(color: text, width: 0.75),
borderRadius: UiConstants.radiusMd,
),
child: Text(
'$current/$total',
style: UiTypography.body3b.copyWith(
color: text,
),
),
);
}
}
/// Row displaying a single worker's status.
class _WorkerRow extends StatelessWidget {
/// Creates a [_WorkerRow].
const _WorkerRow({
required this.worker,
required this.shiftStartTime,
required this.formatTime,
});
/// The worker to display.
final CoverageWorker worker;
/// The shift start time.
final String shiftStartTime;
/// Function to format time strings.
final String Function(String?) formatTime;
@override
Widget build(BuildContext context) {
Color bg;
Color border;
Color textBg;
Color textColor;
IconData icon;
String statusText;
Color badgeBg;
Color badgeText;
Color badgeBorder;
String badgeLabel;
switch (worker.status) {
case CoverageWorkerStatus.checkedIn:
bg = UiColors.textSuccess.withAlpha(26);
border = UiColors.textSuccess;
textBg = UiColors.textSuccess.withAlpha(51);
textColor = UiColors.textSuccess;
icon = UiIcons.success;
statusText = '✓ Checked In at ${formatTime(worker.checkInTime)}';
badgeBg = UiColors.textSuccess.withAlpha(40);
badgeText = UiColors.textSuccess;
badgeBorder = badgeText;
badgeLabel = 'On Site';
case CoverageWorkerStatus.confirmed:
if (worker.checkInTime == null) {
bg = UiColors.textWarning.withAlpha(26);
border = UiColors.textWarning;
textBg = UiColors.textWarning.withAlpha(51);
textColor = UiColors.textWarning;
icon = UiIcons.clock;
statusText = 'En Route - Expected $shiftStartTime';
badgeBg = UiColors.textWarning.withAlpha(40);
badgeText = UiColors.textWarning;
badgeBorder = badgeText;
badgeLabel = 'En Route';
} else {
bg = UiColors.muted.withAlpha(26);
border = UiColors.border;
textBg = UiColors.muted.withAlpha(51);
textColor = UiColors.textSecondary;
icon = UiIcons.success;
statusText = 'Confirmed';
badgeBg = UiColors.textSecondary.withAlpha(40);
badgeText = UiColors.textSecondary;
badgeBorder = badgeText;
badgeLabel = 'Confirmed';
}
case CoverageWorkerStatus.late:
bg = UiColors.destructive.withAlpha(26);
border = UiColors.destructive;
textBg = UiColors.destructive.withAlpha(51);
textColor = UiColors.destructive;
icon = UiIcons.warning;
statusText = '⚠ Running Late';
badgeBg = UiColors.destructive.withAlpha(40);
badgeText = UiColors.destructive;
badgeBorder = badgeText;
badgeLabel = 'Late';
case CoverageWorkerStatus.checkedOut:
bg = UiColors.muted.withAlpha(26);
border = UiColors.border;
textBg = UiColors.muted.withAlpha(51);
textColor = UiColors.textSecondary;
icon = UiIcons.success;
statusText = 'Checked Out';
badgeBg = UiColors.textSecondary.withAlpha(40);
badgeText = UiColors.textSecondary;
badgeBorder = badgeText;
badgeLabel = 'Done';
case CoverageWorkerStatus.noShow:
bg = UiColors.destructive.withAlpha(26);
border = UiColors.destructive;
textBg = UiColors.destructive.withAlpha(51);
textColor = UiColors.destructive;
icon = UiIcons.warning;
statusText = 'No Show';
badgeBg = UiColors.destructive.withAlpha(40);
badgeText = UiColors.destructive;
badgeBorder = badgeText;
badgeLabel = 'No Show';
case CoverageWorkerStatus.completed:
bg = UiColors.iconSuccess.withAlpha(26);
border = UiColors.iconSuccess;
textBg = UiColors.iconSuccess.withAlpha(51);
textColor = UiColors.textSuccess;
icon = UiIcons.success;
statusText = 'Completed';
badgeBg = UiColors.textSuccess.withAlpha(40);
badgeText = UiColors.textSuccess;
badgeBorder = badgeText;
badgeLabel = 'Completed';
case CoverageWorkerStatus.pending:
case CoverageWorkerStatus.accepted:
case CoverageWorkerStatus.rejected:
bg = UiColors.muted.withAlpha(26);
border = UiColors.border;
textBg = UiColors.muted.withAlpha(51);
textColor = UiColors.textSecondary;
icon = UiIcons.clock;
statusText = worker.status.name.toUpperCase();
badgeBg = UiColors.textSecondary.withAlpha(40);
badgeText = UiColors.textSecondary;
badgeBorder = badgeText;
badgeLabel = worker.status.name[0].toUpperCase() +
worker.status.name.substring(1);
}
return Container(
padding: const EdgeInsets.all(UiConstants.space2),
decoration: BoxDecoration(
color: bg,
borderRadius: UiConstants.radiusMd,
),
child: Row(
children: <Widget>[
Stack(
clipBehavior: Clip.none,
children: <Widget>[
Container(
width: UiConstants.space10,
height: UiConstants.space10,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: border, width: 2),
),
child: CircleAvatar(
backgroundColor: textBg,
child: Text(
worker.name.isNotEmpty ? worker.name[0] : 'W',
style: UiTypography.body1b.copyWith(
color: textColor,
),
),
),
),
Positioned(
bottom: -2,
right: -2,
child: Container(
width: UiConstants.space4,
height: UiConstants.space4,
decoration: BoxDecoration(
color: border,
shape: BoxShape.circle,
),
child: Icon(
icon,
size: UiConstants.space2 + UiConstants.space1,
color: UiColors.primaryForeground,
),
),
),
],
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
worker.name,
style: UiTypography.body2b.copyWith(
color: UiColors.textPrimary,
),
),
Text(
statusText,
style: UiTypography.body3m.copyWith(
color: textColor,
),
),
],
),
),
Column(
spacing: UiConstants.space2,
children: <Widget>[
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: UiConstants.space1 / 2,
),
decoration: BoxDecoration(
color: badgeBg,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: badgeBorder, width: 0.5),
),
child: Text(
badgeLabel,
style: UiTypography.footnote2b.copyWith(
color: badgeText,
),
),
),
// if (worker.status == CoverageWorkerStatus.checkedIn)
// UiButton.primary(
// text: context.t.client_coverage.worker_row.verify,
// size: UiButtonSize.small,
// onPressed: () {
// UiSnackbar.show(
// context,
// message:
// context.t.client_coverage.worker_row.verified_message(
// name: worker.name,
// ),
// type: UiSnackbarType.success,
// );
// },
// ),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,64 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Stat card displaying an icon, value, and label with an accent color.
class CoverageStatCard extends StatelessWidget {
/// Creates a [CoverageStatCard].
const CoverageStatCard({
required this.icon,
required this.label,
required this.value,
required this.color,
super.key,
});
/// The icon to display.
final IconData icon;
/// The label text describing the stat.
final String label;
/// The numeric value to display.
final String value;
/// The accent color for the card border, icon, and text.
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: color.withAlpha(10),
borderRadius: UiConstants.radiusLg,
border: Border.all(
color: color,
width: 0.5,
),
),
child: Row(
spacing: UiConstants.space2,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(
icon,
color: color,
size: UiConstants.space6,
),
Text(
value,
style: UiTypography.title1b.copyWith(
color: color,
),
),
Text(
label,
style: UiTypography.body3r.copyWith(
color: color,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,73 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Displays coverage percentage and worker ratio in the app bar header.
class CoverageStatsHeader extends StatelessWidget {
/// Creates a [CoverageStatsHeader].
const CoverageStatsHeader({
required this.coveragePercent,
required this.totalConfirmed,
required this.totalNeeded,
super.key,
});
/// The current coverage percentage.
final double coveragePercent;
/// The number of confirmed workers.
final int totalConfirmed;
/// The total number of workers needed.
final int totalNeeded;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.primaryForeground.withOpacity(0.1),
borderRadius: UiConstants.radiusLg,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
context.t.client_coverage.page.coverage_status,
style: UiTypography.body2r.copyWith(
color: UiColors.primaryForeground.withOpacity(0.7),
),
),
Text(
'${coveragePercent.toStringAsFixed(0)}%',
style: UiTypography.display1b.copyWith(
color: UiColors.primaryForeground,
),
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
context.t.client_coverage.page.workers,
style: UiTypography.body2r.copyWith(
color: UiColors.primaryForeground.withOpacity(0.7),
),
),
Text(
'$totalConfirmed/$totalNeeded',
style: UiTypography.title2m.copyWith(
color: UiColors.primaryForeground,
),
),
],
),
],
),
);
}
}

View File

@@ -1,9 +1,10 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Alert widget for displaying late workers warning. /// Alert widget for displaying late workers warning.
/// ///
/// Shows a warning banner when there are late workers. /// Shows a warning banner when workers are running late.
class LateWorkersAlert extends StatelessWidget { class LateWorkersAlert extends StatelessWidget {
/// Creates a [LateWorkersAlert]. /// Creates a [LateWorkersAlert].
const LateWorkersAlert({ const LateWorkersAlert({
@@ -38,11 +39,12 @@ class LateWorkersAlert extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
'$lateCount ${lateCount == 1 ? 'worker is' : 'workers are'} running late', context.t.client_coverage.alert
.workers_running_late(n: lateCount, count: lateCount),
style: UiTypography.body1b.textError, style: UiTypography.body1b.textError,
), ),
Text( Text(
'Auto-backup system system is searching for replacements.', context.t.client_coverage.alert.auto_backup_searching,
style: UiTypography.body3r.copyWith( style: UiTypography.body3r.copyWith(
color: UiColors.textError.withValues(alpha: 0.7), color: UiColors.textError.withValues(alpha: 0.7),
), ),

View File

@@ -0,0 +1,125 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'coverage_badge.dart';
/// Header section for a shift card showing title, location, time, and coverage.
class ShiftHeader extends StatelessWidget {
/// Creates a [ShiftHeader].
const ShiftHeader({
required this.title,
required this.location,
required this.startTime,
required this.current,
required this.total,
required this.coveragePercent,
required this.shiftId,
super.key,
});
/// The shift title.
final String title;
/// The shift location.
final String location;
/// The formatted shift start time.
final String startTime;
/// Current number of assigned workers.
final int current;
/// Total workers needed for the shift.
final int total;
/// Coverage percentage (0-100+).
final int coveragePercent;
/// The shift identifier.
final String shiftId;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: const BoxDecoration(
color: UiColors.muted,
border: Border(
bottom: BorderSide(
color: UiColors.border,
),
),
),
child: Row(
spacing: UiConstants.space4,
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: UiConstants.space2,
children: <Widget>[
Row(
spacing: UiConstants.space2,
children: <Widget>[
Container(
width: UiConstants.space2,
height: UiConstants.space2,
decoration: const BoxDecoration(
color: UiColors.primary,
shape: BoxShape.circle,
),
),
Text(
title,
style: UiTypography.body1b.textPrimary,
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
spacing: UiConstants.space1,
children: <Widget>[
const Icon(
UiIcons.mapPin,
size: UiConstants.space3,
color: UiColors.iconSecondary,
),
Expanded(
child: Text(
location,
style: UiTypography.body3r.textSecondary,
overflow: TextOverflow.ellipsis,
)),
],
),
Row(
spacing: UiConstants.space1,
children: <Widget>[
const Icon(
UiIcons.clock,
size: UiConstants.space3,
color: UiColors.iconSecondary,
),
Text(
startTime,
style: UiTypography.body3r.textSecondary,
),
],
),
],
),
],
),
),
CoverageBadge(
current: current,
total: total,
coveragePercent: coveragePercent,
),
],
),
);
}
}

View File

@@ -0,0 +1,231 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
/// Row displaying a single worker's avatar, name, status, and badge.
class WorkerRow extends StatelessWidget {
/// Creates a [WorkerRow].
const WorkerRow({
required this.worker,
required this.shiftStartTime,
required this.formatTime,
super.key,
});
/// The worker data to display.
final CoverageWorker worker;
/// The formatted shift start time.
final String shiftStartTime;
/// Callback to format a raw time string into a readable format.
final String Function(String?) formatTime;
@override
Widget build(BuildContext context) {
final TranslationsClientCoverageEn l10n = context.t.client_coverage;
Color bg;
Color border;
Color textBg;
Color textColor;
IconData icon;
String statusText;
Color badgeBg;
Color badgeText;
Color badgeBorder;
String badgeLabel;
switch (worker.status) {
case CoverageWorkerStatus.checkedIn:
bg = UiColors.textSuccess.withAlpha(26);
border = UiColors.textSuccess;
textBg = UiColors.textSuccess.withAlpha(51);
textColor = UiColors.textSuccess;
icon = UiIcons.success;
statusText = l10n.status_checked_in_at(
time: formatTime(worker.checkInTime),
);
badgeBg = UiColors.textSuccess.withAlpha(40);
badgeText = UiColors.textSuccess;
badgeBorder = badgeText;
badgeLabel = l10n.status_on_site;
case CoverageWorkerStatus.confirmed:
if (worker.checkInTime == null) {
bg = UiColors.textWarning.withAlpha(26);
border = UiColors.textWarning;
textBg = UiColors.textWarning.withAlpha(51);
textColor = UiColors.textWarning;
icon = UiIcons.clock;
statusText = l10n.status_en_route_expected(time: shiftStartTime);
badgeBg = UiColors.textWarning.withAlpha(40);
badgeText = UiColors.textWarning;
badgeBorder = badgeText;
badgeLabel = l10n.status_en_route;
} else {
bg = UiColors.muted.withAlpha(26);
border = UiColors.border;
textBg = UiColors.muted.withAlpha(51);
textColor = UiColors.textSecondary;
icon = UiIcons.success;
statusText = l10n.status_confirmed;
badgeBg = UiColors.textSecondary.withAlpha(40);
badgeText = UiColors.textSecondary;
badgeBorder = badgeText;
badgeLabel = l10n.status_confirmed;
}
case CoverageWorkerStatus.late:
bg = UiColors.destructive.withAlpha(26);
border = UiColors.destructive;
textBg = UiColors.destructive.withAlpha(51);
textColor = UiColors.destructive;
icon = UiIcons.warning;
statusText = l10n.status_running_late;
badgeBg = UiColors.destructive.withAlpha(40);
badgeText = UiColors.destructive;
badgeBorder = badgeText;
badgeLabel = l10n.status_late;
case CoverageWorkerStatus.checkedOut:
bg = UiColors.muted.withAlpha(26);
border = UiColors.border;
textBg = UiColors.muted.withAlpha(51);
textColor = UiColors.textSecondary;
icon = UiIcons.success;
statusText = l10n.status_checked_out;
badgeBg = UiColors.textSecondary.withAlpha(40);
badgeText = UiColors.textSecondary;
badgeBorder = badgeText;
badgeLabel = l10n.status_done;
case CoverageWorkerStatus.noShow:
bg = UiColors.destructive.withAlpha(26);
border = UiColors.destructive;
textBg = UiColors.destructive.withAlpha(51);
textColor = UiColors.destructive;
icon = UiIcons.warning;
statusText = l10n.status_no_show;
badgeBg = UiColors.destructive.withAlpha(40);
badgeText = UiColors.destructive;
badgeBorder = badgeText;
badgeLabel = l10n.status_no_show;
case CoverageWorkerStatus.completed:
bg = UiColors.iconSuccess.withAlpha(26);
border = UiColors.iconSuccess;
textBg = UiColors.iconSuccess.withAlpha(51);
textColor = UiColors.textSuccess;
icon = UiIcons.success;
statusText = l10n.status_completed;
badgeBg = UiColors.textSuccess.withAlpha(40);
badgeText = UiColors.textSuccess;
badgeBorder = badgeText;
badgeLabel = l10n.status_completed;
case CoverageWorkerStatus.pending:
case CoverageWorkerStatus.accepted:
case CoverageWorkerStatus.rejected:
bg = UiColors.muted.withAlpha(26);
border = UiColors.border;
textBg = UiColors.muted.withAlpha(51);
textColor = UiColors.textSecondary;
icon = UiIcons.clock;
statusText = worker.status.name.toUpperCase();
badgeBg = UiColors.textSecondary.withAlpha(40);
badgeText = UiColors.textSecondary;
badgeBorder = badgeText;
badgeLabel = worker.status.name[0].toUpperCase() +
worker.status.name.substring(1);
}
return Container(
padding: const EdgeInsets.all(UiConstants.space2),
decoration: BoxDecoration(
color: bg,
borderRadius: UiConstants.radiusMd,
),
child: Row(
children: <Widget>[
Stack(
clipBehavior: Clip.none,
children: <Widget>[
Container(
width: UiConstants.space10,
height: UiConstants.space10,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: border, width: 2),
),
child: CircleAvatar(
backgroundColor: textBg,
child: Text(
worker.name.isNotEmpty ? worker.name[0] : 'W',
style: UiTypography.body1b.copyWith(
color: textColor,
),
),
),
),
Positioned(
bottom: -2,
right: -2,
child: Container(
width: UiConstants.space4,
height: UiConstants.space4,
decoration: BoxDecoration(
color: border,
shape: BoxShape.circle,
),
child: Icon(
icon,
size: UiConstants.space2 + UiConstants.space1,
color: UiColors.primaryForeground,
),
),
),
],
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
worker.name,
style: UiTypography.body2b.copyWith(
color: UiColors.textPrimary,
),
),
Text(
statusText,
style: UiTypography.body3m.copyWith(
color: textColor,
),
),
],
),
),
Column(
spacing: UiConstants.space2,
children: <Widget>[
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: UiConstants.space1 / 2,
),
decoration: BoxDecoration(
color: badgeBg,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: badgeBorder, width: 0.5),
),
child: Text(
badgeLabel,
style: UiTypography.footnote2b.copyWith(
color: badgeText,
),
),
),
],
),
],
),
);
}
}