Refactor clock-in feature: Introduce validation pipeline and interaction strategies

- Added a validation pipeline for clock-in actions, including geofence, time window, and override notes validators.
- Created a context class for passing validation data and results.
- Implemented check-in interaction strategies for swipe and NFC methods, encapsulating their UI and behavior.
- Removed redundant utility functions and centralized time formatting in a new utility file.
- Enhanced the notification service to handle clock-in and clock-out notifications separately.
- Updated the ClockInBloc to utilize the new validation and notification services.
- Cleaned up the ClockInActionSection widget to use the new interaction strategies and removed unnecessary listeners.
This commit is contained in:
Achintha Isuru
2026-03-14 19:58:43 -04:00
parent e3f7e1ac3e
commit 28a219bbea
29 changed files with 864 additions and 394 deletions

View File

@@ -56,6 +56,15 @@ If any of these files are missing or unreadable, notify the user before proceedi
- Use `UiColors`, `UiTypography`, `UiIcons`, `UiConstants` for all design values - Use `UiColors`, `UiTypography`, `UiIcons`, `UiConstants` for all design values
- Use `core_localization` for user-facing strings - Use `core_localization` for user-facing strings
- Add concise `///` doc comments to every class, method, and field. Keep them short (1-2 lines) — just enough for another developer to understand the purpose without reading the implementation. - Add concise `///` doc comments to every class, method, and field. Keep them short (1-2 lines) — just enough for another developer to understand the purpose without reading the implementation.
- **Always specify explicit types** on every local variable, loop variable, and lambda parameter — never use `final x = ...` or `var x = ...` without the type. Example: `final String name = getName();` not `final name = getName();`. This is enforced by the `always_specify_types` lint rule.
- **Always place constructors before fields and methods** in class declarations. The correct order is: constructor → fields → methods. This is enforced by the `sort_constructors_first` lint rule. Example:
```dart
class MyClass {
const MyClass({required this.name});
final String name;
void doSomething() {}
}
```
## Standard Workflow ## Standard Workflow
@@ -121,13 +130,19 @@ features/
entities/ # Pure Dart classes entities/ # Pure Dart classes
repositories/ # Abstract interfaces repositories/ # Abstract interfaces
usecases/ # Business logic lives HERE usecases/ # Business logic lives HERE
validators/ # Composable validation pipeline (optional)
domain.dart # Barrel file domain.dart # Barrel file
data/ data/
models/ # With fromJson/toJson models/ # With fromJson/toJson
repositories/ # Concrete implementations repositories/ # Concrete implementations
data.dart # Barrel file data.dart # Barrel file
presentation/ presentation/
bloc/ # Events, states, BLoC bloc/
feature_bloc/ # Each BLoC in its own subfolder
feature_bloc.dart
feature_event.dart
feature_state.dart
strategies/ # Strategy pattern implementations (optional)
screens/ # Full pages screens/ # Full pages
widgets/ # Reusable components widgets/ # Reusable components
presentation.dart # Barrel file presentation.dart # Barrel file

View File

@@ -6,6 +6,7 @@ export 'src/domain/arguments/usecase_argument.dart';
export 'src/domain/usecases/usecase.dart'; export 'src/domain/usecases/usecase.dart';
export 'src/utils/date_time_utils.dart'; export 'src/utils/date_time_utils.dart';
export 'src/utils/geo_utils.dart'; export 'src/utils/geo_utils.dart';
export 'src/utils/time_utils.dart';
export 'src/presentation/widgets/web_mobile_frame.dart'; export 'src/presentation/widgets/web_mobile_frame.dart';
export 'src/presentation/mixins/bloc_error_handler.dart'; export 'src/presentation/mixins/bloc_error_handler.dart';
export 'src/presentation/observers/core_bloc_observer.dart'; export 'src/presentation/observers/core_bloc_observer.dart';

View File

@@ -0,0 +1,30 @@
import 'package:intl/intl.dart';
/// Formats a time string (ISO 8601 or HH:mm) into 12-hour format
/// (e.g. "9:00 AM").
///
/// Returns the original string unchanged if parsing fails.
String formatTime(String timeStr) {
if (timeStr.isEmpty) return '';
try {
final DateTime dt = DateTime.parse(timeStr);
return DateFormat('h:mm a').format(dt);
} catch (_) {
try {
final List<String> parts = timeStr.split(':');
if (parts.length >= 2) {
final DateTime dt = DateTime(
2022,
1,
1,
int.parse(parts[0]),
int.parse(parts[1]),
);
return DateFormat('h:mm a').format(dt);
}
return timeStr;
} catch (_) {
return timeStr;
}
}
}

View File

@@ -18,6 +18,7 @@ dependencies:
design_system: design_system:
path: ../design_system path: ../design_system
intl: ^0.20.0
flutter_bloc: ^8.1.0 flutter_bloc: ^8.1.0
equatable: ^2.0.8 equatable: ^2.0.8
flutter_modular: ^6.4.1 flutter_modular: ^6.4.1

View File

