diff --git a/.claude/agent-memory/mobile-qa-analyst/MEMORY.md b/.claude/agent-memory/mobile-qa-analyst/MEMORY.md new file mode 100644 index 00000000..9bfe7a71 --- /dev/null +++ b/.claude/agent-memory/mobile-qa-analyst/MEMORY.md @@ -0,0 +1,4 @@ +# Mobile QA Analyst Memory Index + +## Project Context +- [project_clock_in_feature_issues.md](project_clock_in_feature_issues.md) — Critical bugs in staff clock_in feature: BLoC lifecycle leak, stale geofence override, dead lunch break data, non-functional date selector diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart index ea0e990f..c2509429 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart @@ -183,12 +183,12 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface { final fdc.Timestamp checkInTs = _service.toTimestamp(DateTime.now()); - await _service.run(() => _service.connector + await _service.connector .updateApplicationStatus( - id: app!.id, + id: app.id, ) .checkInTime(checkInTs) - .execute()); + .execute(); _activeApplicationId = app.id; return getAttendanceStatus(); @@ -210,9 +210,9 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface { } final fdc.QueryResult appResult = - await _service.run(() => _service.connector + await _service.connector .getApplicationById(id: targetAppId) - .execute()); + .execute(); final dc.GetApplicationByIdApplication? app = appResult.data.application; if (app == null) { @@ -222,12 +222,12 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface { throw Exception('No active shift found to clock out'); } - await _service.run(() => _service.connector + await _service.connector .updateApplicationStatus( id: targetAppId, ) .checkOutTime(_service.toTimestamp(DateTime.now())) - .execute()); + .execute(); return getAttendanceStatus(); }); 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 7b84b7df..a7846548 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 @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -49,8 +51,7 @@ class ClockInBloc extends Bloc on(_onCheckIn); on(_onCheckOut); on(_onModeChanged); - - add(ClockInPageLoaded()); + on(_onTimeWindowRefresh); } final GetTodaysShiftUseCase _getTodaysShift; @@ -64,6 +65,10 @@ class ClockInBloc extends Bloc /// Composite validator for clock-in preconditions. final CompositeClockInValidator _validator; + /// Periodic timer that re-evaluates time window flags every 30 seconds + /// so the "too early" banner updates without user interaction. + Timer? _timeWindowTimer; + /// Loads today's shifts and the current attendance status. Future _onLoaded( ClockInPageLoaded event, @@ -101,6 +106,9 @@ class ClockInBloc extends Bloc checkInAvailabilityTime: timeFlags.checkInAvailabilityTime, checkOutAvailabilityTime: timeFlags.checkOutAvailabilityTime, )); + + // Start periodic timer so time-window banners auto-update. + _startTimeWindowTimer(); }, onError: (String errorKey) => state.copyWith( status: ClockInStatus.failure, @@ -124,12 +132,18 @@ class ClockInBloc extends Bloc )); } - /// Updates the selected date for shift viewing. - void _onDateSelected( + /// Updates the selected date and re-fetches shifts. + /// + /// Currently the repository always fetches today's shifts regardless of + /// the selected date. Re-loading ensures the UI stays in sync after a + /// date change. + // TODO(clock_in): Pass selected date to repository for date-based filtering. + Future _onDateSelected( DateSelected event, Emitter emit, - ) { + ) async { emit(state.copyWith(selectedDate: event.date)); + await _onLoaded(ClockInPageLoaded(), emit); } /// Updates the check-in interaction mode. @@ -222,7 +236,7 @@ class ClockInBloc extends Bloc final AttendanceStatus newStatus = await _clockOut( ClockOutArguments( notes: event.notes, - breakTimeMinutes: 0, + breakTimeMinutes: event.breakTimeMinutes ?? 0, applicationId: state.attendance.activeApplicationId, ), ); @@ -246,6 +260,44 @@ class ClockInBloc extends Bloc ); } + /// Re-evaluates time window flags for the currently selected shift. + /// + /// Fired periodically by [_timeWindowTimer] so banners like "too early" + /// automatically disappear once the check-in window opens. + void _onTimeWindowRefresh( + TimeWindowRefreshRequested event, + Emitter emit, + ) { + if (state.status != ClockInStatus.success) return; + final _TimeWindowFlags timeFlags = _computeTimeWindowFlags( + state.selectedShift, + ); + emit(state.copyWith( + isCheckInAllowed: timeFlags.isCheckInAllowed, + isCheckOutAllowed: timeFlags.isCheckOutAllowed, + checkInAvailabilityTime: timeFlags.checkInAvailabilityTime, + clearCheckInAvailabilityTime: timeFlags.checkInAvailabilityTime == null, + checkOutAvailabilityTime: timeFlags.checkOutAvailabilityTime, + clearCheckOutAvailabilityTime: + timeFlags.checkOutAvailabilityTime == null, + )); + } + + /// Starts the periodic time-window refresh timer. + void _startTimeWindowTimer() { + _timeWindowTimer?.cancel(); + _timeWindowTimer = Timer.periodic( + const Duration(seconds: 30), + (_) => add(const TimeWindowRefreshRequested()), + ); + } + + @override + Future close() { + _timeWindowTimer?.cancel(); + return super.close(); + } + /// Safely parses a time string into a [DateTime], returning `null` on failure. static DateTime? _tryParseDateTime(String? value) { if (value == null || value.isEmpty) return null; 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 27c2aa44..36652f26 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 @@ -118,3 +118,12 @@ class CheckInModeChanged extends ClockInEvent { @override List get props => [mode]; } + +/// Periodically emitted by a timer to re-evaluate time window flags. +/// +/// Ensures banners like "too early to check in" disappear once the +/// time window opens, without requiring user interaction. +class TimeWindowRefreshRequested extends ClockInEvent { + /// Creates a [TimeWindowRefreshRequested] event. + const TimeWindowRefreshRequested(); +} 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 3febeaf2..ddda84ed 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 @@ -58,10 +58,14 @@ class ClockInState extends Equatable { final String? checkOutAvailabilityTime; /// Creates a copy of this state with the given fields replaced. + /// + /// Use the `clearX` flags to explicitly set nullable fields to `null`, + /// since the `??` fallback otherwise prevents clearing. ClockInState copyWith({ ClockInStatus? status, List? todayShifts, Shift? selectedShift, + bool clearSelectedShift = false, AttendanceStatus? attendance, DateTime? selectedDate, String? checkInMode, @@ -69,22 +73,27 @@ class ClockInState extends Equatable { bool? isCheckInAllowed, bool? isCheckOutAllowed, String? checkInAvailabilityTime, + bool clearCheckInAvailabilityTime = false, String? checkOutAvailabilityTime, + bool clearCheckOutAvailabilityTime = false, }) { return ClockInState( status: status ?? this.status, todayShifts: todayShifts ?? this.todayShifts, - selectedShift: selectedShift ?? this.selectedShift, + selectedShift: + clearSelectedShift ? null : (selectedShift ?? this.selectedShift), attendance: attendance ?? this.attendance, 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, + checkInAvailabilityTime: clearCheckInAvailabilityTime + ? null + : (checkInAvailabilityTime ?? this.checkInAvailabilityTime), + checkOutAvailabilityTime: clearCheckOutAvailabilityTime + ? null + : (checkOutAvailabilityTime ?? this.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 5290a008..d9c6a260 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 @@ -39,6 +39,10 @@ class GeofenceBloc extends Bloc on(_onOverrideApproved); on(_onStopped); } + /// Generation counter to discard stale geofence results when a new + /// [GeofenceStarted] event arrives before the previous check completes. + int _generation = 0; + /// The geofence service for foreground proximity checks. final GeofenceServiceInterface _geofenceService; @@ -60,10 +64,23 @@ class GeofenceBloc extends Bloc GeofenceStarted event, Emitter emit, ) async { + // Increment generation so in-flight results from previous shifts are + // discarded when they complete after a new GeofenceStarted fires. + _generation++; + final int currentGeneration = _generation; + + // Reset override state from any previous shift and clear stale location + // data so the new shift starts with a clean geofence verification. emit(state.copyWith( isVerifying: true, targetLat: event.targetLat, targetLng: event.targetLng, + isGeofenceOverridden: false, + clearOverrideNotes: true, + isLocationVerified: false, + isLocationTimedOut: false, + clearCurrentLocation: true, + clearDistanceFromTarget: true, )); await handleError( @@ -71,6 +88,10 @@ class GeofenceBloc extends Bloc action: () async { // Check permission first. final LocationPermissionStatus permission = await _geofenceService.ensurePermission(); + + // Discard if a newer GeofenceStarted has fired while awaiting. + if (_generation != currentGeneration) return; + emit(state.copyWith(permissionStatus: permission)); if (permission == LocationPermissionStatus.denied || @@ -97,6 +118,9 @@ class GeofenceBloc extends Bloc targetLng: event.targetLng, ); + // Discard if a newer GeofenceStarted has fired while awaiting. + if (_generation != currentGeneration) return; + if (result == null) { add(const GeofenceTimeoutReached()); } else { diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_state.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_state.dart index a4ab8ed7..d73a4b2e 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_state.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_state.dart @@ -59,35 +59,51 @@ class GeofenceState extends Equatable { final double? targetLng; /// Creates a copy with the given fields replaced. + /// + /// Use the `clearX` flags to explicitly set nullable fields to `null`, + /// since the `??` fallback otherwise prevents clearing. GeofenceState copyWith({ LocationPermissionStatus? permissionStatus, + bool clearPermissionStatus = false, bool? isLocationServiceEnabled, DeviceLocation? currentLocation, + bool clearCurrentLocation = false, double? distanceFromTarget, + bool clearDistanceFromTarget = false, bool? isLocationVerified, bool? isLocationTimedOut, bool? isVerifying, bool? isBackgroundTrackingActive, bool? isGeofenceOverridden, String? overrideNotes, + bool clearOverrideNotes = false, double? targetLat, + bool clearTargetLat = false, double? targetLng, + bool clearTargetLng = false, }) { return GeofenceState( - permissionStatus: permissionStatus ?? this.permissionStatus, + permissionStatus: clearPermissionStatus + ? null + : (permissionStatus ?? this.permissionStatus), isLocationServiceEnabled: isLocationServiceEnabled ?? this.isLocationServiceEnabled, - currentLocation: currentLocation ?? this.currentLocation, - distanceFromTarget: distanceFromTarget ?? this.distanceFromTarget, + currentLocation: clearCurrentLocation + ? null + : (currentLocation ?? this.currentLocation), + distanceFromTarget: clearDistanceFromTarget + ? null + : (distanceFromTarget ?? this.distanceFromTarget), isLocationVerified: isLocationVerified ?? this.isLocationVerified, isLocationTimedOut: isLocationTimedOut ?? this.isLocationTimedOut, isVerifying: isVerifying ?? this.isVerifying, isBackgroundTrackingActive: isBackgroundTrackingActive ?? this.isBackgroundTrackingActive, isGeofenceOverridden: isGeofenceOverridden ?? this.isGeofenceOverridden, - overrideNotes: overrideNotes ?? this.overrideNotes, - targetLat: targetLat ?? this.targetLat, - targetLng: targetLng ?? this.targetLng, + overrideNotes: + clearOverrideNotes ? null : (overrideNotes ?? this.overrideNotes), + targetLat: clearTargetLat ? null : (targetLat ?? this.targetLat), + targetLng: clearTargetLng ? null : (targetLng ?? this.targetLng), ); } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart index 279749a0..c6b1ffa6 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart @@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import '../bloc/clock_in/clock_in_bloc.dart'; +import '../bloc/clock_in/clock_in_event.dart'; import '../bloc/clock_in/clock_in_state.dart'; import '../bloc/geofence/geofence_bloc.dart'; import '../widgets/clock_in_body.dart'; @@ -32,8 +33,12 @@ class ClockInPage extends StatelessWidget { BlocProvider.value( value: Modular.get(), ), - BlocProvider.value( - value: Modular.get(), + BlocProvider( + create: (BuildContext _) { + final ClockInBloc bloc = Modular.get(); + bloc.add(ClockInPageLoaded()); + return bloc; + }, ), ], child: BlocListener( 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 2a34900a..5a5ec04d 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 @@ -163,7 +163,10 @@ class ClockInActionSection extends StatelessWidget { /// Triggers the check-in flow, passing notification strings and /// override notes from geofence state. + /// + /// Returns early if [selectedShift] is null to avoid force-unwrap errors. void _handleCheckIn(BuildContext context) { + if (selectedShift == null) return; final GeofenceState geofenceState = ReadContext( context, ).read().state; @@ -192,10 +195,11 @@ class ClockInActionSection extends StatelessWidget { showDialog( context: context, builder: (BuildContext dialogContext) => LunchBreakDialog( - onComplete: () { + onComplete: (int breakTimeMinutes) { Modular.to.popSafe(); ReadContext(context).read().add( CheckOutRequested( + breakTimeMinutes: breakTimeMinutes, clockOutTitle: geofenceI18n.clock_out_title, clockOutBody: geofenceI18n.clock_out_body, ), diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart index 47ceb80d..7aac190d 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart @@ -3,9 +3,16 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +/// Dialog that collects lunch break information during the check-out flow. +/// +/// Returns the break duration in minutes via [onComplete]. If the user +/// indicates they did not take a lunch break, the value will be `0`. class LunchBreakDialog extends StatefulWidget { + /// Creates a [LunchBreakDialog] with the required [onComplete] callback. const LunchBreakDialog({super.key, required this.onComplete}); - final VoidCallback onComplete; + + /// Called when the user finishes the dialog, passing break time in minutes. + final ValueChanged onComplete; @override State createState() => _LunchBreakDialogState(); @@ -25,6 +32,36 @@ class _LunchBreakDialogState extends State { final List _timeOptions = _generateTimeOptions(); + /// Computes the break duration in minutes from [_breakStart] and [_breakEnd]. + /// + /// Returns `0` when the user did not take lunch or the times are invalid. + int _computeBreakMinutes() { + if (_tookLunch != true || _breakStart == null || _breakEnd == null) { + return 0; + } + final int? startMinutes = _parseTimeToMinutes(_breakStart!); + final int? endMinutes = _parseTimeToMinutes(_breakEnd!); + if (startMinutes == null || endMinutes == null) return 0; + final int diff = endMinutes - startMinutes; + return diff > 0 ? diff : 0; + } + + /// Parses a time string like "12:30pm" into total minutes since midnight. + static int? _parseTimeToMinutes(String time) { + final String lower = time.toLowerCase().trim(); + final bool isPm = lower.endsWith('pm'); + final String cleaned = lower.replaceAll(RegExp(r'[ap]m'), ''); + final List parts = cleaned.split(':'); + if (parts.length != 2) return null; + final int? hour = int.tryParse(parts[0]); + final int? minute = int.tryParse(parts[1]); + if (hour == null || minute == null) return null; + int hour24 = hour; + if (isPm && hour != 12) hour24 += 12; + if (!isPm && hour == 12) hour24 = 0; + return hour24 * 60 + minute; + } + static List _generateTimeOptions() { final List options = []; for (int h = 0; h < 24; h++) { @@ -258,9 +295,11 @@ class _LunchBreakDialogState extends State { const SizedBox(height: UiConstants.space6), ElevatedButton( - onPressed: () { - setState(() => _step = 3); - }, + onPressed: _noLunchReason != null + ? () { + setState(() => _step = 3); + } + : null, style: ElevatedButton.styleFrom( backgroundColor: UiColors.primary, minimumSize: const Size(double.infinity, 48), @@ -325,7 +364,7 @@ class _LunchBreakDialogState extends State { ), const SizedBox(height: UiConstants.space6), ElevatedButton( - onPressed: widget.onComplete, + onPressed: () => widget.onComplete(_computeBreakMinutes()), style: ElevatedButton.styleFrom( backgroundColor: UiColors.primary, minimumSize: const Size(double.infinity, 48), diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart index 4f0b2fd5..4cac15dc 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart @@ -89,6 +89,7 @@ class _SwipeToCheckInState extends State _isComplete = true; }); Future.delayed(const Duration(milliseconds: 300), () { + if (!mounted) return; if (widget.isCheckedIn) { widget.onCheckOut?.call(); } else {