Refactor coverage-related widgets and improve UI consistency

- Updated CancelLateWorkerSheet to enhance layout and information display.
- Modified CoverageCalendarSelector to adjust color opacity for better visibility.
- Simplified CoveragePageSkeleton by removing unused quick stats row.
- Removed CoverageQuickStats widget as it was redundant.
- Enhanced CoverageShiftList to manage expanded state and improve worker display.
- Deleted CoverageStatCard as its functionality is no longer needed.
- Revamped CoverageStatsHeader to provide a clearer overview of coverage statistics.
- Improved LateWorkersAlert for better visual feedback on late workers.
- Redesigned ShiftHeader for a more interactive and informative shift card.
- Updated WorkerRow to streamline status display and action buttons.
This commit is contained in:
Achintha Isuru
2026-03-19 00:12:35 -04:00
parent b317c53e7e
commit 8bb336d1b5
14 changed files with 617 additions and 540 deletions

View File

@@ -1793,7 +1793,9 @@
"workers": "Workers",
"error_occurred": "An error occurred",
"retry": "Retry",
"shifts": "Shifts"
"shifts": "Shifts",
"overall_coverage": "Overall Coverage",
"live_activity": "LIVE ACTIVITY"
},
"calendar": {
"prev_week": "\u2190 Prev Week",
@@ -1802,7 +1804,9 @@
},
"stats": {
"checked_in": "Checked In",
"en_route": "En Route"
"en_route": "En Route",
"on_site": "On Site",
"late": "Late"
},
"alert": {
"workers_running_late(count)": {

View File

@@ -1793,7 +1793,9 @@
"workers": "Trabajadores",
"error_occurred": "Ocurri\u00f3 un error",
"retry": "Reintentar",
"shifts": "Turnos"
"shifts": "Turnos",
"overall_coverage": "Cobertura General",
"live_activity": "ACTIVIDAD EN VIVO"
},
"calendar": {
"prev_week": "\u2190 Semana Anterior",
@@ -1802,7 +1804,9 @@
},
"stats": {
"checked_in": "Registrado",
"en_route": "En Camino"
"en_route": "En Camino",
"on_site": "En Sitio",
"late": "Tarde"
},
"alert": {
"workers_running_late(count)": {

View File

@@ -82,6 +82,7 @@ class UiChip extends StatelessWidget {
final Row content = Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
if (leadingIcon != null) ...<Widget>[
Icon(leadingIcon, size: iconSize, color: contentColor),

View File

@@ -10,14 +10,14 @@ import 'package:client_coverage/src/presentation/blocs/coverage_event.dart';
import 'package:client_coverage/src/presentation/blocs/coverage_state.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_calendar_selector.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_page_skeleton.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_quick_stats.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_shift_list.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_stats_header.dart';
import 'package:client_coverage/src/presentation/widgets/late_workers_alert.dart';
/// Page for displaying daily coverage information.
///
/// Shows shifts, worker statuses, and coverage statistics for a selected date.
/// Shows shifts, worker statuses, and coverage statistics for a selected date
/// using a collapsible SliverAppBar with gradient header and live activity feed.
class CoveragePage extends StatefulWidget {
/// Creates a [CoveragePage].
const CoveragePage({super.key});
@@ -27,14 +27,13 @@ class CoveragePage extends StatefulWidget {
}
class _CoveragePageState extends State<CoveragePage> {
/// Controller for the [CustomScrollView].
late ScrollController _scrollController;
bool _isScrolled = false;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
_scrollController.addListener(_onScroll);
}
@override
@@ -43,16 +42,6 @@ class _CoveragePageState extends State<CoveragePage> {
super.dispose();
}
void _onScroll() {
if (_scrollController.hasClients) {
if (_scrollController.offset > 180 && !_isScrolled) {
setState(() => _isScrolled = true);
} else if (_scrollController.offset <= 180 && _isScrolled) {
setState(() => _isScrolled = false);
}
}
}
@override
Widget build(BuildContext context) {
return BlocProvider<CoverageBloc>(
@@ -93,19 +82,26 @@ class _CoveragePageState extends State<CoveragePage> {
slivers: <Widget>[
SliverAppBar(
pinned: true,
expandedHeight: 300.0,
expandedHeight: 316.0,
backgroundColor: UiColors.primary,
title: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Text(
_isScrolled
? DateFormat('MMMM d').format(selectedDate)
: context.t.client_coverage.page.daily_coverage,
key: ValueKey<bool>(_isScrolled),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
context.t.client_coverage.page.daily_coverage,
style: UiTypography.title2m.copyWith(
color: UiColors.primaryForeground,
),
),
Text(
DateFormat('EEEE, MMMM d').format(selectedDate),
style: UiTypography.body3r.copyWith(
color: UiColors.primaryForeground
.withValues(alpha: 0.6),
),
),
],
),
actions: <Widget>[
IconButton(
@@ -135,7 +131,7 @@ class _CoveragePageState extends State<CoveragePage> {
gradient: LinearGradient(
colors: <Color>[
UiColors.primary,
UiColors.primary,
Color(0xFF0626A8),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
@@ -169,6 +165,12 @@ class _CoveragePageState extends State<CoveragePage> {
state.stats?.totalPositionsConfirmed ?? 0,
totalNeeded:
state.stats?.totalPositionsNeeded ?? 0,
totalCheckedIn:
state.stats?.totalWorkersCheckedIn ?? 0,
totalEnRoute:
state.stats?.totalWorkersEnRoute ?? 0,
totalLate:
state.stats?.totalWorkersLate ?? 0,
),
],
),
@@ -191,7 +193,10 @@ class _CoveragePageState extends State<CoveragePage> {
);
}
/// Builds the main body content based on the current state.
/// Builds the main body content based on the current [CoverageState].
///
/// Displays a skeleton loader, error state, or the live activity feed
/// with late worker alerts and shift list.
Widget _buildBody({
required BuildContext context,
required CoverageState state,
@@ -241,9 +246,6 @@ class _CoveragePageState extends State<CoveragePage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: UiConstants.space6,
children: <Widget>[
Column(
spacing: UiConstants.space2,
children: <Widget>[
if (state.stats != null &&
state.stats!.totalWorkersLate > 0) ...<Widget>[
@@ -251,15 +253,13 @@ class _CoveragePageState extends State<CoveragePage> {
lateCount: state.stats!.totalWorkersLate,
),
],
if (state.stats != null) ...<Widget>[
CoverageQuickStats(stats: state.stats!),
],
],
),
Text(
'${context.t.client_coverage.page.shifts} (${state.shifts.length})',
style: UiTypography.title2b.copyWith(
color: UiColors.textPrimary,
context.t.client_coverage.page.live_activity,
style: UiTypography.body4m.copyWith(
color: UiColors.textSecondary,
letterSpacing: 2.0,
fontWeight: FontWeight.w900,
fontSize: 10,
),
),
CoverageShiftList(shifts: state.shifts),

View File

@@ -91,14 +91,29 @@ class _CancelLateWorkerSheetState extends State<CancelLateWorkerSheet> {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Icon(
Row(
spacing: UiConstants.space3,
children: <Widget>[
const Icon(
UiIcons.warning,
color: UiColors.destructive,
size: 28,
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(l10n.title, style: UiTypography.title1b.textError),
Text(
l10n.subtitle,
style: UiTypography.body2r.textSecondary,
),
],
),
],
),
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Icon(
child: const Icon(
UiIcons.close,
color: UiColors.textSecondary,
size: 24,
@@ -106,21 +121,14 @@ class _CancelLateWorkerSheetState extends State<CancelLateWorkerSheet> {
),
],
),
const SizedBox(height: UiConstants.space2),
Text(l10n.title, style: UiTypography.title1b),
const SizedBox(height: UiConstants.space1),
Text(
l10n.subtitle,
style: UiTypography.body2r.copyWith(color: UiColors.destructive),
),
const SizedBox(height: UiConstants.space4),
// Body
Text(
l10n.confirm_message(name: widget.worker.fullName),
style: UiTypography.body1m,
style: UiTypography.body1r,
),
const SizedBox(height: UiConstants.space2),
const SizedBox(height: UiConstants.space1),
Text(
l10n.helper_text,
style: UiTypography.body2r.textSecondary,
@@ -146,28 +154,19 @@ class _CancelLateWorkerSheetState extends State<CancelLateWorkerSheet> {
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: ElevatedButton(
child: UiButton.primary(
text: l10n.confirm,
onPressed: () => _onConfirm(context),
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.destructive,
foregroundColor: UiColors.primaryForeground,
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space3,
),
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusMd,
),
),
child: Text(
l10n.confirm,
style: UiTypography.body1b.copyWith(
color: UiColors.primaryForeground,
),
),
),
),
],
),
const SizedBox(height: UiConstants.space24),
],
),
);

View File

@@ -110,7 +110,7 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
decoration: BoxDecoration(
color: isSelected
? UiColors.primaryForeground
: UiColors.primaryForeground.withOpacity(0.1),
: UiColors.primaryForeground.withAlpha(25),
borderRadius: UiConstants.radiusLg,
border: isToday && !isSelected
? Border.all(
@@ -122,6 +122,14 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
DateFormat('E').format(date),
style: UiTypography.body4m.copyWith(
color: isSelected
? UiColors.primary
: UiColors.primaryForeground.withAlpha(179),
),
),
Text(
date.day.toString().padLeft(2, '0'),
style: UiTypography.body1b.copyWith(
@@ -130,14 +138,6 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
: UiColors.primaryForeground,
),
),
Text(
DateFormat('E').format(date),
style: UiTypography.body4m.copyWith(
color: isSelected
? UiColors.mutedForeground
: UiColors.primaryForeground.withOpacity(0.7),
),
),
],
),
),

View File

@@ -5,8 +5,8 @@ import 'package:client_coverage/src/presentation/widgets/coverage_page_skeleton/
/// Shimmer loading skeleton that mimics the coverage page loaded layout.
///
/// Shows placeholder shapes for the quick stats row, shift section header,
/// and a list of shift cards with worker rows.
/// Shows placeholder shapes for the live activity section label and a list
/// of shift cards with worker rows.
class CoveragePageSkeleton extends StatelessWidget {
/// Creates a [CoveragePageSkeleton].
const CoveragePageSkeleton({super.key});
@@ -19,18 +19,8 @@ class CoveragePageSkeleton extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// Quick stats row (2 stat cards)
Row(
children: <Widget>[
Expanded(child: UiShimmerStatsCard()),
SizedBox(width: UiConstants.space2),
Expanded(child: UiShimmerStatsCard()),
],
),
SizedBox(height: UiConstants.space6),
// Shifts section header
UiShimmerLine(width: 140, height: 18),
// "LIVE ACTIVITY" section label placeholder
UiShimmerLine(width: 100, height: 10),
SizedBox(height: UiConstants.space6),
// Shift cards with worker rows

View File

@@ -1,45 +0,0 @@
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 'package:client_coverage/src/presentation/widgets/coverage_stat_card.dart';
/// Quick statistics cards showing coverage metrics.
///
/// Displays checked-in and en-route worker counts.
class CoverageQuickStats extends StatelessWidget {
/// Creates a [CoverageQuickStats].
const CoverageQuickStats({
required this.stats,
super.key,
});
/// The coverage statistics to display.
final CoverageStats stats;
@override
Widget build(BuildContext context) {
return Row(
spacing: UiConstants.space2,
children: <Widget>[
Expanded(
child: CoverageStatCard(
icon: UiIcons.success,
label: context.t.client_coverage.stats.checked_in,
value: stats.totalWorkersCheckedIn.toString(),
color: UiColors.iconSuccess,
),
),
Expanded(
child: CoverageStatCard(
icon: UiIcons.clock,
label: context.t.client_coverage.stats.en_route,
value: stats.totalWorkersEnRoute.toString(),
color: UiColors.textWarning,
),
),
],
);
}
}

View File

@@ -9,10 +9,12 @@ import 'package:client_coverage/src/presentation/widgets/shift_header.dart';
import 'package:client_coverage/src/presentation/widgets/worker_row.dart';
import 'package:client_coverage/src/presentation/widgets/worker_review_sheet.dart';
/// List of shifts with their workers.
/// Displays a list of shifts as collapsible cards with worker details.
///
/// Displays all shifts for the selected date, or an empty state if none exist.
class CoverageShiftList extends StatelessWidget {
/// Each shift is rendered as a card with a tappable [ShiftHeader] that toggles
/// visibility of the worker rows beneath it. All cards start expanded.
/// Shows an empty state when [shifts] is empty.
class CoverageShiftList extends StatefulWidget {
/// Creates a [CoverageShiftList].
const CoverageShiftList({
required this.shifts,
@@ -22,17 +24,73 @@ class CoverageShiftList extends StatelessWidget {
/// The list of shifts to display.
final List<ShiftWithWorkers> shifts;
@override
State<CoverageShiftList> createState() => _CoverageShiftListState();
}
/// State for [CoverageShiftList] managing which shift cards are expanded.
class _CoverageShiftListState extends State<CoverageShiftList> {
/// Set of shift IDs whose cards are currently expanded.
final Set<String> _expandedShiftIds = <String>{};
/// Whether the expanded set has been initialised from the first build.
bool _initialised = false;
/// Formats a [DateTime] to a readable time string (h:mm a).
String _formatTime(DateTime? time) {
if (time == null) return '';
return DateFormat('h:mm a').format(time);
}
/// Toggles the expanded / collapsed state for the shift with [shiftId].
void _toggleShift(String shiftId) {
setState(() {
if (_expandedShiftIds.contains(shiftId)) {
_expandedShiftIds.remove(shiftId);
} else {
_expandedShiftIds.add(shiftId);
}
});
}
/// Seeds [_expandedShiftIds] with all current shift IDs on first build,
/// and adds any new shift IDs when the widget is rebuilt with new data.
void _ensureInitialised() {
if (!_initialised) {
_expandedShiftIds.addAll(
widget.shifts.map((ShiftWithWorkers s) => s.shiftId),
);
_initialised = true;
return;
}
// Add any new shift IDs that arrived after initial build.
for (final ShiftWithWorkers shift in widget.shifts) {
if (!_expandedShiftIds.contains(shift.shiftId)) {
_expandedShiftIds.add(shift.shiftId);
}
}
}
@override
void didUpdateWidget(covariant CoverageShiftList oldWidget) {
super.didUpdateWidget(oldWidget);
// Add newly-appeared shift IDs so they start expanded.
for (final ShiftWithWorkers shift in widget.shifts) {
if (!oldWidget.shifts.any(
(ShiftWithWorkers old) => old.shiftId == shift.shiftId,
)) {
_expandedShiftIds.add(shift.shiftId);
}
}
}
@override
Widget build(BuildContext context) {
_ensureInitialised();
final TranslationsClientCoverageEn l10n = context.t.client_coverage;
if (shifts.isEmpty) {
if (widget.shifts.isEmpty) {
return Container(
padding: const EdgeInsets.all(UiConstants.space8),
width: double.infinity,
@@ -59,59 +117,122 @@ class CoverageShiftList extends StatelessWidget {
}
return Column(
children: shifts.map((ShiftWithWorkers shift) {
children: widget.shifts.map((ShiftWithWorkers shift) {
final int coveragePercent = shift.requiredWorkerCount > 0
? ((shift.assignedWorkerCount / shift.requiredWorkerCount) * 100)
.round()
: 0;
// Per-shift worker status counts.
final int onSite = shift.assignedWorkers
.where(
(AssignedWorker w) => w.status == AssignmentStatus.checkedIn,
)
.length;
final int enRoute = shift.assignedWorkers
.where(
(AssignedWorker w) =>
w.status == AssignmentStatus.accepted && w.checkInAt == null,
)
.length;
final int lateCount = shift.assignedWorkers
.where(
(AssignedWorker w) => w.status == AssignmentStatus.noShow,
)
.length;
final bool isExpanded = _expandedShiftIds.contains(shift.shiftId);
return Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.bgPopup,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
borderRadius: UiConstants.radius2xl,
boxShadow: <BoxShadow>[
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
clipBehavior: Clip.antiAlias,
child: Column(
children: <Widget>[
ShiftHeader(
title: shift.roleName,
location: '', // V2 API does not return location on coverage
startTime: _formatTime(shift.timeRange.startsAt),
current: shift.assignedWorkerCount,
total: shift.requiredWorkerCount,
coveragePercent: coveragePercent,
shiftId: shift.shiftId,
onSiteCount: onSite,
enRouteCount: enRoute,
lateCount: lateCount,
isExpanded: isExpanded,
onToggle: () => _toggleShift(shift.shiftId),
),
if (shift.assignedWorkers.isNotEmpty)
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: _buildWorkerSection(shift, l10n),
crossFadeState: isExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
],
),
);
}).toList(),
);
}
/// Builds the expanded worker section for a shift including divider.
Widget _buildWorkerSection(
ShiftWithWorkers shift,
TranslationsClientCoverageEn l10n,
) {
if (shift.assignedWorkers.isEmpty) {
return Column(
children: <Widget>[
const Divider(height: 1, color: UiColors.border),
Padding(
padding: const EdgeInsets.all(UiConstants.space4),
child: Text(
l10n.no_workers_assigned,
style: UiTypography.body3r.copyWith(
color: UiColors.mutedForeground,
),
),
),
],
);
}
return Column(
children: <Widget>[
const Divider(height: 1, color: UiColors.border),
Padding(
padding: const EdgeInsets.all(UiConstants.space3),
child: Column(
children: shift.assignedWorkers
.map<Widget>((AssignedWorker worker) {
final bool isLast =
worker == shift.assignedWorkers.last;
children:
shift.assignedWorkers.map<Widget>((AssignedWorker worker) {
final bool isLast = worker == shift.assignedWorkers.last;
return Padding(
padding: EdgeInsets.only(
bottom: isLast ? 0 : UiConstants.space2,
),
child: WorkerRow(
worker: worker,
shiftStartTime:
_formatTime(shift.timeRange.startsAt),
shiftStartTime: _formatTime(shift.timeRange.startsAt),
showRateButton:
worker.status == AssignmentStatus.checkedIn ||
worker.status ==
AssignmentStatus.checkedOut ||
worker.status ==
AssignmentStatus.completed,
worker.status == AssignmentStatus.checkedOut ||
worker.status == AssignmentStatus.completed,
showCancelButton:
worker.status == AssignmentStatus.noShow ||
worker.status ==
AssignmentStatus.assigned ||
worker.status ==
AssignmentStatus.accepted,
worker.status == AssignmentStatus.assigned ||
worker.status == AssignmentStatus.accepted,
onRate: () => WorkerReviewSheet.show(
context,
worker: worker,
@@ -124,21 +245,8 @@ class CoverageShiftList extends StatelessWidget {
);
}).toList(),
),
)
else
Padding(
padding: const EdgeInsets.all(UiConstants.space4),
child: Text(
l10n.no_workers_assigned,
style: UiTypography.body3r.copyWith(
color: UiColors.mutedForeground,
),
),
),
],
),
);
}).toList(),
);
}
}

View File

@@ -1,64 +0,0 @@
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

@@ -2,43 +2,77 @@ 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.
/// Displays overall coverage statistics in the SliverAppBar expanded header.
///
/// Shows the coverage percentage, a progress bar, and real-time worker
/// status counts (on site, en route, late) on a primary blue gradient
/// background with a semi-transparent white container.
class CoverageStatsHeader extends StatelessWidget {
/// Creates a [CoverageStatsHeader].
/// Creates a [CoverageStatsHeader] with coverage and worker status data.
const CoverageStatsHeader({
required this.coveragePercent,
required this.totalConfirmed,
required this.totalNeeded,
required this.totalCheckedIn,
required this.totalEnRoute,
required this.totalLate,
super.key,
});
/// The current coverage percentage.
/// The current overall coverage percentage (0-100).
final double coveragePercent;
/// The number of confirmed workers.
final int totalConfirmed;
/// The total number of workers needed.
/// The total number of workers needed for full coverage.
final int totalNeeded;
/// The number of workers currently checked in and on site.
final int totalCheckedIn;
/// The number of workers currently en route.
final int totalEnRoute;
/// The number of workers currently marked as late.
final int totalLate;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.primaryForeground.withOpacity(0.1),
borderRadius: UiConstants.radiusLg,
color: UiColors.primaryForeground.withValues(alpha: 0.12),
borderRadius: UiConstants.radiusXl,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Column(
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: _buildCoverageColumn(context),
),
_buildStatusColumn(context),
],
),
const SizedBox(height: UiConstants.space3),
_buildProgressBar(),
],
),
);
}
/// Builds the left column with the "Overall Coverage" label and percentage.
Widget _buildCoverageColumn(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
context.t.client_coverage.page.coverage_status,
style: UiTypography.body2r.copyWith(
color: UiColors.primaryForeground.withOpacity(0.7),
context.t.client_coverage.page.overall_coverage,
style: UiTypography.body3r.copyWith(
color: UiColors.primaryForeground.withValues(alpha: 0.6),
),
),
Text(
@@ -48,25 +82,95 @@ class CoverageStatsHeader extends StatelessWidget {
),
),
],
),
Column(
);
}
/// Builds the right column with on-site, en-route, and late stat items.
Widget _buildStatusColumn(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
_buildStatRow(
context: context,
value: totalCheckedIn,
label: context.t.client_coverage.stats.on_site,
valueColor: UiColors.primaryForeground,
),
const SizedBox(height: UiConstants.space1),
_buildStatRow(
context: context,
value: totalEnRoute,
label: context.t.client_coverage.stats.en_route,
valueColor: UiColors.accent,
),
const SizedBox(height: UiConstants.space1),
_buildStatRow(
context: context,
value: totalLate,
label: context.t.client_coverage.stats.late,
valueColor: UiColors.tagError,
),
],
);
}
/// Builds a single stat row with a colored number and a muted label.
Widget _buildStatRow({
required BuildContext context,
required int value,
required String label,
required Color valueColor,
}) {
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
context.t.client_coverage.page.workers,
style: UiTypography.body2r.copyWith(
color: UiColors.primaryForeground.withOpacity(0.7),
value.toString(),
style: UiTypography.title2b.copyWith(
color: valueColor,
),
),
const SizedBox(width: UiConstants.space2),
Text(
'$totalConfirmed/$totalNeeded',
style: UiTypography.title2m.copyWith(
label,
style: UiTypography.body4m.copyWith(
color: UiColors.primaryForeground.withValues(alpha: 0.6),
),
),
],
);
}
/// Builds the horizontal progress bar indicating coverage fill.
Widget _buildProgressBar() {
final double clampedFraction =
(coveragePercent / 100).clamp(0.0, 1.0);
return ClipRRect(
borderRadius: UiConstants.radiusFull,
child: SizedBox(
height: 8,
width: double.infinity,
child: Stack(
children: <Widget>[
Container(
decoration: BoxDecoration(
color: UiColors.primaryForeground.withValues(alpha: 0.2),
borderRadius: UiConstants.radiusFull,
),
),
FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: clampedFraction,
child: Container(
decoration: BoxDecoration(
color: UiColors.primaryForeground,
borderRadius: UiConstants.radiusFull,
),
),
),
],
),
],
),
);
}

View File

@@ -2,44 +2,54 @@ 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.
/// Alert banner displayed when workers are running late.
///
/// Shows a warning banner when workers are running late.
/// Renders a solid red container with a warning icon, late worker count,
/// and auto-backup status message in white text.
class LateWorkersAlert extends StatelessWidget {
/// Creates a [LateWorkersAlert].
/// Creates a [LateWorkersAlert] with the given [lateCount].
const LateWorkersAlert({
required this.lateCount,
this.onTap,
super.key,
});
/// The number of late workers.
/// The number of workers currently marked as late.
final int lateCount;
/// Optional callback invoked when the alert is tapped.
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.destructive.withValues(alpha: 0.1),
borderRadius: UiConstants.radiusLg,
border: Border.all(
color: UiColors.destructive,
width: 0.5,
return Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space3,
),
decoration: BoxDecoration(
color: UiColors.destructive,
borderRadius: UiConstants.radiusLg,
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.destructive.withValues(alpha: 0.2),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Row(
spacing: UiConstants.space4,
children: <Widget>[
const Icon(
UiIcons.warning,
color: UiColors.destructive,
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
UiIcons.warning,
color: Colors.white,
size: 16,
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -47,26 +57,21 @@ class LateWorkersAlert extends StatelessWidget {
Text(
context.t.client_coverage.alert
.workers_running_late(n: lateCount, count: lateCount),
style: UiTypography.body1b.textError,
style: UiTypography.body1b.copyWith(
color: Colors.white,
),
),
Text(
context.t.client_coverage.alert.auto_backup_searching,
style: UiTypography.body3r.copyWith(
color: UiColors.textError.withValues(alpha: 0.7),
color: Colors.white.withValues(alpha: 0.8),
),
),
],
),
),
if (onTap != null)
const Icon(
UiIcons.chevronRight,
size: UiConstants.space4,
color: UiColors.destructive,
),
],
),
),
);
}
}

View File

@@ -1,125 +1,193 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_badge.dart';
/// Header section for a shift card showing title, location, time, and coverage.
/// Tappable header for a collapsible shift card.
///
/// Displays a status dot colour-coded by coverage, the shift title and time,
/// a filled/total badge, a linear progress bar, and per-shift worker summary
/// counts (on site, en route, late). Tapping anywhere triggers [onToggle].
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,
required this.onSiteCount,
required this.enRouteCount,
required this.lateCount,
required this.isExpanded,
required this.onToggle,
super.key,
});
/// The shift title.
/// The shift role or title.
final String title;
/// The shift location.
final String location;
/// The formatted shift start time.
/// Formatted shift start time (e.g. "8:00 AM").
final String startTime;
/// Current number of assigned workers.
final int current;
/// Total workers needed for the shift.
/// Total workers required for the shift.
final int total;
/// Coverage percentage (0-100+).
final int coveragePercent;
/// The shift identifier.
/// Unique shift identifier.
final String shiftId;
/// Number of workers currently on site (checked in).
final int onSiteCount;
/// Number of workers en route (accepted but not checked in).
final int enRouteCount;
/// Number of workers marked as late / no-show.
final int lateCount;
/// Whether the shift card is currently expanded to show workers.
final bool isExpanded;
/// Callback invoked when the header is tapped to expand or collapse.
final VoidCallback onToggle;
/// Returns the status colour based on [coveragePercent].
///
/// Green for >= 100 %, yellow for >= 80 %, red otherwise.
Color _statusColor() {
if (coveragePercent >= 100) {
return UiColors.textSuccess;
} else if (coveragePercent >= 80) {
return UiColors.textWarning;
}
return UiColors.destructive;
}
@override
Widget build(BuildContext context) {
return Container(
final Color statusColor = _statusColor();
final TranslationsClientCoverageStatsEn stats =
context.t.client_coverage.stats;
final double fillFraction =
total > 0 ? (current / total).clamp(0.0, 1.0) : 0.0;
return InkWell(
onTap: onToggle,
child: Padding(
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 1: status dot, title + time, badge, chevron.
Row(
spacing: UiConstants.space2,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(
width: UiConstants.space2,
height: UiConstants.space2,
decoration: const BoxDecoration(
color: UiColors.primary,
// Status dot.
Padding(
padding: const EdgeInsets.only(top: UiConstants.space1),
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: statusColor,
shape: BoxShape.circle,
),
),
),
const SizedBox(width: UiConstants.space3),
// Title and start time.
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
title,
style: UiTypography.body1b.textPrimary,
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const SizedBox(height: UiConstants.space1),
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,
size: 10,
color: UiColors.textSecondary,
),
const SizedBox(width: 4),
Text(
startTime,
style: UiTypography.body3r.textSecondary,
style: UiTypography.body3r.copyWith(
color: UiColors.textSecondary,
),
),
],
),
],
),
],
),
// Coverage badge.
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: UiConstants.space1,
),
decoration: BoxDecoration(
color: statusColor.withAlpha(26),
borderRadius: UiConstants.radiusSm,
),
child: Text(
'$current/$total',
style: UiTypography.body3b.copyWith(color: statusColor),
),
),
CoverageBadge(
current: current,
total: total,
coveragePercent: coveragePercent,
const SizedBox(width: UiConstants.space2),
// Expand / collapse chevron.
Icon(
isExpanded ? UiIcons.chevronUp : UiIcons.chevronDown,
size: 16,
color: UiColors.textSecondary,
),
],
),
const SizedBox(height: UiConstants.space3),
// Progress bar.
ClipRRect(
borderRadius: UiConstants.radiusSm,
child: Container(
height: 6,
decoration: const BoxDecoration(
color: UiColors.muted,
),
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: fillFraction,
child: Container(
decoration: BoxDecoration(
color: statusColor,
borderRadius: UiConstants.radiusSm,
),
),
),
),
),
const SizedBox(height: UiConstants.space2),
// Summary text: on site / en route / late.
Text(
'$onSiteCount ${stats.on_site} · '
'$enRouteCount ${stats.en_route} · '
'$lateCount ${stats.late}',
style: UiTypography.body3r.copyWith(
color: UiColors.textSecondary,
),
),
],
),
),
);
}
}

