diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index b0eb7570..4b63ce6b 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -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)": { diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index f61a721c..731896fd 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -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)": { diff --git a/apps/mobile/packages/design_system/lib/src/widgets/ui_chip.dart b/apps/mobile/packages/design_system/lib/src/widgets/ui_chip.dart index 09a781da..62af6cf1 100644 --- a/apps/mobile/packages/design_system/lib/src/widgets/ui_chip.dart +++ b/apps/mobile/packages/design_system/lib/src/widgets/ui_chip.dart @@ -82,6 +82,7 @@ class UiChip extends StatelessWidget { final Row content = Row( mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, children: [ if (leadingIcon != null) ...[ Icon(leadingIcon, size: iconSize, color: contentColor), diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart index d540735a..79650827 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart @@ -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 { + /// 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 { 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( @@ -93,19 +82,26 @@ class _CoveragePageState extends State { slivers: [ 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(_isScrolled), - style: UiTypography.title2m.copyWith( - color: UiColors.primaryForeground, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + 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: [ IconButton( @@ -135,7 +131,7 @@ class _CoveragePageState extends State { gradient: LinearGradient( colors: [ UiColors.primary, - UiColors.primary, + Color(0xFF0626A8), ], begin: Alignment.topLeft, end: Alignment.bottomRight, @@ -169,6 +165,12 @@ class _CoveragePageState extends State { 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 { ); } - /// 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, @@ -242,24 +247,19 @@ class _CoveragePageState extends State { crossAxisAlignment: CrossAxisAlignment.start, spacing: UiConstants.space6, children: [ - Column( - spacing: UiConstants.space2, - children: [ - if (state.stats != null && - state.stats!.totalWorkersLate > 0) ...[ - LateWorkersAlert( - lateCount: state.stats!.totalWorkersLate, - ), - ], - if (state.stats != null) ...[ - CoverageQuickStats(stats: state.stats!), - ], - ], - ), + if (state.stats != null && + state.stats!.totalWorkersLate > 0) ...[ + LateWorkersAlert( + lateCount: state.stats!.totalWorkersLate, + ), + ], 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), diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/cancel_late_worker_sheet.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/cancel_late_worker_sheet.dart index dc4ad5fe..ba375262 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/cancel_late_worker_sheet.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/cancel_late_worker_sheet.dart @@ -91,14 +91,29 @@ class _CancelLateWorkerSheetState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Icon( - UiIcons.warning, - color: UiColors.destructive, - size: 28, + Row( + spacing: UiConstants.space3, + children: [ + const Icon( + UiIcons.warning, + color: UiColors.destructive, + size: 28, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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 { ), ], ), - 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 { ), 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), ], ), ); diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_calendar_selector.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_calendar_selector.dart index 8ae4ce85..44bc9670 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_calendar_selector.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_calendar_selector.dart @@ -110,7 +110,7 @@ class _CoverageCalendarSelectorState extends State { 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 { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ + 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 { : UiColors.primaryForeground, ), ), - Text( - DateFormat('E').format(date), - style: UiTypography.body4m.copyWith( - color: isSelected - ? UiColors.mutedForeground - : UiColors.primaryForeground.withOpacity(0.7), - ), - ), ], ), ), diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/coverage_page_skeleton.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/coverage_page_skeleton.dart index 1e639bc1..6d85aec5 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/coverage_page_skeleton.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/coverage_page_skeleton.dart @@ -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: [ - // Quick stats row (2 stat cards) - Row( - children: [ - 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 diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart deleted file mode 100644 index e1e9a85b..00000000 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart +++ /dev/null @@ -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: [ - 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, - ), - ), - ], - ); - } -} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart index 13b6ce9e..db989400 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart @@ -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 shifts; + @override + State createState() => _CoverageShiftListState(); +} + +/// State for [CoverageShiftList] managing which shift cards are expanded. +class _CoverageShiftListState extends State { + /// Set of shift IDs whose cards are currently expanded. + final Set _expandedShiftIds = {}; + + /// 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,86 +117,136 @@ 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( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], ), clipBehavior: Clip.antiAlias, child: Column( children: [ 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), + ), + AnimatedCrossFade( + firstChild: const SizedBox.shrink(), + secondChild: _buildWorkerSection(shift, l10n), + crossFadeState: isExpanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 200), ), - if (shift.assignedWorkers.isNotEmpty) - Padding( - padding: const EdgeInsets.all(UiConstants.space3), - child: Column( - children: shift.assignedWorkers - .map((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), - showRateButton: - worker.status == AssignmentStatus.checkedIn || - worker.status == - AssignmentStatus.checkedOut || - worker.status == - AssignmentStatus.completed, - showCancelButton: - worker.status == AssignmentStatus.noShow || - worker.status == - AssignmentStatus.assigned || - worker.status == - AssignmentStatus.accepted, - onRate: () => WorkerReviewSheet.show( - context, - worker: worker, - ), - onCancel: () => CancelLateWorkerSheet.show( - context, - worker: worker, - ), - ), - ); - }).toList(), - ), - ) - else - Padding( - padding: const EdgeInsets.all(UiConstants.space4), - child: Text( - l10n.no_workers_assigned, - style: UiTypography.body3r.copyWith( - color: UiColors.mutedForeground, - ), - ), - ), ], ), ); }).toList(), ); } + + /// Builds the expanded worker section for a shift including divider. + Widget _buildWorkerSection( + ShiftWithWorkers shift, + TranslationsClientCoverageEn l10n, + ) { + if (shift.assignedWorkers.isEmpty) { + return Column( + children: [ + 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: [ + const Divider(height: 1, color: UiColors.border), + Padding( + padding: const EdgeInsets.all(UiConstants.space3), + child: Column( + children: + shift.assignedWorkers.map((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), + showRateButton: + worker.status == AssignmentStatus.checkedIn || + worker.status == AssignmentStatus.checkedOut || + worker.status == AssignmentStatus.completed, + showCancelButton: + worker.status == AssignmentStatus.noShow || + worker.status == AssignmentStatus.assigned || + worker.status == AssignmentStatus.accepted, + onRate: () => WorkerReviewSheet.show( + context, + worker: worker, + ), + onCancel: () => CancelLateWorkerSheet.show( + context, + worker: worker, + ), + ), + ); + }).toList(), + ), + ), + ], + ); + } } diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_stat_card.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_stat_card.dart deleted file mode 100644 index b82585ce..00000000 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_stat_card.dart +++ /dev/null @@ -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: [ - Icon( - icon, - color: color, - size: UiConstants.space6, - ), - Text( - value, - style: UiTypography.title1b.copyWith( - color: color, - ), - ), - Text( - label, - style: UiTypography.body3r.copyWith( - color: color, - ), - ), - ], - ), - ); - } -} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_stats_header.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_stats_header.dart index 15b4b448..da92d7eb 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_stats_header.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_stats_header.dart @@ -2,72 +2,176 @@ 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: [ - Column( + Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - 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: [ - 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, - ), + 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: [ + Text( + context.t.client_coverage.page.overall_coverage, + style: UiTypography.body3r.copyWith( + color: UiColors.primaryForeground.withValues(alpha: 0.6), + ), + ), + Text( + '${coveragePercent.toStringAsFixed(0)}%', + style: UiTypography.display1b.copyWith( + color: UiColors.primaryForeground, + ), + ), + ], + ); + } + + /// Builds the right column with on-site, en-route, and late stat items. + Widget _buildStatusColumn(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _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: [ + Text( + value.toString(), + style: UiTypography.title2b.copyWith( + color: valueColor, + ), + ), + const SizedBox(width: UiConstants.space2), + Text( + 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: [ + 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, + ), + ), + ), + ], + ), + ), + ); + } } diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/late_workers_alert.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/late_workers_alert.dart index 5d1f7bd8..d6f1f400 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/late_workers_alert.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/late_workers_alert.dart @@ -2,70 +2,75 @@ 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( + color: UiColors.destructive.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 4), ), - ), - child: Row( - spacing: UiConstants.space4, - children: [ - const Icon( + ], + ), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( UiIcons.warning, - color: UiColors.destructive, + color: Colors.white, + size: 16, ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.t.client_coverage.alert - .workers_running_late(n: lateCount, count: lateCount), - style: UiTypography.body1b.textError, + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_coverage.alert + .workers_running_late(n: lateCount, count: lateCount), + 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), - ), + ), + Text( + context.t.client_coverage.alert.auto_backup_searching, + style: UiTypography.body3r.copyWith( + color: Colors.white.withValues(alpha: 0.8), ), - ], - ), + ), + ], ), - if (onTap != null) - const Icon( - UiIcons.chevronRight, - size: UiConstants.space4, - color: UiColors.destructive, - ), - ], - ), + ), + ], ), ); } diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/shift_header.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/shift_header.dart index ffa56b00..1b11ef4d 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/shift_header.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/shift_header.dart @@ -1,124 +1,192 @@ +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( - 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: [ - Expanded( - child: Column( + 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), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Row 1: status dot, title + time, badge, chevron. + Row( crossAxisAlignment: CrossAxisAlignment.start, - spacing: UiConstants.space2, children: [ - Row( - spacing: UiConstants.space2, - children: [ - Container( - width: UiConstants.space2, - height: UiConstants.space2, - decoration: const BoxDecoration( - color: UiColors.primary, - shape: BoxShape.circle, - ), + // Status dot. + Padding( + padding: const EdgeInsets.only(top: UiConstants.space1), + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: statusColor, + shape: BoxShape.circle, ), - Text( - title, - style: UiTypography.body1b.textPrimary, - ), - ], + ), ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - spacing: UiConstants.space1, - children: [ - 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: [ - const Icon( - UiIcons.clock, - size: UiConstants.space3, - color: UiColors.iconSecondary, - ), - Text( - startTime, - style: UiTypography.body3r.textSecondary, - ), - ], - ), - ], + const SizedBox(width: UiConstants.space3), + // Title and start time. + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: UiTypography.body1b.textPrimary, + ), + const SizedBox(height: UiConstants.space1), + Row( + children: [ + const Icon( + UiIcons.clock, + size: 10, + color: UiColors.textSecondary, + ), + const SizedBox(width: 4), + Text( + startTime, + 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), + ), + ), + const SizedBox(width: UiConstants.space2), + // Expand / collapse chevron. + Icon( + isExpanded ? UiIcons.chevronUp : UiIcons.chevronDown, + size: 16, + color: UiColors.textSecondary, ), ], ), - ), - CoverageBadge( - current: current, - total: total, - coveragePercent: coveragePercent, - ), - ], + 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, + ), + ), + ], + ), ), ); } diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_row.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_row.dart index 1c5aae75..fbd5349a 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_row.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_row.dart @@ -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: [ - 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: [ - 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: [ - 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, ), ), ],