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());
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<dc.GetApplicationByIdData,
dc.GetApplicationByIdVariables> 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();
});

View File

@@ -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<ClockInEvent, ClockInState>
on<CheckInRequested>(_onCheckIn);
on<CheckOutRequested>(_onCheckOut);
on<CheckInModeChanged>(_onModeChanged);
add(ClockInPageLoaded());
on<TimeWindowRefreshRequested>(_onTimeWindowRefresh);
}
final GetTodaysShiftUseCase _getTodaysShift;
@@ -64,6 +65,10 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
/// 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<void> _onLoaded(
ClockInPageLoaded event,
@@ -101,6 +106,9 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
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<ClockInEvent, ClockInState>
));
}
/// 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<void> _onDateSelected(
DateSelected event,
Emitter<ClockInState> 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<ClockInEvent, ClockInState>
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<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.
static DateTime? _tryParseDateTime(String? value) {
if (value == null || value.isEmpty) return null;

View File

@@ -118,3 +118,12 @@ class CheckInModeChanged extends ClockInEvent {
@override
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;
/// 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<Shift>? 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),
);
}

View File

@@ -39,6 +39,10 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
on<GeofenceOverrideApproved>(_onOverrideApproved);
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.
final GeofenceServiceInterface _geofenceService;
@@ -60,10 +64,23 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
GeofenceStarted event,
Emitter<GeofenceState> 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<GeofenceEvent, GeofenceState>
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<GeofenceEvent, GeofenceState>
targetLng: event.targetLng,
);
// Discard if a newer GeofenceStarted has fired while awaiting.
if (_generation != currentGeneration) return;
if (result == null) {
add(const GeofenceTimeoutReached());
} else {

View File

@@ -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),
);
}

View File

@@ -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<GeofenceBloc>.value(
value: Modular.get<GeofenceBloc>(),
),
BlocProvider<ClockInBloc>.value(
value: Modular.get<ClockInBloc>(),
BlocProvider<ClockInBloc>(
create: (BuildContext _) {
final ClockInBloc bloc = Modular.get<ClockInBloc>();
bloc.add(ClockInPageLoaded());
return bloc;
},
),
],
child: BlocListener<ClockInBloc, ClockInState>(

View File

@@ -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<GeofenceBloc>().state;
@@ -192,10 +195,11 @@ class ClockInActionSection extends StatelessWidget {
showDialog<void>(
context: context,
builder: (BuildContext dialogContext) => LunchBreakDialog(
onComplete: () {
onComplete: (int breakTimeMinutes) {
Modular.to.popSafe();
ReadContext(context).read<ClockInBloc>().add(
CheckOutRequested(
breakTimeMinutes: breakTimeMinutes,
clockOutTitle: geofenceI18n.clock_out_title,
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: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<int> onComplete;
@override
State<LunchBreakDialog> createState() => _LunchBreakDialogState();
@@ -25,6 +32,36 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
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() {
final List<String> options = <String>[];
for (int h = 0; h < 24; h++) {
@@ -258,9 +295,11 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
const SizedBox(height: UiConstants.space6),
ElevatedButton(
onPressed: () {
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<LunchBreakDialog> {
),
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),

View File

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