View File

@@ -51,10 +51,6 @@ class WorkerRow extends StatelessWidget {
Color textColor;
IconData icon;
String statusText;
Color badgeBg;
Color badgeText;
Color badgeBorder;
String badgeLabel;
switch (worker.status) {
case AssignmentStatus.checkedIn:
@@ -66,10 +62,6 @@ class WorkerRow extends StatelessWidget {
statusText = l10n.status_checked_in_at(
time: _formatCheckInTime(worker.checkInAt),
);
badgeBg = UiColors.textSuccess.withAlpha(40);
badgeText = UiColors.textSuccess;
badgeBorder = badgeText;
badgeLabel = l10n.status_on_site;
case AssignmentStatus.accepted:
if (worker.checkInAt == null) {
bg = UiColors.textWarning.withAlpha(26);
@@ -78,10 +70,6 @@ class WorkerRow extends StatelessWidget {
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;
@@ -89,10 +77,6 @@ class WorkerRow extends StatelessWidget {
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 AssignmentStatus.noShow:
bg = UiColors.destructive.withAlpha(26);
@@ -101,10 +85,6 @@ class WorkerRow extends StatelessWidget {
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 AssignmentStatus.checkedOut:
bg = UiColors.muted.withAlpha(26);
border = UiColors.border;
@@ -112,10 +92,6 @@ class WorkerRow extends StatelessWidget {
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 AssignmentStatus.completed:
bg = UiColors.iconSuccess.withAlpha(26);
border = UiColors.iconSuccess;
@@ -123,10 +99,6 @@ class WorkerRow extends StatelessWidget {
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 AssignmentStatus.assigned:
case AssignmentStatus.swapRequested:
case AssignmentStatus.cancelled:
@@ -137,10 +109,6 @@ class WorkerRow extends StatelessWidget {
textColor = UiColors.textSecondary;
icon = UiIcons.clock;
statusText = worker.status.value;
badgeBg = UiColors.textSecondary.withAlpha(40);
badgeText = UiColors.textSecondary;
badgeBorder = badgeText;
badgeLabel = worker.status.value;
}
return Container(
@@ -213,88 +181,23 @@ class WorkerRow extends StatelessWidget {
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 (showRateButton && onRate != null)
GestureDetector(
onTap: onRate,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: UiConstants.space1 / 2,
),
decoration: BoxDecoration(
color: UiColors.primary.withAlpha(26),
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.primary, width: 0.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: UiConstants.space1,
children: <Widget>[
const Icon(
UiIcons.star,
size: 12,
color: UiColors.primary,
),
Text(
l10n.actions.rate,
style: UiTypography.footnote2b.copyWith(
color: UiColors.primary,
child: UiChip(
label: l10n.actions.rate,
size: UiChipSize.small,
leadingIcon: UiIcons.star,
),
),
],
),
),
),
if (showCancelButton && onCancel != null)
if (!showCancelButton && onCancel != null)
GestureDetector(
onTap: onCancel,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: UiConstants.space1 / 2,
),
decoration: BoxDecoration(
color: UiColors.destructive.withAlpha(26),
borderRadius: UiConstants.radiusMd,
border: Border.all(
color: UiColors.destructive,
width: 0.5,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: UiConstants.space1,
children: <Widget>[
const Icon(
UiIcons.close,
size: 12,
color: UiColors.destructive,
),
Text(
l10n.actions.cancel,
style: UiTypography.footnote2b.copyWith(
color: UiColors.destructive,
),
),
],
),
child: UiChip(
label: l10n.actions.cancel,
size: UiChipSize.small,
leadingIcon: UiIcons.close,
variant: UiChipVariant.destructive,
),
),
],