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

@@ -11,6 +11,7 @@ import '../blocs/coverage_state.dart';
import '../widgets/coverage_calendar_selector.dart';
import '../widgets/coverage_quick_stats.dart';
import '../widgets/coverage_shift_list.dart';
import '../widgets/coverage_stats_header.dart';
import '../widgets/late_workers_alert.dart';
/// Page for displaying daily coverage information.
@@ -83,7 +84,7 @@ class _CoveragePageState extends State<CoveragePage> {
child: Text(
_isScrolled
? DateFormat('MMMM d').format(selectedDate)
: 'Daily Coverage',
: context.t.client_coverage.page.daily_coverage,
key: ValueKey<bool>(_isScrolled),
style: UiTypography.title2m.copyWith(
color: UiColors.primaryForeground,
@@ -100,7 +101,7 @@ class _CoveragePageState extends State<CoveragePage> {
icon: Container(
padding: const EdgeInsets.all(UiConstants.space2),
decoration: BoxDecoration(
color: UiColors.primaryForeground.withOpacity(0.2),
color: UiColors.primaryForeground.withValues(alpha: 0.2),
borderRadius: UiConstants.radiusMd,
),
child: const Icon(
@@ -143,57 +144,13 @@ class _CoveragePageState extends State<CoveragePage> {
},
),
const SizedBox(height: UiConstants.space4),
// Coverage Stats Container
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(
'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,
),
),
],
),
],
),
CoverageStatsHeader(
coveragePercent:
(state.stats?.coveragePercent ?? 0)
.toDouble(),
totalConfirmed:
state.stats?.totalConfirmed ?? 0,
totalNeeded: state.stats?.totalNeeded ?? 0,
),
],
),
@@ -244,13 +201,13 @@ class _CoveragePageState extends State<CoveragePage> {
Text(
state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: 'An error occurred',
: context.t.client_coverage.page.error_occurred,
style: UiTypography.body1m.textError,
textAlign: TextAlign.center,
),
const SizedBox(height: UiConstants.space4),
UiButton.secondary(
text: 'Retry',
text: context.t.client_coverage.page.retry,
onPressed: () => BlocProvider.of<CoverageBloc>(context).add(
const CoverageRefreshRequested(),
),
@@ -280,7 +237,7 @@ class _CoveragePageState extends State<CoveragePage> {
],
),
Text(
'Shifts (${state.shifts.length})',
'${context.t.client_coverage.page.shifts} (${state.shifts.length})',
style: UiTypography.title2b.copyWith(
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:flutter/material.dart';
import 'package:intl/intl.dart';
import 'calendar_nav_button.dart';
/// Calendar selector widget for choosing dates.
///
/// Displays a week view with navigation buttons and date selection.
@@ -74,16 +77,16 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
_NavButton(
text: '← Prev Week',
CalendarNavButton(
text: context.t.client_coverage.calendar.prev_week,
onTap: _navigatePrevWeek,
),
_NavButton(
text: 'Today',
CalendarNavButton(
text: context.t.client_coverage.calendar.today,
onTap: _navigateToday,
),
_NavButton(
text: 'Next Week',
CalendarNavButton(
text: context.t.client_coverage.calendar.next_week,
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:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
import 'coverage_stat_card.dart';
/// 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 {
/// Creates a [CoverageQuickStats].
const CoverageQuickStats({
@@ -21,17 +24,17 @@ class CoverageQuickStats extends StatelessWidget {
spacing: UiConstants.space2,
children: <Widget>[
Expanded(
child: _StatCard(
child: CoverageStatCard(
icon: UiIcons.success,
label: 'Checked In',
label: context.t.client_coverage.stats.checked_in,
value: stats.checkedIn.toString(),
color: UiColors.iconSuccess,
),
),
Expanded(
child: _StatCard(
child: CoverageStatCard(
icon: UiIcons.clock,
label: 'En Route',
label: context.t.client_coverage.stats.en_route,
value: stats.enRoute.toString(),
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:krow_domain/krow_domain.dart';
import 'shift_header.dart';
import 'worker_row.dart';
/// List of shifts with their workers.
///
/// Displays all shifts for the selected date, or an empty state if none exist.
@@ -33,6 +36,8 @@ class CoverageShiftList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final TranslationsClientCoverageEn l10n = context.t.client_coverage;
if (shifts.isEmpty) {
return Container(
padding: const EdgeInsets.all(UiConstants.space8),
@@ -51,7 +56,7 @@ class CoverageShiftList extends StatelessWidget {
color: UiColors.textSecondary,
),
Text(
'No shifts scheduled for this day',
l10n.no_shifts_day,
style: UiTypography.body2r.textSecondary,
),
],
@@ -71,7 +76,7 @@ class CoverageShiftList extends StatelessWidget {
clipBehavior: Clip.antiAlias,
child: Column(
children: <Widget>[
_ShiftHeader(
ShiftHeader(
title: shift.title,
location: shift.location,
startTime: _formatTime(shift.startTime),
@@ -91,7 +96,7 @@ class CoverageShiftList extends StatelessWidget {
padding: EdgeInsets.only(
bottom: isLast ? 0 : UiConstants.space2,
),
child: _WorkerRow(
child: WorkerRow(
worker: worker,
shiftStartTime: _formatTime(shift.startTime),
formatTime: _formatTime,
@@ -104,7 +109,7 @@ class CoverageShiftList extends StatelessWidget {
Padding(
padding: const EdgeInsets.all(UiConstants.space4),
child: Text(
'No workers assigned yet',
l10n.no_workers_assigned,
style: UiTypography.body3r.copyWith(
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:flutter/material.dart';
/// 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 {
/// Creates a [LateWorkersAlert].
const LateWorkersAlert({
@@ -38,11 +39,12 @@ class LateWorkersAlert extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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,
),
Text(
'Auto-backup system system is searching for replacements.',
context.t.client_coverage.alert.auto_backup_searching,
style: UiTypography.body3r.copyWith(
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,
),
),
),
],
),
],
),
);
}
}