feat: Enhance clock-in feature with time window auto-refresh and lunch break data handling

This commit is contained in:
Achintha Isuru
2026-03-16 01:55:20 -04:00
parent c5b5c14655
commit 31f03aa52a
11 changed files with 195 additions and 32 deletions

View File

@@ -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

View File

@@ -183,12 +183,12 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface {
final fdc.Timestamp checkInTs = _service.toTimestamp(DateTime.now()); final fdc.Timestamp checkInTs = _service.toTimestamp(DateTime.now());
await _service.run(() => _service.connector await _service.connector
.updateApplicationStatus( .updateApplicationStatus(
id: app!.id, id: app.id,
) )
.checkInTime(checkInTs) .checkInTime(checkInTs)
.execute()); .execute();
_activeApplicationId = app.id; _activeApplicationId = app.id;
return getAttendanceStatus(); return getAttendanceStatus();
@@ -210,9 +210,9 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface {
} }
final fdc.QueryResult<dc.GetApplicationByIdData, final fdc.QueryResult<dc.GetApplicationByIdData,
dc.GetApplicationByIdVariables> appResult = dc.GetApplicationByIdVariables> appResult =
await _service.run(() => _service.connector await _service.connector
.getApplicationById(id: targetAppId) .getApplicationById(id: targetAppId)
.execute()); .execute();
final dc.GetApplicationByIdApplication? app = appResult.data.application; final dc.GetApplicationByIdApplication? app = appResult.data.application;
if (app == null) { if (app == null) {
@@ -222,12 +222,12 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface {
throw Exception('No active shift found to clock out'); throw Exception('No active shift found to clock out');
} }
await _service.run(() => _service.connector await _service.connector
.updateApplicationStatus( .updateApplicationStatus(
id: targetAppId, id: targetAppId,
) )
.checkOutTime(_service.toTimestamp(DateTime.now())) .checkOutTime(_service.toTimestamp(DateTime.now()))
.execute()); .execute();
return getAttendanceStatus(); return getAttendanceStatus();
}); });

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
@@ -49,8 +51,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
on<CheckInRequested>(_onCheckIn); on<CheckInRequested>(_onCheckIn);
on<CheckOutRequested>(_onCheckOut); on<CheckOutRequested>(_onCheckOut);
on<CheckInModeChanged>(_onModeChanged); on<CheckInModeChanged>(_onModeChanged);
on<TimeWindowRefreshRequested>(_onTimeWindowRefresh);
add(ClockInPageLoaded());
} }
final GetTodaysShiftUseCase _getTodaysShift; final GetTodaysShiftUseCase _getTodaysShift;
@@ -64,6 +65,10 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
/// Composite validator for clock-in preconditions. /// Composite validator for clock-in preconditions.
final CompositeClockInValidator _validator; 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. /// Loads today's shifts and the current attendance status.
Future<void> _onLoaded( Future<void> _onLoaded(
ClockInPageLoaded event, ClockInPageLoaded event,
@@ -101,6 +106,9 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
checkInAvailabilityTime: timeFlags.checkInAvailabilityTime, checkInAvailabilityTime: timeFlags.checkInAvailabilityTime,
checkOutAvailabilityTime: timeFlags.checkOutAvailabilityTime, checkOutAvailabilityTime: timeFlags.checkOutAvailabilityTime,
)); ));
// Start periodic timer so time-window banners auto-update.
_startTimeWindowTimer();
}, },
onError: (String errorKey) => state.copyWith( onError: (String errorKey) => state.copyWith(
status: ClockInStatus.failure, status: ClockInStatus.failure,
@@ -124,12 +132,18 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
)); ));
} }
/// Updates the selected date for shift viewing. /// Updates the selected date and re-fetches shifts.
void _onDateSelected( ///
/// 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<void> _onDateSelected(
DateSelected event, DateSelected event,
Emitter<ClockInState> emit, Emitter<ClockInState> emit,
) { ) async {
emit(state.copyWith(selectedDate: event.date)); emit(state.copyWith(selectedDate: event.date));
await _onLoaded(ClockInPageLoaded(), emit);
} }
/// Updates the check-in interaction mode. /// Updates the check-in interaction mode.
@@ -222,7 +236,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
final AttendanceStatus newStatus = await _clockOut( final AttendanceStatus newStatus = await _clockOut(
ClockOutArguments( ClockOutArguments(
notes: event.notes, notes: event.notes,
breakTimeMinutes: 0, breakTimeMinutes: event.breakTimeMinutes ?? 0,
applicationId: state.attendance.activeApplicationId, applicationId: state.attendance.activeApplicationId,
), ),
); );
@@ -246,6 +260,44 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
); );
} }
/// 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<ClockInState> 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<void> close() {
_timeWindowTimer?.cancel();
return super.close();
}
/// Safely parses a time string into a [DateTime], returning `null` on failure. /// Safely parses a time string into a [DateTime], returning `null` on failure.
static DateTime? _tryParseDateTime(String? value) { static DateTime? _tryParseDateTime(String? value) {
if (value == null || value.isEmpty) return null; if (value == null || value.isEmpty) return null;

View File

@@ -118,3 +118,12 @@ class CheckInModeChanged extends ClockInEvent {
@override @override
List<Object?> get props => <Object?>[mode]; List<Object?> get props => <Object?>[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();
}

View File

@@ -58,10 +58,14 @@ class ClockInState extends Equatable {
final String? checkOutAvailabilityTime; final String? checkOutAvailabilityTime;
/// Creates a copy of this state with the given fields replaced. /// 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({ ClockInState copyWith({
ClockInStatus? status, ClockInStatus? status,
List<Shift>? todayShifts, List<Shift>? todayShifts,
Shift? selectedShift, Shift? selectedShift,
bool clearSelectedShift = false,
AttendanceStatus? attendance, AttendanceStatus? attendance,
DateTime? selectedDate, DateTime? selectedDate,
String? checkInMode, String? checkInMode,
@@ -69,22 +73,27 @@ class ClockInState extends Equatable {
bool? isCheckInAllowed, bool? isCheckInAllowed,
bool? isCheckOutAllowed, bool? isCheckOutAllowed,
String? checkInAvailabilityTime, String? checkInAvailabilityTime,
bool clearCheckInAvailabilityTime = false,
String? checkOutAvailabilityTime, String? checkOutAvailabilityTime,
bool clearCheckOutAvailabilityTime = false,
}) { }) {
return ClockInState( return ClockInState(
status: status ?? this.status, status: status ?? this.status,
todayShifts: todayShifts ?? this.todayShifts, todayShifts: todayShifts ?? this.todayShifts,
selectedShift: selectedShift ?? this.selectedShift, selectedShift:
clearSelectedShift ? null : (selectedShift ?? this.selectedShift),
attendance: attendance ?? this.attendance, attendance: attendance ?? this.attendance,
selectedDate: selectedDate ?? this.selectedDate, selectedDate: selectedDate ?? this.selectedDate,
checkInMode: checkInMode ?? this.checkInMode, checkInMode: checkInMode ?? this.checkInMode,
errorMessage: errorMessage, errorMessage: errorMessage,
isCheckInAllowed: isCheckInAllowed ?? this.isCheckInAllowed, isCheckInAllowed: isCheckInAllowed ?? this.isCheckInAllowed,
isCheckOutAllowed: isCheckOutAllowed ?? this.isCheckOutAllowed, isCheckOutAllowed: isCheckOutAllowed ?? this.isCheckOutAllowed,
checkInAvailabilityTime: checkInAvailabilityTime: clearCheckInAvailabilityTime
checkInAvailabilityTime ?? this.checkInAvailabilityTime, ? null
checkOutAvailabilityTime: : (checkInAvailabilityTime ?? this.checkInAvailabilityTime),
checkOutAvailabilityTime ?? this.checkOutAvailabilityTime, checkOutAvailabilityTime: clearCheckOutAvailabilityTime
? null
: (checkOutAvailabilityTime ?? this.checkOutAvailabilityTime),
); );
} }

View File

@@ -39,6 +39,10 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
on<GeofenceOverrideApproved>(_onOverrideApproved); on<GeofenceOverrideApproved>(_onOverrideApproved);
on<GeofenceStopped>(_onStopped); on<GeofenceStopped>(_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. /// The geofence service for foreground proximity checks.
final GeofenceServiceInterface _geofenceService; final GeofenceServiceInterface _geofenceService;
@@ -60,10 +64,23 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
GeofenceStarted event, GeofenceStarted event,
Emitter<GeofenceState> emit, Emitter<GeofenceState> emit,
) async { ) 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( emit(state.copyWith(
isVerifying: true, isVerifying: true,
targetLat: event.targetLat, targetLat: event.targetLat,
targetLng: event.targetLng, targetLng: event.targetLng,
isGeofenceOverridden: false,
clearOverrideNotes: true,
isLocationVerified: false,
isLocationTimedOut: false,
clearCurrentLocation: true,
clearDistanceFromTarget: true,
)); ));
await handleError( await handleError(
@@ -71,6 +88,10 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
action: () async { action: () async {
// Check permission first. // Check permission first.
final LocationPermissionStatus permission = await _geofenceService.ensurePermission(); final LocationPermissionStatus permission = await _geofenceService.ensurePermission();
// Discard if a newer GeofenceStarted has fired while awaiting.
if (_generation != currentGeneration) return;
emit(state.copyWith(permissionStatus: permission)); emit(state.copyWith(permissionStatus: permission));
if (permission == LocationPermissionStatus.denied || if (permission == LocationPermissionStatus.denied ||
@@ -97,6 +118,9 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
targetLng: event.targetLng, targetLng: event.targetLng,
); );
// Discard if a newer GeofenceStarted has fired while awaiting.
if (_generation != currentGeneration) return;
if (result == null) { if (result == null) {
add(const GeofenceTimeoutReached()); add(const GeofenceTimeoutReached());
} else { } else {

View File

@@ -59,35 +59,51 @@ class GeofenceState extends Equatable {
final double? targetLng; final double? targetLng;
/// Creates a copy with the given fields replaced. /// 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({ GeofenceState copyWith({
LocationPermissionStatus? permissionStatus, LocationPermissionStatus? permissionStatus,
bool clearPermissionStatus = false,
bool? isLocationServiceEnabled, bool? isLocationServiceEnabled,
DeviceLocation? currentLocation, DeviceLocation? currentLocation,
bool clearCurrentLocation = false,
double? distanceFromTarget, double? distanceFromTarget,
bool clearDistanceFromTarget = false,
bool? isLocationVerified, bool? isLocationVerified,
bool? isLocationTimedOut, bool? isLocationTimedOut,
bool? isVerifying, bool? isVerifying,
bool? isBackgroundTrackingActive, bool? isBackgroundTrackingActive,
bool? isGeofenceOverridden, bool? isGeofenceOverridden,
String? overrideNotes, String? overrideNotes,
bool clearOverrideNotes = false,
double? targetLat, double? targetLat,
bool clearTargetLat = false,
double? targetLng, double? targetLng,
bool clearTargetLng = false,
}) { }) {
return GeofenceState( return GeofenceState(
permissionStatus: permissionStatus ?? this.permissionStatus, permissionStatus: clearPermissionStatus
? null
: (permissionStatus ?? this.permissionStatus),
isLocationServiceEnabled: isLocationServiceEnabled:
isLocationServiceEnabled ?? this.isLocationServiceEnabled, isLocationServiceEnabled ?? this.isLocationServiceEnabled,
currentLocation: currentLocation ?? this.currentLocation, currentLocation: clearCurrentLocation
distanceFromTarget: distanceFromTarget ?? this.distanceFromTarget, ? null
: (currentLocation ?? this.currentLocation),
distanceFromTarget: clearDistanceFromTarget
? null
: (distanceFromTarget ?? this.distanceFromTarget),
isLocationVerified: isLocationVerified ?? this.isLocationVerified, isLocationVerified: isLocationVerified ?? this.isLocationVerified,
isLocationTimedOut: isLocationTimedOut ?? this.isLocationTimedOut, isLocationTimedOut: isLocationTimedOut ?? this.isLocationTimedOut,
isVerifying: isVerifying ?? this.isVerifying, isVerifying: isVerifying ?? this.isVerifying,
isBackgroundTrackingActive: isBackgroundTrackingActive:
isBackgroundTrackingActive ?? this.isBackgroundTrackingActive, isBackgroundTrackingActive ?? this.isBackgroundTrackingActive,
isGeofenceOverridden: isGeofenceOverridden ?? this.isGeofenceOverridden, isGeofenceOverridden: isGeofenceOverridden ?? this.isGeofenceOverridden,
overrideNotes: overrideNotes ?? this.overrideNotes, overrideNotes:
targetLat: targetLat ?? this.targetLat, clearOverrideNotes ? null : (overrideNotes ?? this.overrideNotes),
targetLng: targetLng ?? this.targetLng, targetLat: clearTargetLat ? null : (targetLat ?? this.targetLat),
targetLng: clearTargetLng ? null : (targetLng ?? this.targetLng),
); );
} }

View File

@@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import '../bloc/clock_in/clock_in_bloc.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/clock_in/clock_in_state.dart';
import '../bloc/geofence/geofence_bloc.dart'; import '../bloc/geofence/geofence_bloc.dart';
import '../widgets/clock_in_body.dart'; import '../widgets/clock_in_body.dart';
@@ -32,8 +33,12 @@ class ClockInPage extends StatelessWidget {
BlocProvider<GeofenceBloc>.value( BlocProvider<GeofenceBloc>.value(
value: Modular.get<GeofenceBloc>(), value: Modular.get<GeofenceBloc>(),
), ),
BlocProvider<ClockInBloc>.value( BlocProvider<ClockInBloc>(
value: Modular.get<ClockInBloc>(), create: (BuildContext _) {
final ClockInBloc bloc = Modular.get<ClockInBloc>();
bloc.add(ClockInPageLoaded());
return bloc;
},
), ),
], ],
child: BlocListener<ClockInBloc, ClockInState>( child: BlocListener<ClockInBloc, ClockInState>(

View File

@@ -163,7 +163,10 @@ class ClockInActionSection extends StatelessWidget {
/// Triggers the check-in flow, passing notification strings and /// Triggers the check-in flow, passing notification strings and
/// override notes from geofence state. /// override notes from geofence state.
///
/// Returns early if [selectedShift] is null to avoid force-unwrap errors.
void _handleCheckIn(BuildContext context) { void _handleCheckIn(BuildContext context) {
if (selectedShift == null) return;
final GeofenceState geofenceState = ReadContext( final GeofenceState geofenceState = ReadContext(
context, context,
).read<GeofenceBloc>().state; ).read<GeofenceBloc>().state;
@@ -192,10 +195,11 @@ class ClockInActionSection extends StatelessWidget {
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (BuildContext dialogContext) => LunchBreakDialog( builder: (BuildContext dialogContext) => LunchBreakDialog(
onComplete: () { onComplete: (int breakTimeMinutes) {
Modular.to.popSafe(); Modular.to.popSafe();
ReadContext(context).read<ClockInBloc>().add( ReadContext(context).read<ClockInBloc>().add(
CheckOutRequested( CheckOutRequested(
breakTimeMinutes: breakTimeMinutes,
clockOutTitle: geofenceI18n.clock_out_title, clockOutTitle: geofenceI18n.clock_out_title,
clockOutBody: geofenceI18n.clock_out_body, clockOutBody: geofenceI18n.clock_out_body,
), ),

View File

@@ -3,9 +3,16 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.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 { class LunchBreakDialog extends StatefulWidget {
/// Creates a [LunchBreakDialog] with the required [onComplete] callback.
const LunchBreakDialog({super.key, required this.onComplete}); const LunchBreakDialog({super.key, required this.onComplete});
final VoidCallback onComplete;
/// Called when the user finishes the dialog, passing break time in minutes.
final ValueChanged<int> onComplete;
@override @override
State<LunchBreakDialog> createState() => _LunchBreakDialogState(); State<LunchBreakDialog> createState() => _LunchBreakDialogState();
@@ -25,6 +32,36 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
final List<String> _timeOptions = _generateTimeOptions(); final List<String> _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<String> 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<String> _generateTimeOptions() { static List<String> _generateTimeOptions() {
final List<String> options = <String>[]; final List<String> options = <String>[];
for (int h = 0; h < 24; h++) { for (int h = 0; h < 24; h++) {
@@ -258,9 +295,11 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: _noLunchReason != null
setState(() => _step = 3); ? () {
}, setState(() => _step = 3);
}
: null,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary, backgroundColor: UiColors.primary,
minimumSize: const Size(double.infinity, 48), minimumSize: const Size(double.infinity, 48),
@@ -325,7 +364,7 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
ElevatedButton( ElevatedButton(
onPressed: widget.onComplete, onPressed: () => widget.onComplete(_computeBreakMinutes()),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary, backgroundColor: UiColors.primary,
minimumSize: const Size(double.infinity, 48), minimumSize: const Size(double.infinity, 48),

View File

@@ -89,6 +89,7 @@ class _SwipeToCheckInState extends State<SwipeToCheckIn>
_isComplete = true; _isComplete = true;
}); });
Future.delayed(const Duration(milliseconds: 300), () { Future.delayed(const Duration(milliseconds: 300), () {
if (!mounted) return;
if (widget.isCheckedIn) { if (widget.isCheckedIn) {
widget.onCheckOut?.call(); widget.onCheckOut?.call();
} else { } else {