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 4c6ad9c3..aabc7d71 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 @@ -856,6 +856,8 @@ "today_shift_badge": "TODAY'S SHIFT", "early_title": "You're early!", "check_in_at": "Check-in available at $time", + "early_checkout_title": "Too early to check out", + "check_out_at": "Check-out available at $time", "shift_completed": "Shift Completed!", "great_work": "Great work today", "no_shifts_today": "No confirmed shifts for today", 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 1651da22..ae607b0a 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 @@ -851,6 +851,8 @@ "today_shift_badge": "TURNO DE HOY", "early_title": "\u00a1Ha llegado temprano!", "check_in_at": "Entrada disponible a las $time", + "early_checkout_title": "Muy temprano para salir", + "check_out_at": "Salida disponible a las $time", "shift_completed": "\u00a1Turno completado!", "great_work": "Buen trabajo hoy", "no_shifts_today": "No hay turnos confirmados para hoy", diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart index bbc2234b..1b111d41 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart @@ -6,6 +6,7 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:staff_clock_in/src/presentation/widgets/early_check_in_banner.dart'; +import 'package:staff_clock_in/src/presentation/widgets/early_check_out_banner.dart'; import '../../domain/validators/clock_in_validation_context.dart'; import '../../domain/validators/validators/time_window_validator.dart'; @@ -42,9 +43,9 @@ class ClockInActionSection extends StatelessWidget { /// Available check-in interaction strategies keyed by mode identifier. static const Map _interactions = { - 'swipe': SwipeCheckInInteraction(), - 'nfc': NfcCheckInInteraction(), - }; + 'swipe': SwipeCheckInInteraction(), + 'nfc': NfcCheckInInteraction(), + }; /// The currently selected shift, or null if none is selected. final Shift? selectedShift; @@ -85,6 +86,7 @@ class ClockInActionSection extends StatelessWidget { /// Builds the action widget for an active (not completed) shift. Widget _buildActiveShiftAction(BuildContext context) { + // Show geofence status and time-based availability banners when relevant. if (!isCheckedIn && !_isCheckInAllowed(selectedShift!)) { return Column( mainAxisSize: MainAxisSize.min, @@ -92,7 +94,20 @@ class ClockInActionSection extends StatelessWidget { const GeofenceStatusBanner(), const SizedBox(height: UiConstants.space3), EarlyCheckInBanner( - availabilityTime: _getAvailabilityTimeText( + availabilityTime: _getAvailabilityTimeText(selectedShift!, context), + ), + ], + ); + } + + if (isCheckedIn && !_isCheckOutAllowed(selectedShift!)) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const GeofenceStatusBanner(), + const SizedBox(height: UiConstants.space3), + EarlyCheckOutBanner( + availabilityTime: _getCheckOutAvailabilityTimeText( selectedShift!, context, ), @@ -139,8 +154,9 @@ class ClockInActionSection extends StatelessWidget { final GeofenceState geofenceState = ReadContext( context, ).read().state; - final TranslationsStaffClockInGeofenceEn geofenceI18n = - Translations.of(context).staff.clock_in.geofence; + final TranslationsStaffClockInGeofenceEn geofenceI18n = Translations.of( + context, + ).staff.clock_in.geofence; ReadContext(context).read().add( CheckInRequested( @@ -167,6 +183,33 @@ class ClockInActionSection extends StatelessWidget { return const TimeWindowValidator().validate(validationContext).isValid; } + /// Whether the user is allowed to check out for the given [shift]. + /// + /// Delegates to [TimeWindowValidator]; returns `true` if the end time + /// cannot be parsed (don't block the user). + bool _isCheckOutAllowed(Shift shift) { + final DateTime? shiftEnd = DateTime.tryParse(shift.endTime); + if (shiftEnd == null) return true; + + final ClockInValidationContext validationContext = ClockInValidationContext( + isCheckingIn: false, + shiftEndTime: shiftEnd, + ); + return const TimeWindowValidator().validate(validationContext).isValid; + } + + /// Returns the formatted earliest check-out time for the given [shift]. + /// + /// Falls back to the localized "soon" label when the end time cannot + /// be parsed. + String _getCheckOutAvailabilityTimeText(Shift shift, BuildContext context) { + final DateTime? shiftEnd = DateTime.tryParse(shift.endTime.trim()); + if (shiftEnd != null) { + return TimeWindowValidator.getAvailabilityTime(shiftEnd); + } + return Translations.of(context).staff.clock_in.soon; + } + /// Returns the formatted earliest check-in time for the given [shift]. /// /// Falls back to the localized "soon" label when the start time cannot @@ -181,8 +224,9 @@ class ClockInActionSection extends StatelessWidget { /// Triggers the check-out flow via the lunch-break confirmation dialog. void _handleCheckOut(BuildContext context) { - final TranslationsStaffClockInGeofenceEn geofenceI18n = - Translations.of(context).staff.clock_in.geofence; + final TranslationsStaffClockInGeofenceEn geofenceI18n = Translations.of( + context, + ).staff.clock_in.geofence; showDialog( context: context, diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/early_check_out_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/early_check_out_banner.dart new file mode 100644 index 00000000..eda5272a --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/early_check_out_banner.dart @@ -0,0 +1,50 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Banner shown when the user tries to check out too early. +/// +/// Displays a clock icon and a message indicating when check-out +/// will become available. +class EarlyCheckOutBanner extends StatelessWidget { + /// Creates an early check-out banner. + const EarlyCheckOutBanner({ + required this.availabilityTime, + super.key, + }); + + /// Formatted time string when check-out becomes available (e.g. "4:45 PM"). + final String availabilityTime; + + @override + Widget build(BuildContext context) { + final TranslationsStaffClockInEn i18n = Translations.of( + context, + ).staff.clock_in; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(UiConstants.space6), + decoration: BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: UiConstants.radiusLg, + ), + child: Column( + children: [ + const Icon(UiIcons.clock, size: 48, color: UiColors.iconThird), + const SizedBox(height: UiConstants.space4), + Text( + i18n.early_checkout_title, + style: UiTypography.body1m.textSecondary, + ), + const SizedBox(height: UiConstants.space1), + Text( + i18n.check_out_at(time: availabilityTime), + style: UiTypography.body2r.textSecondary, + textAlign: TextAlign.center, + ), + ], + ), + ); + } +}