diff --git a/.claude/agents/architecture-reviewer.md b/.claude/agents/mobile-architecture-reviewer.md similarity index 100% rename from .claude/agents/architecture-reviewer.md rename to .claude/agents/mobile-architecture-reviewer.md 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 aabc7d71..f4693848 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 @@ -862,6 +862,7 @@ "great_work": "Great work today", "no_shifts_today": "No confirmed shifts for today", "accept_shift_cta": "Accept a shift to clock in", + "per_hr": "\\$$amount/hr", "soon": "soon", "checked_in_at_label": "Checked in at", "not_in_range": "You must be within $distance m to clock in.", 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 ae607b0a..006e3dec 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 @@ -857,6 +857,7 @@ "great_work": "Buen trabajo hoy", "no_shifts_today": "No hay turnos confirmados para hoy", "accept_shift_cta": "Acepte un turno para registrar su entrada", + "per_hr": "\\$$amount/hr", "soon": "pronto", "checked_in_at_label": "Entrada registrada a las", "nfc_dialog": { diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart index 108b12f6..f13360c7 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart @@ -1,6 +1,6 @@ // ignore_for_file: avoid_print import 'package:krow_core/core.dart'; -import 'package:krow_domain/src/core/models/device_location.dart'; +import 'package:krow_domain/krow_domain.dart'; /// Top-level callback dispatcher for background geofence tasks. /// @@ -63,13 +63,17 @@ void backgroundGeofenceDispatcher() { 'showing notification', ); + final String title = inputData?['leftGeofenceTitle'] as String? ?? + "You've Left the Workplace"; + final String body = inputData?['leftGeofenceBody'] as String? ?? + 'You appear to be more than 500m from your shift location.'; + final NotificationService notificationService = NotificationService(); await notificationService.showNotification( id: BackgroundGeofenceService.leftGeofenceNotificationId, - title: "You've Left the Workplace", - body: - 'You appear to be more than 500m from your shift location.', + title: title, + body: body, ); } else { print( @@ -138,11 +142,14 @@ class BackgroundGeofenceService { /// Starts periodic 15-minute background geofence checks. /// /// Called after a successful clock-in. Persists the target coordinates - /// so the background isolate can access them. + /// and passes localized notification strings via [inputData] so the + /// background isolate can display them without DI. Future startBackgroundTracking({ required double targetLat, required double targetLng, required String shiftId, + required String leftGeofenceTitle, + required String leftGeofenceBody, }) async { await Future.wait(>[ _storageService.setDouble(_keyTargetLat, targetLat), @@ -159,6 +166,8 @@ class BackgroundGeofenceService { 'targetLat': targetLat, 'targetLng': targetLng, 'shiftId': shiftId, + 'leftGeofenceTitle': leftGeofenceTitle, + 'leftGeofenceBody': leftGeofenceBody, }, ); } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart index b5532656..7b84b7df 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart @@ -11,6 +11,7 @@ import '../../../domain/usecases/get_todays_shift_usecase.dart'; import '../../../domain/validators/clock_in_validation_context.dart'; import '../../../domain/validators/clock_in_validation_result.dart'; import '../../../domain/validators/validators/composite_clock_in_validator.dart'; +import '../../../domain/validators/validators/time_window_validator.dart'; import '../geofence/geofence_bloc.dart'; import '../geofence/geofence_event.dart'; import '../geofence/geofence_state.dart'; @@ -86,11 +87,19 @@ class ClockInBloc extends Bloc selectedShift ??= shifts.last; } + final _TimeWindowFlags timeFlags = _computeTimeWindowFlags( + selectedShift, + ); + emit(state.copyWith( status: ClockInStatus.success, todayShifts: shifts, selectedShift: selectedShift, attendance: status, + isCheckInAllowed: timeFlags.isCheckInAllowed, + isCheckOutAllowed: timeFlags.isCheckOutAllowed, + checkInAvailabilityTime: timeFlags.checkInAvailabilityTime, + checkOutAvailabilityTime: timeFlags.checkOutAvailabilityTime, )); }, onError: (String errorKey) => state.copyWith( @@ -100,12 +109,19 @@ class ClockInBloc extends Bloc ); } - /// Updates the currently selected shift. + /// Updates the currently selected shift and recomputes time window flags. void _onShiftSelected( ShiftSelected event, Emitter emit, ) { - emit(state.copyWith(selectedShift: event.shift)); + final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(event.shift); + emit(state.copyWith( + selectedShift: event.shift, + isCheckInAllowed: timeFlags.isCheckInAllowed, + isCheckOutAllowed: timeFlags.isCheckOutAllowed, + checkInAvailabilityTime: timeFlags.checkInAvailabilityTime, + checkOutAvailabilityTime: timeFlags.checkOutAvailabilityTime, + )); } /// Updates the selected date for shift viewing. @@ -236,6 +252,56 @@ class ClockInBloc extends Bloc return DateTime.tryParse(value); } + /// Computes time-window check-in/check-out flags for the given [shift]. + /// + /// Uses [TimeWindowValidator] so this business logic stays out of widgets. + static _TimeWindowFlags _computeTimeWindowFlags(Shift? shift) { + if (shift == null) { + return const _TimeWindowFlags(); + } + + const TimeWindowValidator validator = TimeWindowValidator(); + final DateTime? shiftStart = _tryParseDateTime(shift.startTime); + final DateTime? shiftEnd = _tryParseDateTime(shift.endTime); + + // Check-in window. + bool isCheckInAllowed = true; + String? checkInAvailabilityTime; + if (shiftStart != null) { + final ClockInValidationContext checkInCtx = ClockInValidationContext( + isCheckingIn: true, + shiftStartTime: shiftStart, + ); + isCheckInAllowed = validator.validate(checkInCtx).isValid; + if (!isCheckInAllowed) { + checkInAvailabilityTime = + TimeWindowValidator.getAvailabilityTime(shiftStart); + } + } + + // Check-out window. + bool isCheckOutAllowed = true; + String? checkOutAvailabilityTime; + if (shiftEnd != null) { + final ClockInValidationContext checkOutCtx = ClockInValidationContext( + isCheckingIn: false, + shiftEndTime: shiftEnd, + ); + isCheckOutAllowed = validator.validate(checkOutCtx).isValid; + if (!isCheckOutAllowed) { + checkOutAvailabilityTime = + TimeWindowValidator.getAvailabilityTime(shiftEnd); + } + } + + return _TimeWindowFlags( + isCheckInAllowed: isCheckInAllowed, + isCheckOutAllowed: isCheckOutAllowed, + checkInAvailabilityTime: checkInAvailabilityTime, + checkOutAvailabilityTime: checkOutAvailabilityTime, + ); + } + /// Dispatches [BackgroundTrackingStarted] to [_geofenceBloc] if the /// geofence has target coordinates. void _dispatchBackgroundTrackingStarted({ @@ -254,8 +320,33 @@ class ClockInBloc extends Bloc targetLng: geofenceState.targetLng!, greetingTitle: event.clockInGreetingTitle, greetingBody: event.clockInGreetingBody, + leftGeofenceTitle: event.leftGeofenceTitle, + leftGeofenceBody: event.leftGeofenceBody, ), ); } } } + +/// Internal value holder for time-window computation results. +class _TimeWindowFlags { + /// Creates a [_TimeWindowFlags] with default allowed values. + const _TimeWindowFlags({ + this.isCheckInAllowed = true, + this.isCheckOutAllowed = true, + this.checkInAvailabilityTime, + this.checkOutAvailabilityTime, + }); + + /// Whether the time window currently allows check-in. + final bool isCheckInAllowed; + + /// Whether the time window currently allows check-out. + final bool isCheckOutAllowed; + + /// Formatted time when check-in becomes available, or `null`. + final String? checkInAvailabilityTime; + + /// Formatted time when check-out becomes available, or `null`. + final String? checkOutAvailabilityTime; +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart index d8c30eb1..27c2aa44 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart @@ -45,6 +45,8 @@ class CheckInRequested extends ClockInEvent { this.notes, this.clockInGreetingTitle = '', this.clockInGreetingBody = '', + this.leftGeofenceTitle = '', + this.leftGeofenceBody = '', }); /// The ID of the shift to clock into. @@ -59,12 +61,20 @@ class CheckInRequested extends ClockInEvent { /// Localized body for the clock-in greeting notification. final String clockInGreetingBody; + /// Localized title for the left-geofence background notification. + final String leftGeofenceTitle; + + /// Localized body for the left-geofence background notification. + final String leftGeofenceBody; + @override List get props => [ shiftId, notes, clockInGreetingTitle, clockInGreetingBody, + leftGeofenceTitle, + leftGeofenceBody, ]; } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_state.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_state.dart index 3e69fd50..3febeaf2 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_state.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_state.dart @@ -18,6 +18,10 @@ class ClockInState extends Equatable { required this.selectedDate, this.checkInMode = 'swipe', this.errorMessage, + this.isCheckInAllowed = true, + this.isCheckOutAllowed = true, + this.checkInAvailabilityTime, + this.checkOutAvailabilityTime, }); /// Current page status. @@ -41,6 +45,18 @@ class ClockInState extends Equatable { /// Error message key for displaying failures. final String? errorMessage; + /// Whether the time window allows the user to check in. + final bool isCheckInAllowed; + + /// Whether the time window allows the user to check out. + final bool isCheckOutAllowed; + + /// Formatted earliest time when check-in becomes available, or `null`. + final String? checkInAvailabilityTime; + + /// Formatted earliest time when check-out becomes available, or `null`. + final String? checkOutAvailabilityTime; + /// Creates a copy of this state with the given fields replaced. ClockInState copyWith({ ClockInStatus? status, @@ -50,6 +66,10 @@ class ClockInState extends Equatable { DateTime? selectedDate, String? checkInMode, String? errorMessage, + bool? isCheckInAllowed, + bool? isCheckOutAllowed, + String? checkInAvailabilityTime, + String? checkOutAvailabilityTime, }) { return ClockInState( status: status ?? this.status, @@ -59,6 +79,12 @@ class ClockInState extends Equatable { selectedDate: selectedDate ?? this.selectedDate, checkInMode: checkInMode ?? this.checkInMode, errorMessage: errorMessage, + isCheckInAllowed: isCheckInAllowed ?? this.isCheckInAllowed, + isCheckOutAllowed: isCheckOutAllowed ?? this.isCheckOutAllowed, + checkInAvailabilityTime: + checkInAvailabilityTime ?? this.checkInAvailabilityTime, + checkOutAvailabilityTime: + checkOutAvailabilityTime ?? this.checkOutAvailabilityTime, ); } @@ -71,5 +97,9 @@ class ClockInState extends Equatable { selectedDate, checkInMode, errorMessage, + isCheckInAllowed, + isCheckOutAllowed, + checkInAvailabilityTime, + checkOutAvailabilityTime, ]; } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart index e2db2f73..5290a008 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart @@ -213,6 +213,8 @@ class GeofenceBloc extends Bloc targetLat: event.targetLat, targetLng: event.targetLng, shiftId: event.shiftId, + leftGeofenceTitle: event.leftGeofenceTitle, + leftGeofenceBody: event.leftGeofenceBody, ); // Show greeting notification using localized strings from the UI. diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart index c5f68a60..980d5c5d 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart @@ -71,6 +71,8 @@ class BackgroundTrackingStarted extends GeofenceEvent { required this.targetLng, required this.greetingTitle, required this.greetingBody, + required this.leftGeofenceTitle, + required this.leftGeofenceBody, }); /// The shift ID being tracked. @@ -88,9 +90,24 @@ class BackgroundTrackingStarted extends GeofenceEvent { /// Localized greeting notification body passed from the UI layer. final String greetingBody; + /// Localized title for the left-geofence notification, persisted to storage + /// for the background isolate. + final String leftGeofenceTitle; + + /// Localized body for the left-geofence notification, persisted to storage + /// for the background isolate. + final String leftGeofenceBody; + @override - List get props => - [shiftId, targetLat, targetLng, greetingTitle, greetingBody]; + List get props => [ + shiftId, + targetLat, + targetLng, + greetingTitle, + greetingBody, + leftGeofenceTitle, + leftGeofenceBody, + ]; } /// Stops background tracking after clock-out. 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 1b111d41..2a34900a 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 @@ -8,8 +8,6 @@ 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'; import '../bloc/clock_in/clock_in_bloc.dart'; import '../bloc/clock_in/clock_in_event.dart'; import '../bloc/geofence/geofence_bloc.dart'; @@ -37,6 +35,10 @@ class ClockInActionSection extends StatelessWidget { required this.checkInMode, required this.isActionInProgress, this.hasClockinError = false, + this.isCheckInAllowed = true, + this.isCheckOutAllowed = true, + this.checkInAvailabilityTime, + this.checkOutAvailabilityTime, super.key, }); @@ -65,6 +67,18 @@ class ClockInActionSection extends StatelessWidget { /// Whether the last action attempt resulted in an error. final bool hasClockinError; + /// Whether the time window allows check-in, computed by the BLoC. + final bool isCheckInAllowed; + + /// Whether the time window allows check-out, computed by the BLoC. + final bool isCheckOutAllowed; + + /// Formatted earliest time when check-in becomes available, or `null`. + final String? checkInAvailabilityTime; + + /// Formatted earliest time when check-out becomes available, or `null`. + final String? checkOutAvailabilityTime; + /// Resolves the [CheckInInteraction] for the current mode. /// /// Falls back to [SwipeCheckInInteraction] if the mode is unrecognized. @@ -86,31 +100,30 @@ class ClockInActionSection extends StatelessWidget { /// Builds the action widget for an active (not completed) shift. Widget _buildActiveShiftAction(BuildContext context) { + final String soonLabel = Translations.of(context).staff.clock_in.soon; + // Show geofence status and time-based availability banners when relevant. - if (!isCheckedIn && !_isCheckInAllowed(selectedShift!)) { + if (!isCheckedIn && !isCheckInAllowed) { return Column( mainAxisSize: MainAxisSize.min, children: [ const GeofenceStatusBanner(), const SizedBox(height: UiConstants.space3), EarlyCheckInBanner( - availabilityTime: _getAvailabilityTimeText(selectedShift!, context), + availabilityTime: checkInAvailabilityTime ?? soonLabel, ), ], ); } - if (isCheckedIn && !_isCheckOutAllowed(selectedShift!)) { + if (isCheckedIn && !isCheckOutAllowed) { return Column( mainAxisSize: MainAxisSize.min, children: [ const GeofenceStatusBanner(), const SizedBox(height: UiConstants.space3), EarlyCheckOutBanner( - availabilityTime: _getCheckOutAvailabilityTimeText( - selectedShift!, - context, - ), + availabilityTime: checkOutAvailabilityTime ?? soonLabel, ), ], ); @@ -164,64 +177,12 @@ class ClockInActionSection extends StatelessWidget { notes: geofenceState.overrideNotes, clockInGreetingTitle: geofenceI18n.clock_in_greeting_title, clockInGreetingBody: geofenceI18n.clock_in_greeting_body, + leftGeofenceTitle: geofenceI18n.background_left_title, + leftGeofenceBody: geofenceI18n.background_left_body, ), ); } - /// Whether the user is allowed to check in for the given [shift]. - /// - /// Delegates to [TimeWindowValidator]; returns `true` if the start time - /// cannot be parsed (don't block the user). - bool _isCheckInAllowed(Shift shift) { - final DateTime? shiftStart = DateTime.tryParse(shift.startTime); - if (shiftStart == null) return true; - - final ClockInValidationContext validationContext = ClockInValidationContext( - isCheckingIn: true, - shiftStartTime: shiftStart, - ); - 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 - /// be parsed. - String _getAvailabilityTimeText(Shift shift, BuildContext context) { - final DateTime? shiftStart = DateTime.tryParse(shift.startTime.trim()); - if (shiftStart != null) { - return TimeWindowValidator.getAvailabilityTime(shiftStart); - } - return Translations.of(context).staff.clock_in.soon; - } - /// Triggers the check-out flow via the lunch-break confirmation dialog. void _handleCheckOut(BuildContext context) { final TranslationsStaffClockInGeofenceEn geofenceI18n = Translations.of( diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart index c9f6ea50..fc67a5b4 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart @@ -112,6 +112,10 @@ class _ClockInBodyState extends State { isActionInProgress: state.status == ClockInStatus.actionInProgress, hasClockinError: state.status == ClockInStatus.failure, + isCheckInAllowed: state.isCheckInAllowed, + isCheckOutAllowed: state.isCheckOutAllowed, + checkInAvailabilityTime: state.checkInAvailabilityTime, + checkOutAvailabilityTime: state.checkOutAvailabilityTime, ), // checked-in banner (only when checked in to the selected shift) diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart index 3b4d0d97..5224e922 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart @@ -106,6 +106,10 @@ class _ShiftTimeAndRate extends StatelessWidget { @override Widget build(BuildContext context) { + final TranslationsStaffClockInEn i18n = Translations.of( + context, + ).staff.clock_in; + return Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ @@ -114,7 +118,7 @@ class _ShiftTimeAndRate extends StatelessWidget { style: UiTypography.body3m.textSecondary, ), Text( - '\$${shift.hourlyRate}/hr', + i18n.per_hr(amount: shift.hourlyRate), style: UiTypography.body3m.copyWith(color: UiColors.primary), ), ],