feat: Enhance clock-in feature with time window auto-refresh and lunch break data handling
This commit is contained in:
4
.claude/agent-memory/mobile-qa-analyst/MEMORY.md
Normal file
4
.claude/agent-memory/mobile-qa-analyst/MEMORY.md
Normal 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
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user