@@ -90,24 +90,21 @@ void backgroundGeofenceDispatcher() {
/// Service that manages periodic background geofence checks while clocked in. /// Service that manages periodic background geofence checks while clocked in.
/// ///
/// Uses core services for foreground operations. The background isolate logic /// Handles scheduling and cancelling background tasks only. Notification
/// lives in the top-level [backgroundGeofenceDispatcher] function above. /// delivery is handled by [ClockInNotificationService]. The background isolate
/// logic lives in the top-level [backgroundGeofenceDispatcher] function above.
class BackgroundGeofenceService { class BackgroundGeofenceService {
/// Creates a [BackgroundGeofenceService] instance. /// Creates a [BackgroundGeofenceService] instance.
BackgroundGeofenceService({ BackgroundGeofenceService({
required BackgroundTaskService backgroundTaskService, required BackgroundTaskService backgroundTaskService,
required NotificationService notificationService,
required StorageService storageService, required StorageService storageService,
}) : _backgroundTaskService = backgroundTaskService, }) : _backgroundTaskService = backgroundTaskService,
_notificationService = notificationService,
_storageService = storageService; _storageService = storageService;
/// The core background task service for scheduling periodic work. /// The core background task service for scheduling periodic work.
final BackgroundTaskService _backgroundTaskService; final BackgroundTaskService _backgroundTaskService;
/// The core notification service for displaying local notifications.
final NotificationService _notificationService;
/// The core storage service for persisting geofence target data. /// The core storage service for persisting geofence target data.
final StorageService _storageService; final StorageService _storageService;
@@ -129,18 +126,15 @@ class BackgroundGeofenceService {
/// Task name identifier for the workmanager callback. /// Task name identifier for the workmanager callback.
static const String taskName = 'geofenceCheck'; static const String taskName = 'geofenceCheck';
/// Notification ID for clock-in greeting notifications.
static const int _clockInNotificationId = 1;
/// Notification ID for left-geofence warnings. /// Notification ID for left-geofence warnings.
///
/// Kept here because the top-level [backgroundGeofenceDispatcher] references
/// it directly (background isolate has no DI access).
static const int leftGeofenceNotificationId = 2; static const int leftGeofenceNotificationId = 2;
/// Geofence radius in meters. /// Geofence radius in meters.
static const double geofenceRadiusMeters = 500; static const double geofenceRadiusMeters = 500;
/// Notification ID for clock-out notifications.
static const int _clockOutNotificationId = 3;
/// Starts periodic 15-minute background geofence checks. /// Starts periodic 15-minute background geofence checks.
/// ///
/// Called after a successful clock-in. Persists the target coordinates /// Called after a successful clock-in. Persists the target coordinates
@@ -188,40 +182,4 @@ class BackgroundGeofenceService {
final bool? active = await _storageService.getBool(_keyTrackingActive); final bool? active = await _storageService.getBool(_keyTrackingActive);
return active ?? false; return active ?? false;
} }
/// Shows a notification that the worker has left the geofence.
Future<void> showLeftGeofenceNotification({
required String title,
required String body,
}) async {
await _notificationService.showNotification(
title: title,
body: body,
id: leftGeofenceNotificationId,
);
}
/// Shows a greeting notification upon successful clock-in.
Future<void> showClockInGreetingNotification({
required String title,
required String body,
}) async {
await _notificationService.showNotification(
title: title,
body: body,
id: _clockInNotificationId,
);
}
/// Shows a notification upon successful clock-out.
Future<void> showClockOutNotification({
required String title,
required String body,
}) async {
await _notificationService.showNotification(
title: title,
body: body,
id: _clockOutNotificationId,
);
}
} }

View File

@@ -0,0 +1,61 @@
import 'package:krow_core/core.dart';
/// Service responsible for displaying clock-in related local notifications.
///
/// Encapsulates notification logic extracted from [BackgroundGeofenceService]
/// so that geofence tracking and user-facing notifications have separate
/// responsibilities.
class ClockInNotificationService {
/// Creates a [ClockInNotificationService] instance.
const ClockInNotificationService({
required NotificationService notificationService,
}) : _notificationService = notificationService;
/// The underlying core notification service.
final NotificationService _notificationService;
/// Notification ID for clock-in greeting notifications.
static const int _clockInNotificationId = 1;
/// Notification ID for left-geofence warnings.
static const int leftGeofenceNotificationId = 2;
/// Notification ID for clock-out notifications.
static const int _clockOutNotificationId = 3;
/// Shows a greeting notification after successful clock-in.
Future<void> showClockInGreeting({
required String title,
required String body,
}) async {
await _notificationService.showNotification(
title: title,
body: body,
id: _clockInNotificationId,
);
}
/// Shows a notification when the worker clocks out.
Future<void> showClockOutNotification({
required String title,
required String body,
}) async {
await _notificationService.showNotification(
title: title,
body: body,
id: _clockOutNotificationId,
);
}
/// Shows a notification when the worker leaves the geofence.
Future<void> showLeftGeofenceNotification({
required String title,
required String body,
}) async {
await _notificationService.showNotification(
title: title,
body: body,
id: leftGeofenceNotificationId,
);
}
}

View File

@@ -40,7 +40,7 @@ class GeofenceServiceImpl implements GeofenceServiceInterface {
double radiusMeters = 500, double radiusMeters = 500,
}) { }) {
return _locationService.watchLocation(distanceFilter: 10).map( return _locationService.watchLocation(distanceFilter: 10).map(
(location) => _buildResult( (DeviceLocation location) => _buildResult(
location: location, location: location,
targetLat: targetLat, targetLat: targetLat,
targetLng: targetLng, targetLng: targetLng,
@@ -57,7 +57,7 @@ class GeofenceServiceImpl implements GeofenceServiceInterface {
Duration timeout = const Duration(seconds: 30), Duration timeout = const Duration(seconds: 30),
}) async { }) async {
try { try {
final location = final DeviceLocation location =
await _locationService.getCurrentLocation().timeout(timeout); await _locationService.getCurrentLocation().timeout(timeout);
return _buildResult( return _buildResult(
location: location, location: location,
@@ -92,15 +92,15 @@ class GeofenceServiceImpl implements GeofenceServiceInterface {
required double targetLng, required double targetLng,
required double radiusMeters, required double radiusMeters,
}) { }) {
final distance = calculateDistance( final double distance = calculateDistance(
location.latitude, location.latitude,
location.longitude, location.longitude,
targetLat, targetLat,
targetLng, targetLng,
); );
final isWithin = debugAlwaysInRange || distance <= radiusMeters; final bool isWithin = debugAlwaysInRange || distance <= radiusMeters;
final eta = final int eta =
isWithin ? 0 : (distance / _walkingSpeedMetersPerMinute).round(); isWithin ? 0 : (distance / _walkingSpeedMetersPerMinute).round();
return GeofenceResult( return GeofenceResult(

View File

@@ -3,6 +3,14 @@ import 'package:krow_domain/krow_domain.dart';
/// Result of a geofence proximity check. /// Result of a geofence proximity check.
class GeofenceResult extends Equatable { class GeofenceResult extends Equatable {
/// Creates a [GeofenceResult] instance.
const GeofenceResult({
required this.distanceMeters,
required this.isWithinRadius,
required this.estimatedEtaMinutes,
required this.location,
});
/// Distance from the target location in meters. /// Distance from the target location in meters.
final double distanceMeters; final double distanceMeters;
@@ -15,16 +23,8 @@ class GeofenceResult extends Equatable {
/// The device location at the time of the check. /// The device location at the time of the check.
final DeviceLocation location; final DeviceLocation location;
/// Creates a [GeofenceResult] instance.
const GeofenceResult({
required this.distanceMeters,
required this.isWithinRadius,
required this.estimatedEtaMinutes,
required this.location,
});
@override @override
List<Object?> get props => [ List<Object?> get props => <Object?>[
distanceMeters, distanceMeters,
isWithinRadius, isWithinRadius,
estimatedEtaMinutes, estimatedEtaMinutes,

View File

@@ -0,0 +1,55 @@
import 'package:equatable/equatable.dart';
/// Immutable input context carrying all data needed for clock-in validation.
///
/// Constructed by the presentation layer and passed through the validation
/// pipeline so that each validator can inspect the fields it cares about.
class ClockInValidationContext extends Equatable {
/// Creates a [ClockInValidationContext].
const ClockInValidationContext({
required this.isCheckingIn,
this.shiftStartTime,
this.shiftEndTime,
this.hasCoordinates = false,
this.isLocationVerified = false,
this.isLocationTimedOut = false,
this.isGeofenceOverridden = false,
this.overrideNotes,
});
/// Whether this is a clock-in attempt (`true`) or clock-out (`false`).
final bool isCheckingIn;
/// The scheduled start time of the shift, if known.
final DateTime? shiftStartTime;
/// The scheduled end time of the shift, if known.
final DateTime? shiftEndTime;
/// Whether the shift's venue has latitude/longitude coordinates.
final bool hasCoordinates;
/// Whether the device location has been verified against the geofence.
final bool isLocationVerified;
/// Whether the location check timed out before verification completed.
final bool isLocationTimedOut;
/// Whether the worker explicitly overrode the geofence via justification.
final bool isGeofenceOverridden;
/// Optional notes provided when overriding or timing out.
final String? overrideNotes;
@override
List<Object?> get props => <Object?>[
isCheckingIn,
shiftStartTime,
shiftEndTime,
hasCoordinates,
isLocationVerified,
isLocationTimedOut,
isGeofenceOverridden,
overrideNotes,
];
}

View File

@@ -0,0 +1,24 @@
import 'package:equatable/equatable.dart';
/// The outcome of a single validation step in the clock-in pipeline.
///
/// Use the named constructors [ClockInValidationResult.valid] and
/// [ClockInValidationResult.invalid] to create instances.
class ClockInValidationResult extends Equatable {
/// Creates a passing validation result.
const ClockInValidationResult.valid()
: isValid = true,
errorKey = null;
/// Creates a failing validation result with the given [errorKey].
const ClockInValidationResult.invalid(this.errorKey) : isValid = false;
/// Whether the validation passed.
final bool isValid;
/// A localization key describing the validation failure, or `null` if valid.
final String? errorKey;
@override
List<Object?> get props => <Object?>[isValid, errorKey];
}

View File

@@ -0,0 +1,11 @@
import 'clock_in_validation_context.dart';
import 'clock_in_validation_result.dart';
/// Abstract interface for a single step in the clock-in validation pipeline.
///
/// Implementations inspect the [ClockInValidationContext] and return a
/// [ClockInValidationResult] indicating whether the check passed or failed.
abstract class ClockInValidator {
/// Validates the given [context] and returns the result.
ClockInValidationResult validate(ClockInValidationContext context);
}

View File

@@ -0,0 +1,27 @@
import 'clock_in_validation_context.dart';
import 'clock_in_validation_result.dart';
import 'clock_in_validator.dart';
/// Runs a list of [ClockInValidator]s in order, short-circuiting on first failure.
///
/// This implements the composite pattern to chain multiple validation rules
/// into a single pipeline. Validators are executed sequentially and the first
/// failing result is returned immediately.
class CompositeClockInValidator implements ClockInValidator {
/// Creates a [CompositeClockInValidator] with the given [validators].
const CompositeClockInValidator(this.validators);
/// The ordered list of validators to execute.
final List<ClockInValidator> validators;
/// Runs each validator in order. Returns the first failing result,
/// or [ClockInValidationResult.valid] if all pass.
@override
ClockInValidationResult validate(ClockInValidationContext context) {
for (final ClockInValidator validator in validators) {
final ClockInValidationResult result = validator.validate(context);
if (!result.isValid) return result;
}
return const ClockInValidationResult.valid();
}
}

View File

@@ -0,0 +1,35 @@
import 'clock_in_validation_context.dart';
import 'clock_in_validation_result.dart';
import 'clock_in_validator.dart';
/// Validates that geofence requirements are satisfied before clock-in.
///
/// Only applies when checking in to a shift that has venue coordinates.
/// If the shift has no coordinates or this is a clock-out, validation passes.
///
/// Logic extracted from [ClockInBloc._onCheckIn]:
/// - If the shift requires location verification but the geofence has not
/// confirmed proximity, has not timed out, and the worker has not
/// explicitly overridden via the justification modal, the attempt is rejected.
class GeofenceValidator implements ClockInValidator {
/// Creates a [GeofenceValidator].
const GeofenceValidator();
/// Returns invalid when clocking in to a location-based shift without
/// verified location, timeout, or explicit override.
@override
ClockInValidationResult validate(ClockInValidationContext context) {
// Only applies to clock-in for shifts with coordinates.
if (!context.isCheckingIn || !context.hasCoordinates) {
return const ClockInValidationResult.valid();
}
if (!context.isLocationVerified &&
!context.isLocationTimedOut &&
!context.isGeofenceOverridden) {
return const ClockInValidationResult.invalid('geofence_not_verified');
}
return const ClockInValidationResult.valid();
}
}

View File

@@ -0,0 +1,35 @@
import 'clock_in_validation_context.dart';
import 'clock_in_validation_result.dart';
import 'clock_in_validator.dart';
/// Validates that override notes are provided when required.
///
/// When the location check timed out or the geofence was explicitly overridden,
/// the worker must supply non-empty notes explaining why they are clocking in
/// without verified proximity.
///
/// Logic extracted from [ClockInBloc._onCheckIn] notes check.
class OverrideNotesValidator implements ClockInValidator {
/// Creates an [OverrideNotesValidator].
const OverrideNotesValidator();
/// Returns invalid if notes are required but missing or empty.
@override
ClockInValidationResult validate(ClockInValidationContext context) {
// Only applies to clock-in attempts.
if (!context.isCheckingIn) {
return const ClockInValidationResult.valid();
}
final bool notesRequired =
context.isLocationTimedOut || context.isGeofenceOverridden;
if (notesRequired &&
(context.overrideNotes == null ||
context.overrideNotes!.trim().isEmpty)) {
return const ClockInValidationResult.invalid('notes_required');
}
return const ClockInValidationResult.valid();
}
}

View File

@@ -0,0 +1,77 @@
import 'package:intl/intl.dart';
import 'clock_in_validation_context.dart';
import 'clock_in_validation_result.dart';
import 'clock_in_validator.dart';
/// Validates that the current time falls within the allowed window.
///
/// - For clock-in: the current time must be at most 15 minutes before the
/// shift start time.
/// - For clock-out: the current time must be at most 15 minutes before the
/// shift end time.
/// - If the relevant shift time is `null`, validation passes (don't block
/// when the time is unknown).
class TimeWindowValidator implements ClockInValidator {
/// Creates a [TimeWindowValidator].
const TimeWindowValidator();
/// The number of minutes before the shift time that the action is allowed.
static const int _earlyWindowMinutes = 15;
/// Returns invalid if the current time is too early for the action.
@override
ClockInValidationResult validate(ClockInValidationContext context) {
if (context.isCheckingIn) {
return _validateClockIn(context);
}
return _validateClockOut(context);
}
/// Validates the clock-in time window against [shiftStartTime].
ClockInValidationResult _validateClockIn(ClockInValidationContext context) {
final DateTime? shiftStart = context.shiftStartTime;
if (shiftStart == null) {
return const ClockInValidationResult.valid();
}
final DateTime windowStart = shiftStart.subtract(
const Duration(minutes: _earlyWindowMinutes),
);
if (DateTime.now().isBefore(windowStart)) {
return const ClockInValidationResult.invalid('too_early_clock_in');
}
return const ClockInValidationResult.valid();
}
/// Validates the clock-out time window against [shiftEndTime].
ClockInValidationResult _validateClockOut(ClockInValidationContext context) {
final DateTime? shiftEnd = context.shiftEndTime;
if (shiftEnd == null) {
return const ClockInValidationResult.valid();
}
final DateTime windowStart = shiftEnd.subtract(
const Duration(minutes: _earlyWindowMinutes),
);
if (DateTime.now().isBefore(windowStart)) {
return const ClockInValidationResult.invalid('too_early_clock_out');
}
return const ClockInValidationResult.valid();
}
/// Returns the formatted earliest allowed time for the given [shiftTime].
///
/// The result is a 12-hour string such as "8:45 AM". Presentation code
/// can call this directly without depending on Flutter's [BuildContext].
static String getAvailabilityTime(DateTime shiftTime) {
final DateTime windowStart = shiftTime.subtract(
const Duration(minutes: _earlyWindowMinutes),
);
return DateFormat('h:mm a').format(windowStart);
}
}

View File

@@ -8,25 +8,39 @@ import '../../../domain/usecases/clock_in_usecase.dart';
import '../../../domain/usecases/clock_out_usecase.dart'; import '../../../domain/usecases/clock_out_usecase.dart';
import '../../../domain/usecases/get_attendance_status_usecase.dart'; import '../../../domain/usecases/get_attendance_status_usecase.dart';
import '../../../domain/usecases/get_todays_shift_usecase.dart'; import '../../../domain/usecases/get_todays_shift_usecase.dart';
import '../../../domain/validators/clock_in_validation_context.dart';
import '../../../domain/validators/clock_in_validation_result.dart';
import '../../../domain/validators/composite_clock_in_validator.dart';
import '../geofence/geofence_bloc.dart';
import '../geofence/geofence_event.dart';
import '../geofence/geofence_state.dart';
import 'clock_in_event.dart'; import 'clock_in_event.dart';
import 'clock_in_state.dart'; import 'clock_in_state.dart';
/// BLoC responsible for clock-in/clock-out operations and shift management. /// BLoC responsible for clock-in/clock-out operations and shift management.
/// ///
/// Location and geofence concerns are delegated to [GeofenceBloc]. /// Reads [GeofenceBloc] state directly to evaluate geofence conditions,
/// The UI bridges geofence state into [CheckInRequested] event parameters. /// removing the need for the UI to bridge geofence fields into events.
/// Validation is delegated to [CompositeClockInValidator].
/// Background tracking lifecycle is managed here after successful
/// clock-in/clock-out, rather than in the UI layer.
class ClockInBloc extends Bloc<ClockInEvent, ClockInState> class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
with BlocErrorHandler<ClockInState> { with BlocErrorHandler<ClockInState> {
/// Creates a [ClockInBloc] with the required use cases. /// Creates a [ClockInBloc] with the required use cases, geofence BLoC,
/// and validator.
ClockInBloc({ ClockInBloc({
required GetTodaysShiftUseCase getTodaysShift, required GetTodaysShiftUseCase getTodaysShift,
required GetAttendanceStatusUseCase getAttendanceStatus, required GetAttendanceStatusUseCase getAttendanceStatus,
required ClockInUseCase clockIn, required ClockInUseCase clockIn,
required ClockOutUseCase clockOut, required ClockOutUseCase clockOut,
required GeofenceBloc geofenceBloc,
required CompositeClockInValidator validator,
}) : _getTodaysShift = getTodaysShift, }) : _getTodaysShift = getTodaysShift,
_getAttendanceStatus = getAttendanceStatus, _getAttendanceStatus = getAttendanceStatus,
_clockIn = clockIn, _clockIn = clockIn,
_clockOut = clockOut, _clockOut = clockOut,
_geofenceBloc = geofenceBloc,
_validator = validator,
super(ClockInState(selectedDate: DateTime.now())) { super(ClockInState(selectedDate: DateTime.now())) {
on<ClockInPageLoaded>(_onLoaded); on<ClockInPageLoaded>(_onLoaded);
on<ShiftSelected>(_onShiftSelected); on<ShiftSelected>(_onShiftSelected);
@@ -43,6 +57,12 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
final ClockInUseCase _clockIn; final ClockInUseCase _clockIn;
final ClockOutUseCase _clockOut; final ClockOutUseCase _clockOut;
/// Reference to [GeofenceBloc] for reading geofence state directly.
final GeofenceBloc _geofenceBloc;
/// Composite validator for clock-in preconditions.
final CompositeClockInValidator _validator;
/// 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,
@@ -106,41 +126,38 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
/// Handles a clock-in request. /// Handles a clock-in request.
/// ///
/// Geofence state is passed via event parameters from the UI layer: /// Reads geofence state directly from [_geofenceBloc] and builds a
/// - If the shift has a venue (lat/lng) and location is neither verified /// [ClockInValidationContext] to run through the [_validator] pipeline.
/// nor timed out, the clock-in is rejected. /// On success, dispatches [BackgroundTrackingStarted] to [_geofenceBloc].
/// - If the location timed out, notes are required to proceed.
/// - Otherwise the clock-in proceeds normally.
Future<void> _onCheckIn( Future<void> _onCheckIn(
CheckInRequested event, CheckInRequested event,
Emitter<ClockInState> emit, Emitter<ClockInState> emit,
) async { ) async {
final Shift? shift = state.selectedShift; final Shift? shift = state.selectedShift;
final bool shiftHasLocation = final GeofenceState geofenceState = _geofenceBloc.state;
final bool hasCoordinates =
shift != null && shift.latitude != null && shift.longitude != null; shift != null && shift.latitude != null && shift.longitude != null;
// If the shift requires location verification but geofence has not // Build validation context from combined BLoC states.
// confirmed proximity, has not timed out, and the worker has not final ClockInValidationContext validationContext = ClockInValidationContext(
// explicitly overridden via the justification modal, reject the attempt. isCheckingIn: true,
if (shiftHasLocation && shiftStartTime: _tryParseDateTime(shift?.startTime),
!event.isLocationVerified && shiftEndTime: _tryParseDateTime(shift?.endTime),
!event.isLocationTimedOut && hasCoordinates: hasCoordinates,
!event.isGeofenceOverridden) { isLocationVerified: geofenceState.isLocationVerified,
emit(state.copyWith( isLocationTimedOut: geofenceState.isLocationTimedOut,
status: ClockInStatus.failure, isGeofenceOverridden: geofenceState.isGeofenceOverridden,
errorMessage: 'errors.clock_in.location_verification_required', overrideNotes: event.notes,
)); );
return;
}
// When location timed out or geofence is overridden, require the user to final ClockInValidationResult validationResult =
// provide notes explaining why they are clocking in without verified _validator.validate(validationContext);
// proximity.
if ((event.isLocationTimedOut || event.isGeofenceOverridden) && if (!validationResult.isValid) {
(event.notes == null || event.notes!.trim().isEmpty)) {
emit(state.copyWith( emit(state.copyWith(
status: ClockInStatus.failure, status: ClockInStatus.failure,
errorMessage: 'errors.clock_in.notes_required_for_timeout', errorMessage: validationResult.errorKey,
)); ));
return; return;
} }
@@ -156,6 +173,12 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
status: ClockInStatus.success, status: ClockInStatus.success,
attendance: newStatus, attendance: newStatus,
)); ));
// Start background tracking after successful clock-in.
_dispatchBackgroundTrackingStarted(
event: event,
activeShiftId: newStatus.activeShiftId,
);
}, },
onError: (String errorKey) => state.copyWith( onError: (String errorKey) => state.copyWith(
status: ClockInStatus.failure, status: ClockInStatus.failure,
@@ -165,6 +188,8 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
} }
/// Handles a clock-out request. /// Handles a clock-out request.
///
/// On success, dispatches [BackgroundTrackingStopped] to [_geofenceBloc].
Future<void> _onCheckOut( Future<void> _onCheckOut(
CheckOutRequested event, CheckOutRequested event,
Emitter<ClockInState> emit, Emitter<ClockInState> emit,
@@ -184,6 +209,14 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
status: ClockInStatus.success, status: ClockInStatus.success,
attendance: newStatus, attendance: newStatus,
)); ));
// Stop background tracking after successful clock-out.
_geofenceBloc.add(
BackgroundTrackingStopped(
clockOutTitle: event.clockOutTitle,
clockOutBody: event.clockOutBody,
),
);
}, },
onError: (String errorKey) => state.copyWith( onError: (String errorKey) => state.copyWith(
status: ClockInStatus.failure, status: ClockInStatus.failure,
@@ -191,4 +224,33 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
), ),
); );
} }
/// Safely parses a time string into a [DateTime], returning `null` on failure.
static DateTime? _tryParseDateTime(String? value) {
if (value == null || value.isEmpty) return null;
return DateTime.tryParse(value);
}
/// Dispatches [BackgroundTrackingStarted] to [_geofenceBloc] if the
/// geofence has target coordinates.
void _dispatchBackgroundTrackingStarted({
required CheckInRequested event,
required String? activeShiftId,
}) {
final GeofenceState geofenceState = _geofenceBloc.state;
if (geofenceState.targetLat != null &&
geofenceState.targetLng != null &&
activeShiftId != null) {
_geofenceBloc.add(
BackgroundTrackingStarted(
shiftId: activeShiftId,
targetLat: geofenceState.targetLat!,
targetLng: geofenceState.targetLng!,
greetingTitle: event.clockInGreetingTitle,
greetingBody: event.clockInGreetingBody,
),
);
}
}
} }

View File

@@ -36,45 +36,46 @@ class DateSelected extends ClockInEvent {
/// Emitted when the user requests to clock in. /// Emitted when the user requests to clock in.
/// ///
/// [isLocationVerified] and [isLocationTimedOut] are provided by the UI layer /// Geofence state is read directly by the BLoC from [GeofenceBloc],
/// from the GeofenceBloc state, bridging the two BLoCs. /// so this event only carries the shift ID, optional notes, and
/// notification strings for background tracking.
class CheckInRequested extends ClockInEvent { class CheckInRequested extends ClockInEvent {
const CheckInRequested({ const CheckInRequested({
required this.shiftId, required this.shiftId,
this.notes, this.notes,
this.isLocationVerified = false, this.clockInGreetingTitle = '',
this.isLocationTimedOut = false, this.clockInGreetingBody = '',
this.isGeofenceOverridden = false,
}); });
/// The ID of the shift to clock into. /// The ID of the shift to clock into.
final String shiftId; final String shiftId;
/// Optional notes provided by the user. /// Optional notes provided by the user (e.g. geofence override notes).
final String? notes; final String? notes;
/// Whether the geofence verification passed (user is within radius). /// Localized title for the clock-in greeting notification.
final bool isLocationVerified; final String clockInGreetingTitle;
/// Whether the geofence verification timed out (GPS unavailable). /// Localized body for the clock-in greeting notification.
final bool isLocationTimedOut; final String clockInGreetingBody;
/// Whether the worker explicitly overrode geofence via the justification modal.
final bool isGeofenceOverridden;
@override @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
shiftId, shiftId,
notes, notes,
isLocationVerified, clockInGreetingTitle,
isLocationTimedOut, clockInGreetingBody,
isGeofenceOverridden,
]; ];
} }
/// Emitted when the user requests to clock out. /// Emitted when the user requests to clock out.
class CheckOutRequested extends ClockInEvent { class CheckOutRequested extends ClockInEvent {
const CheckOutRequested({this.notes, this.breakTimeMinutes}); const CheckOutRequested({
this.notes,
this.breakTimeMinutes,
this.clockOutTitle = '',
this.clockOutBody = '',
});
/// Optional notes provided by the user. /// Optional notes provided by the user.
final String? notes; final String? notes;
@@ -82,8 +83,19 @@ class CheckOutRequested extends ClockInEvent {
/// Break time taken during the shift, in minutes. /// Break time taken during the shift, in minutes.
final int? breakTimeMinutes; final int? breakTimeMinutes;
/// Localized title for the clock-out notification.
final String clockOutTitle;
/// Localized body for the clock-out notification.
final String clockOutBody;
@override @override
List<Object?> get props => <Object?>[notes, breakTimeMinutes]; List<Object?> get props => <Object?>[
notes,
breakTimeMinutes,
clockOutTitle,
clockOutBody,
];
} }
/// Emitted when the user changes the check-in mode (e.g. swipe vs tap). /// Emitted when the user changes the check-in mode (e.g. swipe vs tap).

View File

@@ -5,6 +5,7 @@ import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import '../../../data/services/background_geofence_service.dart'; import '../../../data/services/background_geofence_service.dart';
import '../../../data/services/clock_in_notification_service.dart';
import '../../../domain/models/geofence_result.dart'; import '../../../domain/models/geofence_result.dart';
import '../../../domain/services/geofence_service_interface.dart'; import '../../../domain/services/geofence_service_interface.dart';
import 'geofence_event.dart'; import 'geofence_event.dart';
@@ -23,8 +24,10 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
GeofenceBloc({ GeofenceBloc({
required GeofenceServiceInterface geofenceService, required GeofenceServiceInterface geofenceService,
required BackgroundGeofenceService backgroundGeofenceService, required BackgroundGeofenceService backgroundGeofenceService,
required ClockInNotificationService notificationService,
}) : _geofenceService = geofenceService, }) : _geofenceService = geofenceService,
_backgroundGeofenceService = backgroundGeofenceService, _backgroundGeofenceService = backgroundGeofenceService,
_notificationService = notificationService,
super(const GeofenceState.initial()) { super(const GeofenceState.initial()) {
on<GeofenceStarted>(_onStarted); on<GeofenceStarted>(_onStarted);
on<GeofenceResultUpdated>(_onResultUpdated); on<GeofenceResultUpdated>(_onResultUpdated);
@@ -42,6 +45,9 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
/// The background service for periodic tracking while clocked in. /// The background service for periodic tracking while clocked in.
final BackgroundGeofenceService _backgroundGeofenceService; final BackgroundGeofenceService _backgroundGeofenceService;
/// The notification service for clock-in related notifications.
final ClockInNotificationService _notificationService;
/// Active subscription to the foreground geofence location stream. /// Active subscription to the foreground geofence location stream.
StreamSubscription<GeofenceResult>? _geofenceSubscription; StreamSubscription<GeofenceResult>? _geofenceSubscription;
@@ -64,7 +70,7 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
emit: emit.call, emit: emit.call,
action: () async { action: () async {
// Check permission first. // Check permission first.
final permission = await _geofenceService.ensurePermission(); final LocationPermissionStatus permission = await _geofenceService.ensurePermission();
emit(state.copyWith(permissionStatus: permission)); emit(state.copyWith(permissionStatus: permission));
if (permission == LocationPermissionStatus.denied || if (permission == LocationPermissionStatus.denied ||
@@ -81,12 +87,12 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
// Start monitoring location service status changes. // Start monitoring location service status changes.
await _serviceStatusSubscription?.cancel(); await _serviceStatusSubscription?.cancel();
_serviceStatusSubscription = _serviceStatusSubscription =
_geofenceService.watchServiceStatus().listen((isEnabled) { _geofenceService.watchServiceStatus().listen((bool isEnabled) {
add(GeofenceServiceStatusChanged(isEnabled)); add(GeofenceServiceStatusChanged(isEnabled));
}); });
// Get initial position with a 30s timeout. // Get initial position with a 30s timeout.
final result = await _geofenceService.checkGeofenceWithTimeout( final GeofenceResult? result = await _geofenceService.checkGeofenceWithTimeout(
targetLat: event.targetLat, targetLat: event.targetLat,
targetLng: event.targetLng, targetLng: event.targetLng,
); );
@@ -105,7 +111,7 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
targetLng: event.targetLng, targetLng: event.targetLng,
) )
.listen( .listen(
(result) => add(GeofenceResultUpdated(result)), (GeofenceResult result) => add(GeofenceResultUpdated(result)),
); );
}, },
onError: (String errorKey) => state.copyWith( onError: (String errorKey) => state.copyWith(
@@ -172,7 +178,7 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
await handleError( await handleError(
emit: emit.call, emit: emit.call,
action: () async { action: () async {
final result = await _geofenceService.checkGeofenceWithTimeout( final GeofenceResult? result = await _geofenceService.checkGeofenceWithTimeout(
targetLat: state.targetLat!, targetLat: state.targetLat!,
targetLng: state.targetLng!, targetLng: state.targetLng!,
); );
@@ -199,7 +205,7 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
emit: emit.call, emit: emit.call,
action: () async { action: () async {
// Request upgrade to "Always" permission for background tracking. // Request upgrade to "Always" permission for background tracking.
final permission = await _geofenceService.requestAlwaysPermission(); final LocationPermissionStatus permission = await _geofenceService.requestAlwaysPermission();
emit(state.copyWith(permissionStatus: permission)); emit(state.copyWith(permissionStatus: permission));
// Start background tracking regardless (degrades gracefully). // Start background tracking regardless (degrades gracefully).
@@ -210,7 +216,7 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
); );
// Show greeting notification using localized strings from the UI. // Show greeting notification using localized strings from the UI.
await _backgroundGeofenceService.showClockInGreetingNotification( await _notificationService.showClockInGreeting(
title: event.greetingTitle, title: event.greetingTitle,
body: event.greetingBody, body: event.greetingBody,
); );
@@ -235,7 +241,7 @@ class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
await _backgroundGeofenceService.stopBackgroundTracking(); await _backgroundGeofenceService.stopBackgroundTracking();
// Show clock-out notification using localized strings from the UI. // Show clock-out notification using localized strings from the UI.
await _backgroundGeofenceService.showClockOutNotification( await _notificationService.showClockOutNotification(
title: event.clockOutTitle, title: event.clockOutTitle,
body: event.clockOutBody, body: event.clockOutBody,
); );

View File

@@ -13,27 +13,27 @@ abstract class GeofenceEvent extends Equatable {
/// Starts foreground geofence verification for a target location. /// Starts foreground geofence verification for a target location.
class GeofenceStarted extends GeofenceEvent { class GeofenceStarted extends GeofenceEvent {
/// Creates a [GeofenceStarted] event.
const GeofenceStarted({required this.targetLat, required this.targetLng});
/// Target latitude of the shift location. /// Target latitude of the shift location.
final double targetLat; final double targetLat;
/// Target longitude of the shift location. /// Target longitude of the shift location.
final double targetLng; final double targetLng;
/// Creates a [GeofenceStarted] event.
const GeofenceStarted({required this.targetLat, required this.targetLng});
@override @override
List<Object?> get props => <Object?>[targetLat, targetLng]; List<Object?> get props => <Object?>[targetLat, targetLng];
} }
/// Emitted when a new geofence result is received from the location stream. /// Emitted when a new geofence result is received from the location stream.
class GeofenceResultUpdated extends GeofenceEvent { class GeofenceResultUpdated extends GeofenceEvent {
/// The latest geofence check result.
final GeofenceResult result;
/// Creates a [GeofenceResultUpdated] event. /// Creates a [GeofenceResultUpdated] event.
const GeofenceResultUpdated(this.result); const GeofenceResultUpdated(this.result);
/// The latest geofence check result.
final GeofenceResult result;
@override @override
List<Object?> get props => <Object?>[result]; List<Object?> get props => <Object?>[result];
} }
@@ -46,12 +46,12 @@ class GeofenceTimeoutReached extends GeofenceEvent {
/// Emitted when the device location service status changes. /// Emitted when the device location service status changes.
class GeofenceServiceStatusChanged extends GeofenceEvent { class GeofenceServiceStatusChanged extends GeofenceEvent {
/// Whether location services are now enabled.
final bool isEnabled;
/// Creates a [GeofenceServiceStatusChanged] event. /// Creates a [GeofenceServiceStatusChanged] event.
const GeofenceServiceStatusChanged(this.isEnabled); const GeofenceServiceStatusChanged(this.isEnabled);
/// Whether location services are now enabled.
final bool isEnabled;
@override @override
List<Object?> get props => <Object?>[isEnabled]; List<Object?> get props => <Object?>[isEnabled];
} }
@@ -64,6 +64,15 @@ class GeofenceRetryRequested extends GeofenceEvent {
/// Starts background tracking after successful clock-in. /// Starts background tracking after successful clock-in.
class BackgroundTrackingStarted extends GeofenceEvent { class BackgroundTrackingStarted extends GeofenceEvent {
/// Creates a [BackgroundTrackingStarted] event.
const BackgroundTrackingStarted({
required this.shiftId,
required this.targetLat,
required this.targetLng,
required this.greetingTitle,
required this.greetingBody,
});
/// The shift ID being tracked. /// The shift ID being tracked.
final String shiftId; final String shiftId;
@@ -79,15 +88,6 @@ class BackgroundTrackingStarted extends GeofenceEvent {
/// Localized greeting notification body passed from the UI layer. /// Localized greeting notification body passed from the UI layer.
final String greetingBody; final String greetingBody;
/// Creates a [BackgroundTrackingStarted] event.
const BackgroundTrackingStarted({
required this.shiftId,
required this.targetLat,
required this.targetLng,
required this.greetingTitle,
required this.greetingBody,
});
@override @override
List<Object?> get props => List<Object?> get props =>
<Object?>[shiftId, targetLat, targetLng, greetingTitle, greetingBody]; <Object?>[shiftId, targetLat, targetLng, greetingTitle, greetingBody];
@@ -95,30 +95,30 @@ class BackgroundTrackingStarted extends GeofenceEvent {
/// Stops background tracking after clock-out. /// Stops background tracking after clock-out.
class BackgroundTrackingStopped extends GeofenceEvent { class BackgroundTrackingStopped extends GeofenceEvent {
/// Localized clock-out notification title passed from the UI layer.
final String clockOutTitle;
/// Localized clock-out notification body passed from the UI layer.
final String clockOutBody;
/// Creates a [BackgroundTrackingStopped] event. /// Creates a [BackgroundTrackingStopped] event.
const BackgroundTrackingStopped({ const BackgroundTrackingStopped({
required this.clockOutTitle, required this.clockOutTitle,
required this.clockOutBody, required this.clockOutBody,
}); });
/// Localized clock-out notification title passed from the UI layer.
final String clockOutTitle;
/// Localized clock-out notification body passed from the UI layer.
final String clockOutBody;
@override @override
List<Object?> get props => <Object?>[clockOutTitle, clockOutBody]; List<Object?> get props => <Object?>[clockOutTitle, clockOutBody];
} }
/// Worker approved geofence override by providing justification notes. /// Worker approved geofence override by providing justification notes.
class GeofenceOverrideApproved extends GeofenceEvent { class GeofenceOverrideApproved extends GeofenceEvent {
/// The justification notes provided by the worker.
final String notes;
/// Creates a [GeofenceOverrideApproved] event. /// Creates a [GeofenceOverrideApproved] event.
const GeofenceOverrideApproved({required this.notes}); const GeofenceOverrideApproved({required this.notes});
/// The justification notes provided by the worker.
final String notes;
@override @override
List<Object?> get props => <Object?>[notes]; List<Object?> get props => <Object?>[notes];
} }

View File

@@ -3,7 +3,6 @@ import 'package:krow_domain/krow_domain.dart';
/// State for the [GeofenceBloc]. /// State for the [GeofenceBloc].
class GeofenceState extends Equatable { class GeofenceState extends Equatable {
/// Creates a [GeofenceState] instance. /// Creates a [GeofenceState] instance.
const GeofenceState({ const GeofenceState({
this.permissionStatus, this.permissionStatus,
@@ -19,6 +18,10 @@ class GeofenceState extends Equatable {
this.targetLat, this.targetLat,
this.targetLng, this.targetLng,
}); });
/// Initial state before any geofence operations.
const GeofenceState.initial() : this();
/// Current location permission status. /// Current location permission status.
final LocationPermissionStatus? permissionStatus; final LocationPermissionStatus? permissionStatus;
@@ -55,9 +58,6 @@ class GeofenceState extends Equatable {
/// Target longitude being monitored. /// Target longitude being monitored.
final double? targetLng; final double? targetLng;
/// Initial state before any geofence operations.
const GeofenceState.initial() : this();
/// Creates a copy with the given fields replaced. /// Creates a copy with the given fields replaced.
GeofenceState copyWith({ GeofenceState copyWith({
LocationPermissionStatus? permissionStatus, LocationPermissionStatus? permissionStatus,

View File

@@ -29,8 +29,12 @@ class ClockInPage extends StatelessWidget {
appBar: UiAppBar(title: i18n.title, showBackButton: false), appBar: UiAppBar(title: i18n.title, showBackButton: false),
body: MultiBlocProvider( body: MultiBlocProvider(
providers: <BlocProvider<dynamic>>[ providers: <BlocProvider<dynamic>>[
BlocProvider<ClockInBloc>.value(value: Modular.get<ClockInBloc>()), BlocProvider<GeofenceBloc>.value(
BlocProvider<GeofenceBloc>.value(value: Modular.get<GeofenceBloc>()), value: Modular.get<GeofenceBloc>(),
),
BlocProvider<ClockInBloc>.value(
value: Modular.get<ClockInBloc>(),
),
], ],
child: BlocListener<ClockInBloc, ClockInState>( child: BlocListener<ClockInBloc, ClockInState>(
listenWhen: (ClockInState previous, ClockInState current) => listenWhen: (ClockInState previous, ClockInState current) =>

View File

@@ -0,0 +1,22 @@
import 'package:flutter/widgets.dart';
/// Interface for different clock-in/out interaction methods (swipe, NFC, etc.).
///
/// Each implementation encapsulates the UI and behavior for a specific
/// check-in mode, allowing the action section to remain mode-agnostic.
abstract class CheckInInteraction {
/// Unique identifier for this interaction mode (e.g. "swipe", "nfc").
String get mode;
/// Builds the action widget for this interaction method.
///
/// The returned widget handles user interaction (swipe gesture, NFC tap,
/// etc.) and invokes [onCheckIn] or [onCheckOut] when the action completes.
Widget buildActionWidget({
required bool isCheckedIn,
required bool isDisabled,
required bool isLoading,
required VoidCallback onCheckIn,
required VoidCallback onCheckOut,
});
}

View File

@@ -0,0 +1,118 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../widgets/nfc_scan_dialog.dart';
import 'check_in_interaction.dart';
/// NFC-based check-in interaction that shows a tap button and scan dialog.
///
/// When tapped, presents the [showNfcScanDialog] and triggers [onCheckIn]
/// or [onCheckOut] upon a successful scan.
class NfcCheckInInteraction implements CheckInInteraction {
/// Creates an NFC check-in interaction.
const NfcCheckInInteraction();
@override
String get mode => 'nfc';
@override
Widget buildActionWidget({
required bool isCheckedIn,
required bool isDisabled,
required bool isLoading,
required VoidCallback onCheckIn,
required VoidCallback onCheckOut,
}) {
return _NfcCheckInButton(
isCheckedIn: isCheckedIn,
isDisabled: isDisabled,
isLoading: isLoading,
onCheckIn: onCheckIn,
onCheckOut: onCheckOut,
);
}
}
/// Tap button that launches the NFC scan dialog and triggers check-in/out.
class _NfcCheckInButton extends StatelessWidget {
const _NfcCheckInButton({
required this.isCheckedIn,
required this.isDisabled,
required this.isLoading,
required this.onCheckIn,
required this.onCheckOut,
});
/// Whether the user is currently checked in.
final bool isCheckedIn;
/// Whether the button should be disabled (e.g. geofence blocking).
final bool isDisabled;
/// Whether a check-in/out action is in progress.
final bool isLoading;
/// Called after a successful NFC scan when checking in.
final VoidCallback onCheckIn;
/// Called after a successful NFC scan when checking out.
final VoidCallback onCheckOut;
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInSwipeEn i18n =
Translations.of(context).staff.clock_in.swipe;
final Color baseColor = isCheckedIn ? UiColors.success : UiColors.primary;
return GestureDetector(
onTap: () => _handleTap(context),
child: Container(
height: 56,
decoration: BoxDecoration(
color: isDisabled ? UiColors.bgSecondary : baseColor,
borderRadius: UiConstants.radiusLg,
boxShadow: isDisabled
? <BoxShadow>[]
: <BoxShadow>[
BoxShadow(
color: baseColor.withValues(alpha: 0.4),
blurRadius: 25,
offset: const Offset(0, 10),
spreadRadius: -5,
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(UiIcons.wifi, color: UiColors.white),
const SizedBox(width: UiConstants.space3),
Text(
isLoading
? (isCheckedIn ? i18n.checking_out : i18n.checking_in)
: (isCheckedIn ? i18n.nfc_checkout : i18n.nfc_checkin),
style: UiTypography.body1b.copyWith(
color: isDisabled ? UiColors.textDisabled : UiColors.white,
),
),
],
),
),
);
}
/// Opens the NFC scan dialog and triggers the appropriate callback on success.
Future<void> _handleTap(BuildContext context) async {
if (isLoading || isDisabled) return;
final bool scanned = await showNfcScanDialog(context);
if (scanned && context.mounted) {
if (isCheckedIn) {
onCheckOut();
} else {
onCheckIn();
}
}
}
}

View File

@@ -0,0 +1,30 @@
import 'package:flutter/widgets.dart';
import '../widgets/swipe_to_check_in.dart';
import 'check_in_interaction.dart';
/// Swipe-based check-in interaction using the [SwipeToCheckIn] slider widget.
class SwipeCheckInInteraction implements CheckInInteraction {
/// Creates a swipe check-in interaction.
const SwipeCheckInInteraction();
@override
String get mode => 'swipe';
@override
Widget buildActionWidget({
required bool isCheckedIn,
required bool isDisabled,
required bool isLoading,
required VoidCallback onCheckIn,
required VoidCallback onCheckOut,
}) {
return SwipeToCheckIn(
isCheckedIn: isCheckedIn,
isDisabled: isDisabled,
isLoading: isLoading,
onCheckIn: onCheckIn,
onCheckOut: onCheckOut,
);
}
}

View File

@@ -8,24 +8,25 @@ import 'package:krow_domain/krow_domain.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_event.dart';
import '../bloc/clock_in/clock_in_state.dart';
import '../bloc/geofence/geofence_bloc.dart'; import '../bloc/geofence/geofence_bloc.dart';
import '../bloc/geofence/geofence_event.dart';
import '../bloc/geofence/geofence_state.dart'; import '../bloc/geofence/geofence_state.dart';
import 'clock_in_helpers.dart'; import '../../domain/validators/clock_in_validation_context.dart';
import '../../domain/validators/time_window_validator.dart';
import '../strategies/check_in_interaction.dart';
import '../strategies/nfc_check_in_interaction.dart';
import '../strategies/swipe_check_in_interaction.dart';
import 'early_check_in_banner.dart'; import 'early_check_in_banner.dart';
import 'geofence_status_banner/geofence_status_banner.dart'; import 'geofence_status_banner/geofence_status_banner.dart';
import 'lunch_break_modal.dart'; import 'lunch_break_modal.dart';
import 'nfc_scan_dialog.dart';
import 'no_shifts_banner.dart'; import 'no_shifts_banner.dart';
import 'shift_completed_banner.dart'; import 'shift_completed_banner.dart';
import 'swipe_to_check_in.dart';
/// Orchestrates which action widget is displayed based on the current state. /// Orchestrates which action widget is displayed based on the current state.
/// ///
/// Decides between the swipe-to-check-in slider, the early-arrival banner, /// Uses the [CheckInInteraction] strategy pattern to delegate the actual
/// the shift-completed banner, or the no-shifts placeholder. Also shows the /// check-in/out UI to mode-specific implementations (swipe, NFC, etc.).
/// [GeofenceStatusBanner] and manages background tracking lifecycle. /// Also shows the [GeofenceStatusBanner]. Background tracking lifecycle
/// is managed by [ClockInBloc], not this widget.
class ClockInActionSection extends StatelessWidget { class ClockInActionSection extends StatelessWidget {
/// Creates the action section. /// Creates the action section.
const ClockInActionSection({ const ClockInActionSection({
@@ -37,6 +38,13 @@ class ClockInActionSection extends StatelessWidget {
super.key, super.key,
}); });
/// Available check-in interaction strategies keyed by mode identifier.
static const Map<String, CheckInInteraction> _interactions =
<String, CheckInInteraction>{
'swipe': SwipeCheckInInteraction(),
'nfc': NfcCheckInInteraction(),
};
/// The currently selected shift, or null if none is selected. /// The currently selected shift, or null if none is selected.
final Shift? selectedShift; final Shift? selectedShift;
@@ -52,46 +60,14 @@ class ClockInActionSection extends StatelessWidget {
/// Whether a check-in or check-out action is currently in progress. /// Whether a check-in or check-out action is currently in progress.
final bool isActionInProgress; final bool isActionInProgress;
/// Resolves the [CheckInInteraction] for the current mode.
///
/// Falls back to [SwipeCheckInInteraction] if the mode is unrecognized.
CheckInInteraction get _currentInteraction =>
_interactions[checkInMode] ?? const SwipeCheckInInteraction();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiBlocListener(
listeners: <BlocListener<dynamic, dynamic>>[
// Start background tracking after successful check-in.
BlocListener<ClockInBloc, ClockInState>(
listenWhen: (ClockInState previous, ClockInState current) {
return previous.status == ClockInStatus.actionInProgress &&
current.status == ClockInStatus.success &&
current.attendance.isCheckedIn &&
!previous.attendance.isCheckedIn;
},
listener: (BuildContext context, ClockInState state) {
_startBackgroundTracking(context, state);
},
),
// Stop background tracking after clock-out.
BlocListener<ClockInBloc, ClockInState>(
listenWhen: (ClockInState previous, ClockInState current) =>
previous.attendance.isCheckedIn &&
!current.attendance.isCheckedIn,
listener: (BuildContext context, ClockInState _) {
final TranslationsStaffClockInGeofenceEn geofenceI18n =
Translations.of(context).staff.clock_in.geofence;
ReadContext(context).read<GeofenceBloc>().add(
BackgroundTrackingStopped(
clockOutTitle: geofenceI18n.clock_out_title,
clockOutBody: geofenceI18n.clock_out_body,
),
);
},
),
],
child: _buildContent(context),
);
}
/// Builds the main content column with geofence banner and action widget.
Widget _buildContent(BuildContext context) {
if (selectedShift != null && checkOutTime == null) { if (selectedShift != null && checkOutTime == null) {
return _buildActiveShiftAction(context); return _buildActiveShiftAction(context);
} }
@@ -105,14 +81,14 @@ class ClockInActionSection extends StatelessWidget {
/// Builds the action widget for an active (not completed) shift. /// Builds the action widget for an active (not completed) shift.
Widget _buildActiveShiftAction(BuildContext context) { Widget _buildActiveShiftAction(BuildContext context) {
if (!isCheckedIn && !ClockInHelpers.isCheckInAllowed(selectedShift!)) { if (!isCheckedIn && !_isCheckInAllowed(selectedShift!)) {
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
const GeofenceStatusBanner(), const GeofenceStatusBanner(),
const SizedBox(height: UiConstants.space3), const SizedBox(height: UiConstants.space3),
EarlyCheckInBanner( EarlyCheckInBanner(
availabilityTime: ClockInHelpers.getCheckInAvailabilityTime( availabilityTime: _getAvailabilityTimeText(
selectedShift!, selectedShift!,
context, context,
), ),
@@ -138,11 +114,9 @@ class ClockInActionSection extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
spacing: UiConstants.space4, spacing: UiConstants.space4,
children: <Widget>[ children: <Widget>[
// Geofence status banner is shown even when not blocking to provide feedback
const GeofenceStatusBanner(), const GeofenceStatusBanner(),
SwipeToCheckIn( _currentInteraction.buildActionWidget(
isCheckedIn: isCheckedIn, isCheckedIn: isCheckedIn,
mode: checkInMode,
isDisabled: isGeofenceBlocking, isDisabled: isGeofenceBlocking,
isLoading: isActionInProgress, isLoading: isActionInProgress,
onCheckIn: () => _handleCheckIn(context), onCheckIn: () => _handleCheckIn(context),
@@ -154,76 +128,70 @@ class ClockInActionSection extends StatelessWidget {
); );
} }
/// Triggers the check-in flow, reading geofence state for location data. /// Triggers the check-in flow, passing notification strings and
Future<void> _handleCheckIn(BuildContext context) async { /// override notes from geofence state.
void _handleCheckIn(BuildContext context) {
final GeofenceState geofenceState = ReadContext( final GeofenceState geofenceState = ReadContext(
context, context,
).read<GeofenceBloc>().state; ).read<GeofenceBloc>().state;
final TranslationsStaffClockInGeofenceEn geofenceI18n =
Translations.of(context).staff.clock_in.geofence;
if (checkInMode == 'nfc') { ReadContext(context).read<ClockInBloc>().add(
final bool scanned = await showNfcScanDialog(context); CheckInRequested(
if (scanned && context.mounted) { shiftId: selectedShift!.id,
ReadContext(context).read<ClockInBloc>().add( notes: geofenceState.overrideNotes,
CheckInRequested( clockInGreetingTitle: geofenceI18n.clock_in_greeting_title,
shiftId: selectedShift!.id, clockInGreetingBody: geofenceI18n.clock_in_greeting_body,
notes: geofenceState.overrideNotes, ),
isLocationVerified: geofenceState.isLocationVerified, );
isLocationTimedOut: geofenceState.isLocationTimedOut, }
isGeofenceOverridden: geofenceState.isGeofenceOverridden,
), /// Whether the user is allowed to check in for the given [shift].
); ///
} /// Delegates to [TimeWindowValidator]; returns `true` if the start time
} else { /// cannot be parsed (don't block the user).
ReadContext(context).read<ClockInBloc>().add( bool _isCheckInAllowed(Shift shift) {
CheckInRequested( final DateTime? shiftStart = DateTime.tryParse(shift.startTime);
shiftId: selectedShift!.id, if (shiftStart == null) return true;
notes: geofenceState.overrideNotes,
isLocationVerified: geofenceState.isLocationVerified, final ClockInValidationContext validationContext = ClockInValidationContext(
isLocationTimedOut: geofenceState.isLocationTimedOut, isCheckingIn: true,
isGeofenceOverridden: geofenceState.isGeofenceOverridden, shiftStartTime: shiftStart,
), );
); return const TimeWindowValidator().validate(validationContext).isValid;
}
/// Returns the formatted earliest check-in time for the given [shift].
///
/// Falls back to the localized "soon" label when the start time cannot
/// be parsed.
String _getAvailabilityTimeText(Shift shift, BuildContext context) {
final DateTime? shiftStart = DateTime.tryParse(shift.startTime.trim());
if (shiftStart != null) {
return TimeWindowValidator.getAvailabilityTime(shiftStart);
} }
return Translations.of(context).staff.clock_in.soon;
} }
/// Triggers the check-out flow via the lunch-break confirmation dialog. /// Triggers the check-out flow via the lunch-break confirmation dialog.
void _handleCheckOut(BuildContext context) { void _handleCheckOut(BuildContext context) {
final TranslationsStaffClockInGeofenceEn geofenceI18n =
Translations.of(context).staff.clock_in.geofence;
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (BuildContext dialogContext) => LunchBreakDialog( builder: (BuildContext dialogContext) => LunchBreakDialog(
onComplete: () { onComplete: () {
Modular.to.popSafe(); Modular.to.popSafe();
ReadContext( ReadContext(context).read<ClockInBloc>().add(
context, CheckOutRequested(
).read<ClockInBloc>().add(const CheckOutRequested()); clockOutTitle: geofenceI18n.clock_out_title,
clockOutBody: geofenceI18n.clock_out_body,
),
);
}, },
), ),
); );
} }
/// Dispatches [BackgroundTrackingStarted] if the geofence has target
/// coordinates after a successful check-in.
void _startBackgroundTracking(BuildContext context, ClockInState state) {
final GeofenceState geofenceState = ReadContext(
context,
).read<GeofenceBloc>().state;
if (geofenceState.targetLat != null &&
geofenceState.targetLng != null &&
state.attendance.activeShiftId != null) {
final TranslationsStaffClockInGeofenceEn geofenceI18n = Translations.of(
context,
).staff.clock_in.geofence;
ReadContext(context).read<GeofenceBloc>().add(
BackgroundTrackingStarted(
shiftId: state.attendance.activeShiftId!,
targetLat: geofenceState.targetLat!,
targetLng: geofenceState.targetLng!,
greetingTitle: geofenceI18n.clock_in_greeting_title,
greetingBody: geofenceI18n.clock_in_greeting_body,
),
);
}
}
} }

View File

@@ -1,78 +0,0 @@
import 'package:core_localization/core_localization.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
/// Pure utility functions shared across clock-in widgets.
///
/// These are stateless helpers that handle time formatting and
/// shift check-in availability calculations.
class ClockInHelpers {
const ClockInHelpers._();
/// Formats a time string (ISO 8601 or HH:mm) into a human-readable
/// 12-hour format (e.g. "9:00 AM").
static String formatTime(String timeStr) {
if (timeStr.isEmpty) return '';
try {
final DateTime dt = DateTime.parse(timeStr);
return DateFormat('h:mm a').format(dt);
} catch (_) {
try {
final List<String> parts = timeStr.split(':');
if (parts.length >= 2) {
final DateTime dt = DateTime(
2022,
1,
1,
int.parse(parts[0]),
int.parse(parts[1]),
);
return DateFormat('h:mm a').format(dt);
}
return timeStr;
} catch (e) {
return timeStr;
}
}
}
/// Whether the user is allowed to check in for the given [shift].
///
/// Check-in is permitted 15 minutes before the shift start time.
/// Falls back to `true` if the start time cannot be parsed.
static bool isCheckInAllowed(Shift shift) {
try {
final DateTime shiftStart = DateTime.parse(shift.startTime);
final DateTime windowStart = shiftStart.subtract(
const Duration(minutes: 15),
);
return DateTime.now().isAfter(windowStart);
} catch (e) {
return true;
}
}
/// Returns the earliest time the user may check in for the given [shift],
/// formatted as a 12-hour string (e.g. "8:45 AM").
///
/// Falls back to the localized "soon" label when the start time cannot
/// be parsed.
static String getCheckInAvailabilityTime(
Shift shift,
BuildContext context,
) {
try {
final DateTime shiftStart = DateTime.parse(shift.startTime.trim());
final DateTime windowStart = shiftStart.subtract(
const Duration(minutes: 15),
);
return DateFormat('h:mm a').format(windowStart);
} catch (e) {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
return i18n.soon;
}
}
}

View File

@@ -3,7 +3,7 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import 'clock_in_helpers.dart'; import 'package:krow_core/core.dart' show formatTime;
/// A selectable card that displays a single shift's summary information. /// A selectable card that displays a single shift's summary information.
/// ///
@@ -110,7 +110,7 @@ class _ShiftTimeAndRate extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[ children: <Widget>[
Text( Text(
'${ClockInHelpers.formatTime(shift.startTime)} - ${ClockInHelpers.formatTime(shift.endTime)}', '${formatTime(shift.startTime)} - ${formatTime(shift.endTime)}',
style: UiTypography.body3m.textSecondary, style: UiTypography.body3m.textSecondary,
), ),
Text( Text(

View File

@@ -3,21 +3,35 @@ 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';
/// A swipe-to-confirm slider for clock-in and clock-out actions.
///
/// Displays a draggable handle that the user slides to the end to confirm
/// check-in or check-out. This widget only handles the swipe interaction;
/// NFC mode is handled by a separate [CheckInInteraction] strategy.
class SwipeToCheckIn extends StatefulWidget { class SwipeToCheckIn extends StatefulWidget {
/// Creates a swipe-to-check-in slider.
const SwipeToCheckIn({ const SwipeToCheckIn({
super.key, super.key,
this.onCheckIn, this.onCheckIn,
this.onCheckOut, this.onCheckOut,
this.isLoading = false, this.isLoading = false,
this.mode = 'swipe',
this.isCheckedIn = false, this.isCheckedIn = false,
this.isDisabled = false, this.isDisabled = false,
}); });
/// Called when the user completes the swipe to check in.
final VoidCallback? onCheckIn; final VoidCallback? onCheckIn;
/// Called when the user completes the swipe to check out.
final VoidCallback? onCheckOut; final VoidCallback? onCheckOut;
/// Whether a check-in/out action is currently in progress.
final bool isLoading; final bool isLoading;
final String mode; // 'swipe' or 'nfc'
/// Whether the user is currently checked in.
final bool isCheckedIn; final bool isCheckedIn;
/// Whether the slider is disabled (e.g. geofence blocking).
final bool isDisabled; final bool isDisabled;
@override @override
@@ -76,57 +90,6 @@ class _SwipeToCheckInState extends State<SwipeToCheckIn>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsStaffClockInSwipeEn i18n = Translations.of(context).staff.clock_in.swipe; final TranslationsStaffClockInSwipeEn i18n = Translations.of(context).staff.clock_in.swipe;
final Color baseColor = widget.isCheckedIn
? UiColors.success
: UiColors.primary;
if (widget.mode == 'nfc') {
return GestureDetector(
onTap: () {
if (widget.isLoading || widget.isDisabled) return;
// Simulate completion for NFC tap
Future.delayed(const Duration(milliseconds: 300), () {
if (widget.isCheckedIn) {
widget.onCheckOut?.call();
} else {
widget.onCheckIn?.call();
}
});
},
child: Container(
height: 56,
decoration: BoxDecoration(
color: widget.isDisabled ? UiColors.bgSecondary : baseColor,
borderRadius: UiConstants.radiusLg,
boxShadow: widget.isDisabled ? [] : <BoxShadow>[
BoxShadow(
color: baseColor.withValues(alpha: 0.4),
blurRadius: 25,
offset: const Offset(0, 10),
spreadRadius: -5,
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(UiIcons.wifi, color: UiColors.white),
const SizedBox(width: UiConstants.space3),
Text(
widget.isLoading
? (widget.isCheckedIn
? i18n.checking_out
: i18n.checking_in)
: (widget.isCheckedIn ? i18n.nfc_checkout : i18n.nfc_checkin),
style: UiTypography.body1b.copyWith(
color: widget.isDisabled ? UiColors.textDisabled : UiColors.white,
),
),
],
),
),
);
}
return LayoutBuilder( return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) { builder: (BuildContext context, BoxConstraints constraints) {

View File

@@ -4,6 +4,7 @@ import 'package:krow_core/core.dart';
import 'data/repositories_impl/clock_in_repository_impl.dart'; import 'data/repositories_impl/clock_in_repository_impl.dart';
import 'data/services/background_geofence_service.dart'; import 'data/services/background_geofence_service.dart';
import 'data/services/clock_in_notification_service.dart';
import 'data/services/geofence_service_impl.dart'; import 'data/services/geofence_service_impl.dart';
import 'domain/repositories/clock_in_repository_interface.dart'; import 'domain/repositories/clock_in_repository_interface.dart';
import 'domain/services/geofence_service_interface.dart'; import 'domain/services/geofence_service_interface.dart';
@@ -11,13 +12,18 @@ import 'domain/usecases/clock_in_usecase.dart';
import 'domain/usecases/clock_out_usecase.dart'; import 'domain/usecases/clock_out_usecase.dart';
import 'domain/usecases/get_attendance_status_usecase.dart'; import 'domain/usecases/get_attendance_status_usecase.dart';
import 'domain/usecases/get_todays_shift_usecase.dart'; import 'domain/usecases/get_todays_shift_usecase.dart';
import 'domain/validators/clock_in_validator.dart';
import 'domain/validators/composite_clock_in_validator.dart';
import 'domain/validators/geofence_validator.dart';
import 'domain/validators/override_notes_validator.dart';
import 'domain/validators/time_window_validator.dart';
import 'presentation/bloc/clock_in/clock_in_bloc.dart'; import 'presentation/bloc/clock_in/clock_in_bloc.dart';
import 'presentation/bloc/geofence/geofence_bloc.dart'; import 'presentation/bloc/geofence/geofence_bloc.dart';
import 'presentation/pages/clock_in_page.dart'; import 'presentation/pages/clock_in_page.dart';
/// Module for the staff clock-in feature. /// Module for the staff clock-in feature.
/// ///
/// Registers repositories, use cases, geofence services, and BLoCs. /// Registers repositories, use cases, validators, geofence services, and BLoCs.
class StaffClockInModule extends Module { class StaffClockInModule extends Module {
@override @override
List<Module> get imports => <Module>[CoreModule()]; List<Module> get imports => <Module>[CoreModule()];
@@ -36,23 +42,50 @@ class StaffClockInModule extends Module {
i.add<BackgroundGeofenceService>( i.add<BackgroundGeofenceService>(
() => BackgroundGeofenceService( () => BackgroundGeofenceService(
backgroundTaskService: i.get<BackgroundTaskService>(), backgroundTaskService: i.get<BackgroundTaskService>(),
notificationService: i.get<NotificationService>(),
storageService: i.get<StorageService>(), storageService: i.get<StorageService>(),
), ),
); );
// Notification Service (clock-in / clock-out / geofence notifications)
i.add<ClockInNotificationService>(
() => ClockInNotificationService(
notificationService: i.get<NotificationService>(),
),
);
// Use Cases // Use Cases
i.add<GetTodaysShiftUseCase>(GetTodaysShiftUseCase.new); i.add<GetTodaysShiftUseCase>(GetTodaysShiftUseCase.new);
i.add<GetAttendanceStatusUseCase>(GetAttendanceStatusUseCase.new); i.add<GetAttendanceStatusUseCase>(GetAttendanceStatusUseCase.new);
i.add<ClockInUseCase>(ClockInUseCase.new); i.add<ClockInUseCase>(ClockInUseCase.new);
i.add<ClockOutUseCase>(ClockOutUseCase.new); i.add<ClockOutUseCase>(ClockOutUseCase.new);
// BLoCs (transient -- new instance per navigation) // Validators
i.add<ClockInBloc>(ClockInBloc.new); i.addLazySingleton<CompositeClockInValidator>(
i.add<GeofenceBloc>( () => const CompositeClockInValidator(<ClockInValidator>[
GeofenceValidator(),
TimeWindowValidator(),
OverrideNotesValidator(),
]),
);
// BLoCs
// GeofenceBloc is a lazy singleton so that ClockInBloc and the widget tree
// share the same instance within a navigation scope.
i.addLazySingleton<GeofenceBloc>(
() => GeofenceBloc( () => GeofenceBloc(
geofenceService: i.get<GeofenceServiceInterface>(), geofenceService: i.get<GeofenceServiceInterface>(),
backgroundGeofenceService: i.get<BackgroundGeofenceService>(), backgroundGeofenceService: i.get<BackgroundGeofenceService>(),
notificationService: i.get<ClockInNotificationService>(),
),
);
i.add<ClockInBloc>(
() => ClockInBloc(
getTodaysShift: i.get<GetTodaysShiftUseCase>(),
getAttendanceStatus: i.get<GetAttendanceStatusUseCase>(),
clockIn: i.get<ClockInUseCase>(),
clockOut: i.get<ClockOutUseCase>(),
geofenceBloc: i.get<GeofenceBloc>(),
validator: i.get<CompositeClockInValidator>(),
), ),
); );
} }