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());
|
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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user