From 28a219bbea90c79c8c527fa704a8a7e2291eea08 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 14 Mar 2026 19:58:43 -0400 Subject: [PATCH] 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. --- .claude/agents/mobile-builder.md | 17 +- apps/mobile/packages/core/lib/core.dart | 1 + .../core/lib/src/utils/time_utils.dart | 30 +++ apps/mobile/packages/core/pubspec.yaml | 1 + .../services/background_geofence_service.dart | 56 +----- .../clock_in_notification_service.dart | 61 ++++++ .../data/services/geofence_service_impl.dart | 10 +- .../src/domain/models/geofence_result.dart | 18 +- .../clock_in_validation_context.dart | 55 ++++++ .../clock_in_validation_result.dart | 24 +++ .../domain/validators/clock_in_validator.dart | 11 ++ .../composite_clock_in_validator.dart | 27 +++ .../domain/validators/geofence_validator.dart | 35 ++++ .../validators/override_notes_validator.dart | 35 ++++ .../validators/time_window_validator.dart | 77 ++++++++ .../bloc/clock_in/clock_in_bloc.dart | 118 +++++++++--- .../bloc/clock_in/clock_in_event.dart | 48 +++-- .../bloc/geofence/geofence_bloc.dart | 22 ++- .../bloc/geofence/geofence_event.dart | 54 +++--- .../bloc/geofence/geofence_state.dart | 8 +- .../src/presentation/pages/clock_in_page.dart | 8 +- .../strategies/check_in_interaction.dart | 22 +++ .../strategies/nfc_check_in_interaction.dart | 118 ++++++++++++ .../swipe_check_in_interaction.dart | 30 +++ .../widgets/clock_in_action_section.dart | 178 +++++++----------- .../widgets/clock_in_helpers.dart | 78 -------- .../src/presentation/widgets/shift_card.dart | 4 +- .../widgets/swipe_to_check_in.dart | 69 ++----- .../lib/src/staff_clock_in_module.dart | 43 ++++- 29 files changed, 864 insertions(+), 394 deletions(-) create mode 100644 apps/mobile/packages/core/lib/src/utils/time_utils.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/data/services/clock_in_notification_service.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validation_context.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validation_result.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validator.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/composite_clock_in_validator.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/geofence_validator.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/override_notes_validator.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/time_window_validator.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/check_in_interaction.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/nfc_check_in_interaction.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/swipe_check_in_interaction.dart delete mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_helpers.dart diff --git a/.claude/agents/mobile-builder.md b/.claude/agents/mobile-builder.md index 7ada36c8..cd55655b 100644 --- a/.claude/agents/mobile-builder.md +++ b/.claude/agents/mobile-builder.md @@ -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 `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. +- **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 @@ -121,13 +130,19 @@ features/ entities/ # Pure Dart classes repositories/ # Abstract interfaces usecases/ # Business logic lives HERE + validators/ # Composable validation pipeline (optional) domain.dart # Barrel file data/ models/ # With fromJson/toJson repositories/ # Concrete implementations data.dart # Barrel file 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 widgets/ # Reusable components presentation.dart # Barrel file diff --git a/apps/mobile/packages/core/lib/core.dart b/apps/mobile/packages/core/lib/core.dart index 5e29efb5..600ff74f 100644 --- a/apps/mobile/packages/core/lib/core.dart +++ b/apps/mobile/packages/core/lib/core.dart @@ -6,6 +6,7 @@ export 'src/domain/arguments/usecase_argument.dart'; export 'src/domain/usecases/usecase.dart'; export 'src/utils/date_time_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/mixins/bloc_error_handler.dart'; export 'src/presentation/observers/core_bloc_observer.dart'; diff --git a/apps/mobile/packages/core/lib/src/utils/time_utils.dart b/apps/mobile/packages/core/lib/src/utils/time_utils.dart new file mode 100644 index 00000000..7340753c --- /dev/null +++ b/apps/mobile/packages/core/lib/src/utils/time_utils.dart @@ -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 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; + } + } +} diff --git a/apps/mobile/packages/core/pubspec.yaml b/apps/mobile/packages/core/pubspec.yaml index 347e45af..f40200eb 100644 --- a/apps/mobile/packages/core/pubspec.yaml +++ b/apps/mobile/packages/core/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: design_system: path: ../design_system + intl: ^0.20.0 flutter_bloc: ^8.1.0 equatable: ^2.0.8 flutter_modular: ^6.4.1 diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart index ec2d9fe2..108b12f6 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart @@ -90,24 +90,21 @@ void backgroundGeofenceDispatcher() { /// Service that manages periodic background geofence checks while clocked in. /// -/// Uses core services for foreground operations. The background isolate logic -/// lives in the top-level [backgroundGeofenceDispatcher] function above. +/// Handles scheduling and cancelling background tasks only. Notification +/// delivery is handled by [ClockInNotificationService]. The background isolate +/// logic lives in the top-level [backgroundGeofenceDispatcher] function above. class BackgroundGeofenceService { /// Creates a [BackgroundGeofenceService] instance. BackgroundGeofenceService({ required BackgroundTaskService backgroundTaskService, - required NotificationService notificationService, required StorageService storageService, }) : _backgroundTaskService = backgroundTaskService, - _notificationService = notificationService, _storageService = storageService; + /// The core background task service for scheduling periodic work. final BackgroundTaskService _backgroundTaskService; - /// The core notification service for displaying local notifications. - final NotificationService _notificationService; - /// The core storage service for persisting geofence target data. final StorageService _storageService; @@ -129,18 +126,15 @@ class BackgroundGeofenceService { /// Task name identifier for the workmanager callback. static const String taskName = 'geofenceCheck'; - /// Notification ID for clock-in greeting notifications. - static const int _clockInNotificationId = 1; - /// 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; /// Geofence radius in meters. static const double geofenceRadiusMeters = 500; - /// Notification ID for clock-out notifications. - static const int _clockOutNotificationId = 3; - /// Starts periodic 15-minute background geofence checks. /// /// Called after a successful clock-in. Persists the target coordinates @@ -188,40 +182,4 @@ class BackgroundGeofenceService { final bool? active = await _storageService.getBool(_keyTrackingActive); return active ?? false; } - - /// Shows a notification that the worker has left the geofence. - Future 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 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 showClockOutNotification({ - required String title, - required String body, - }) async { - await _notificationService.showNotification( - title: title, - body: body, - id: _clockOutNotificationId, - ); - } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/clock_in_notification_service.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/clock_in_notification_service.dart new file mode 100644 index 00000000..17b5f0a6 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/clock_in_notification_service.dart @@ -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 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 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 showLeftGeofenceNotification({ + required String title, + required String body, + }) async { + await _notificationService.showNotification( + title: title, + body: body, + id: leftGeofenceNotificationId, + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/geofence_service_impl.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/geofence_service_impl.dart index 4a76b07b..cc4d00d6 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/geofence_service_impl.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/geofence_service_impl.dart @@ -40,7 +40,7 @@ class GeofenceServiceImpl implements GeofenceServiceInterface { double radiusMeters = 500, }) { return _locationService.watchLocation(distanceFilter: 10).map( - (location) => _buildResult( + (DeviceLocation location) => _buildResult( location: location, targetLat: targetLat, targetLng: targetLng, @@ -57,7 +57,7 @@ class GeofenceServiceImpl implements GeofenceServiceInterface { Duration timeout = const Duration(seconds: 30), }) async { try { - final location = + final DeviceLocation location = await _locationService.getCurrentLocation().timeout(timeout); return _buildResult( location: location, @@ -92,15 +92,15 @@ class GeofenceServiceImpl implements GeofenceServiceInterface { required double targetLng, required double radiusMeters, }) { - final distance = calculateDistance( + final double distance = calculateDistance( location.latitude, location.longitude, targetLat, targetLng, ); - final isWithin = debugAlwaysInRange || distance <= radiusMeters; - final eta = + final bool isWithin = debugAlwaysInRange || distance <= radiusMeters; + final int eta = isWithin ? 0 : (distance / _walkingSpeedMetersPerMinute).round(); return GeofenceResult( diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/models/geofence_result.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/models/geofence_result.dart index d5185375..95043929 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/models/geofence_result.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/models/geofence_result.dart @@ -3,6 +3,14 @@ import 'package:krow_domain/krow_domain.dart'; /// Result of a geofence proximity check. 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. final double distanceMeters; @@ -15,16 +23,8 @@ class GeofenceResult extends Equatable { /// The device location at the time of the check. final DeviceLocation location; - /// Creates a [GeofenceResult] instance. - const GeofenceResult({ - required this.distanceMeters, - required this.isWithinRadius, - required this.estimatedEtaMinutes, - required this.location, - }); - @override - List get props => [ + List get props => [ distanceMeters, isWithinRadius, estimatedEtaMinutes, diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validation_context.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validation_context.dart new file mode 100644 index 00000000..6a071e58 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validation_context.dart @@ -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 get props => [ + isCheckingIn, + shiftStartTime, + shiftEndTime, + hasCoordinates, + isLocationVerified, + isLocationTimedOut, + isGeofenceOverridden, + overrideNotes, + ]; +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validation_result.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validation_result.dart new file mode 100644 index 00000000..59a03ac2 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validation_result.dart @@ -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 get props => [isValid, errorKey]; +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validator.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validator.dart new file mode 100644 index 00000000..8b818524 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validator.dart @@ -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); +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/composite_clock_in_validator.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/composite_clock_in_validator.dart new file mode 100644 index 00000000..3521ee7a --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/composite_clock_in_validator.dart @@ -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 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(); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/geofence_validator.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/geofence_validator.dart new file mode 100644 index 00000000..1f6c3c80 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/geofence_validator.dart @@ -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(); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/override_notes_validator.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/override_notes_validator.dart new file mode 100644 index 00000000..a425e53d --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/override_notes_validator.dart @@ -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(); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/time_window_validator.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/time_window_validator.dart new file mode 100644 index 00000000..4fcac299 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/time_window_validator.dart @@ -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); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart index f3bf5a7f..d2db4a44 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart @@ -8,25 +8,39 @@ import '../../../domain/usecases/clock_in_usecase.dart'; import '../../../domain/usecases/clock_out_usecase.dart'; import '../../../domain/usecases/get_attendance_status_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_state.dart'; /// BLoC responsible for clock-in/clock-out operations and shift management. /// -/// Location and geofence concerns are delegated to [GeofenceBloc]. -/// The UI bridges geofence state into [CheckInRequested] event parameters. +/// Reads [GeofenceBloc] state directly to evaluate geofence conditions, +/// 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 with BlocErrorHandler { - /// Creates a [ClockInBloc] with the required use cases. + /// Creates a [ClockInBloc] with the required use cases, geofence BLoC, + /// and validator. ClockInBloc({ required GetTodaysShiftUseCase getTodaysShift, required GetAttendanceStatusUseCase getAttendanceStatus, required ClockInUseCase clockIn, required ClockOutUseCase clockOut, + required GeofenceBloc geofenceBloc, + required CompositeClockInValidator validator, }) : _getTodaysShift = getTodaysShift, _getAttendanceStatus = getAttendanceStatus, _clockIn = clockIn, _clockOut = clockOut, + _geofenceBloc = geofenceBloc, + _validator = validator, super(ClockInState(selectedDate: DateTime.now())) { on(_onLoaded); on(_onShiftSelected); @@ -43,6 +57,12 @@ class ClockInBloc extends Bloc final ClockInUseCase _clockIn; 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. Future _onLoaded( ClockInPageLoaded event, @@ -106,41 +126,38 @@ class ClockInBloc extends Bloc /// Handles a clock-in request. /// - /// Geofence state is passed via event parameters from the UI layer: - /// - If the shift has a venue (lat/lng) and location is neither verified - /// nor timed out, the clock-in is rejected. - /// - If the location timed out, notes are required to proceed. - /// - Otherwise the clock-in proceeds normally. + /// Reads geofence state directly from [_geofenceBloc] and builds a + /// [ClockInValidationContext] to run through the [_validator] pipeline. + /// On success, dispatches [BackgroundTrackingStarted] to [_geofenceBloc]. Future _onCheckIn( CheckInRequested event, Emitter emit, ) async { final Shift? shift = state.selectedShift; - final bool shiftHasLocation = + final GeofenceState geofenceState = _geofenceBloc.state; + + final bool hasCoordinates = shift != null && shift.latitude != null && shift.longitude != null; - // If the shift requires location verification but geofence has not - // confirmed proximity, has not timed out, and the worker has not - // explicitly overridden via the justification modal, reject the attempt. - if (shiftHasLocation && - !event.isLocationVerified && - !event.isLocationTimedOut && - !event.isGeofenceOverridden) { - emit(state.copyWith( - status: ClockInStatus.failure, - errorMessage: 'errors.clock_in.location_verification_required', - )); - return; - } + // Build validation context from combined BLoC states. + final ClockInValidationContext validationContext = ClockInValidationContext( + isCheckingIn: true, + shiftStartTime: _tryParseDateTime(shift?.startTime), + shiftEndTime: _tryParseDateTime(shift?.endTime), + hasCoordinates: hasCoordinates, + isLocationVerified: geofenceState.isLocationVerified, + isLocationTimedOut: geofenceState.isLocationTimedOut, + isGeofenceOverridden: geofenceState.isGeofenceOverridden, + overrideNotes: event.notes, + ); - // When location timed out or geofence is overridden, require the user to - // provide notes explaining why they are clocking in without verified - // proximity. - if ((event.isLocationTimedOut || event.isGeofenceOverridden) && - (event.notes == null || event.notes!.trim().isEmpty)) { + final ClockInValidationResult validationResult = + _validator.validate(validationContext); + + if (!validationResult.isValid) { emit(state.copyWith( status: ClockInStatus.failure, - errorMessage: 'errors.clock_in.notes_required_for_timeout', + errorMessage: validationResult.errorKey, )); return; } @@ -156,6 +173,12 @@ class ClockInBloc extends Bloc status: ClockInStatus.success, attendance: newStatus, )); + + // Start background tracking after successful clock-in. + _dispatchBackgroundTrackingStarted( + event: event, + activeShiftId: newStatus.activeShiftId, + ); }, onError: (String errorKey) => state.copyWith( status: ClockInStatus.failure, @@ -165,6 +188,8 @@ class ClockInBloc extends Bloc } /// Handles a clock-out request. + /// + /// On success, dispatches [BackgroundTrackingStopped] to [_geofenceBloc]. Future _onCheckOut( CheckOutRequested event, Emitter emit, @@ -184,6 +209,14 @@ class ClockInBloc extends Bloc status: ClockInStatus.success, attendance: newStatus, )); + + // Stop background tracking after successful clock-out. + _geofenceBloc.add( + BackgroundTrackingStopped( + clockOutTitle: event.clockOutTitle, + clockOutBody: event.clockOutBody, + ), + ); }, onError: (String errorKey) => state.copyWith( status: ClockInStatus.failure, @@ -191,4 +224,33 @@ class ClockInBloc extends Bloc ), ); } + + /// 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, + ), + ); + } + } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart index 181ed372..d8c30eb1 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart @@ -36,45 +36,46 @@ class DateSelected extends ClockInEvent { /// Emitted when the user requests to clock in. /// -/// [isLocationVerified] and [isLocationTimedOut] are provided by the UI layer -/// from the GeofenceBloc state, bridging the two BLoCs. +/// Geofence state is read directly by the BLoC from [GeofenceBloc], +/// so this event only carries the shift ID, optional notes, and +/// notification strings for background tracking. class CheckInRequested extends ClockInEvent { const CheckInRequested({ required this.shiftId, this.notes, - this.isLocationVerified = false, - this.isLocationTimedOut = false, - this.isGeofenceOverridden = false, + this.clockInGreetingTitle = '', + this.clockInGreetingBody = '', }); /// The ID of the shift to clock into. final String shiftId; - /// Optional notes provided by the user. + /// Optional notes provided by the user (e.g. geofence override notes). final String? notes; - /// Whether the geofence verification passed (user is within radius). - final bool isLocationVerified; + /// Localized title for the clock-in greeting notification. + final String clockInGreetingTitle; - /// Whether the geofence verification timed out (GPS unavailable). - final bool isLocationTimedOut; - - /// Whether the worker explicitly overrode geofence via the justification modal. - final bool isGeofenceOverridden; + /// Localized body for the clock-in greeting notification. + final String clockInGreetingBody; @override List get props => [ shiftId, notes, - isLocationVerified, - isLocationTimedOut, - isGeofenceOverridden, + clockInGreetingTitle, + clockInGreetingBody, ]; } /// Emitted when the user requests to clock out. 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. final String? notes; @@ -82,8 +83,19 @@ class CheckOutRequested extends ClockInEvent { /// Break time taken during the shift, in minutes. final int? breakTimeMinutes; + /// Localized title for the clock-out notification. + final String clockOutTitle; + + /// Localized body for the clock-out notification. + final String clockOutBody; + @override - List get props => [notes, breakTimeMinutes]; + List get props => [ + notes, + breakTimeMinutes, + clockOutTitle, + clockOutBody, + ]; } /// Emitted when the user changes the check-in mode (e.g. swipe vs tap). diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart index 1472d3a2..e2db2f73 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart @@ -5,6 +5,7 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import '../../../data/services/background_geofence_service.dart'; +import '../../../data/services/clock_in_notification_service.dart'; import '../../../domain/models/geofence_result.dart'; import '../../../domain/services/geofence_service_interface.dart'; import 'geofence_event.dart'; @@ -23,8 +24,10 @@ class GeofenceBloc extends Bloc GeofenceBloc({ required GeofenceServiceInterface geofenceService, required BackgroundGeofenceService backgroundGeofenceService, + required ClockInNotificationService notificationService, }) : _geofenceService = geofenceService, _backgroundGeofenceService = backgroundGeofenceService, + _notificationService = notificationService, super(const GeofenceState.initial()) { on(_onStarted); on(_onResultUpdated); @@ -42,6 +45,9 @@ class GeofenceBloc extends Bloc /// The background service for periodic tracking while clocked in. final BackgroundGeofenceService _backgroundGeofenceService; + /// The notification service for clock-in related notifications. + final ClockInNotificationService _notificationService; + /// Active subscription to the foreground geofence location stream. StreamSubscription? _geofenceSubscription; @@ -64,7 +70,7 @@ class GeofenceBloc extends Bloc emit: emit.call, action: () async { // Check permission first. - final permission = await _geofenceService.ensurePermission(); + final LocationPermissionStatus permission = await _geofenceService.ensurePermission(); emit(state.copyWith(permissionStatus: permission)); if (permission == LocationPermissionStatus.denied || @@ -81,12 +87,12 @@ class GeofenceBloc extends Bloc // Start monitoring location service status changes. await _serviceStatusSubscription?.cancel(); _serviceStatusSubscription = - _geofenceService.watchServiceStatus().listen((isEnabled) { + _geofenceService.watchServiceStatus().listen((bool isEnabled) { add(GeofenceServiceStatusChanged(isEnabled)); }); // Get initial position with a 30s timeout. - final result = await _geofenceService.checkGeofenceWithTimeout( + final GeofenceResult? result = await _geofenceService.checkGeofenceWithTimeout( targetLat: event.targetLat, targetLng: event.targetLng, ); @@ -105,7 +111,7 @@ class GeofenceBloc extends Bloc targetLng: event.targetLng, ) .listen( - (result) => add(GeofenceResultUpdated(result)), + (GeofenceResult result) => add(GeofenceResultUpdated(result)), ); }, onError: (String errorKey) => state.copyWith( @@ -172,7 +178,7 @@ class GeofenceBloc extends Bloc await handleError( emit: emit.call, action: () async { - final result = await _geofenceService.checkGeofenceWithTimeout( + final GeofenceResult? result = await _geofenceService.checkGeofenceWithTimeout( targetLat: state.targetLat!, targetLng: state.targetLng!, ); @@ -199,7 +205,7 @@ class GeofenceBloc extends Bloc emit: emit.call, action: () async { // Request upgrade to "Always" permission for background tracking. - final permission = await _geofenceService.requestAlwaysPermission(); + final LocationPermissionStatus permission = await _geofenceService.requestAlwaysPermission(); emit(state.copyWith(permissionStatus: permission)); // Start background tracking regardless (degrades gracefully). @@ -210,7 +216,7 @@ class GeofenceBloc extends Bloc ); // Show greeting notification using localized strings from the UI. - await _backgroundGeofenceService.showClockInGreetingNotification( + await _notificationService.showClockInGreeting( title: event.greetingTitle, body: event.greetingBody, ); @@ -235,7 +241,7 @@ class GeofenceBloc extends Bloc await _backgroundGeofenceService.stopBackgroundTracking(); // Show clock-out notification using localized strings from the UI. - await _backgroundGeofenceService.showClockOutNotification( + await _notificationService.showClockOutNotification( title: event.clockOutTitle, body: event.clockOutBody, ); diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart index 1b1c219b..c5f68a60 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart @@ -13,27 +13,27 @@ abstract class GeofenceEvent extends Equatable { /// Starts foreground geofence verification for a target location. class GeofenceStarted extends GeofenceEvent { + /// Creates a [GeofenceStarted] event. + const GeofenceStarted({required this.targetLat, required this.targetLng}); + /// Target latitude of the shift location. final double targetLat; /// Target longitude of the shift location. final double targetLng; - /// Creates a [GeofenceStarted] event. - const GeofenceStarted({required this.targetLat, required this.targetLng}); - @override List get props => [targetLat, targetLng]; } /// Emitted when a new geofence result is received from the location stream. class GeofenceResultUpdated extends GeofenceEvent { - /// The latest geofence check result. - final GeofenceResult result; - /// Creates a [GeofenceResultUpdated] event. const GeofenceResultUpdated(this.result); + /// The latest geofence check result. + final GeofenceResult result; + @override List get props => [result]; } @@ -46,12 +46,12 @@ class GeofenceTimeoutReached extends GeofenceEvent { /// Emitted when the device location service status changes. class GeofenceServiceStatusChanged extends GeofenceEvent { - /// Whether location services are now enabled. - final bool isEnabled; - /// Creates a [GeofenceServiceStatusChanged] event. const GeofenceServiceStatusChanged(this.isEnabled); + /// Whether location services are now enabled. + final bool isEnabled; + @override List get props => [isEnabled]; } @@ -64,6 +64,15 @@ class GeofenceRetryRequested extends GeofenceEvent { /// Starts background tracking after successful clock-in. 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. final String shiftId; @@ -79,15 +88,6 @@ class BackgroundTrackingStarted extends GeofenceEvent { /// Localized greeting notification body passed from the UI layer. 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 List get props => [shiftId, targetLat, targetLng, greetingTitle, greetingBody]; @@ -95,30 +95,30 @@ class BackgroundTrackingStarted extends GeofenceEvent { /// Stops background tracking after clock-out. 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. const BackgroundTrackingStopped({ required this.clockOutTitle, 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 List get props => [clockOutTitle, clockOutBody]; } /// Worker approved geofence override by providing justification notes. class GeofenceOverrideApproved extends GeofenceEvent { - /// The justification notes provided by the worker. - final String notes; - /// Creates a [GeofenceOverrideApproved] event. const GeofenceOverrideApproved({required this.notes}); + /// The justification notes provided by the worker. + final String notes; + @override List get props => [notes]; } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_state.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_state.dart index 080e5a75..a4ab8ed7 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_state.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_state.dart @@ -3,7 +3,6 @@ import 'package:krow_domain/krow_domain.dart'; /// State for the [GeofenceBloc]. class GeofenceState extends Equatable { - /// Creates a [GeofenceState] instance. const GeofenceState({ this.permissionStatus, @@ -19,6 +18,10 @@ class GeofenceState extends Equatable { this.targetLat, this.targetLng, }); + + /// Initial state before any geofence operations. + const GeofenceState.initial() : this(); + /// Current location permission status. final LocationPermissionStatus? permissionStatus; @@ -55,9 +58,6 @@ class GeofenceState extends Equatable { /// Target longitude being monitored. final double? targetLng; - /// Initial state before any geofence operations. - const GeofenceState.initial() : this(); - /// Creates a copy with the given fields replaced. GeofenceState copyWith({ LocationPermissionStatus? permissionStatus, diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart index 7b07af80..8aacb8ff 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart @@ -29,8 +29,12 @@ class ClockInPage extends StatelessWidget { appBar: UiAppBar(title: i18n.title, showBackButton: false), body: MultiBlocProvider( providers: >[ - BlocProvider.value(value: Modular.get()), - BlocProvider.value(value: Modular.get()), + BlocProvider.value( + value: Modular.get(), + ), + BlocProvider.value( + value: Modular.get(), + ), ], child: BlocListener( listenWhen: (ClockInState previous, ClockInState current) => diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/check_in_interaction.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/check_in_interaction.dart new file mode 100644 index 00000000..707d055f --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/check_in_interaction.dart @@ -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, + }); +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/nfc_check_in_interaction.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/nfc_check_in_interaction.dart new file mode 100644 index 00000000..f479ac77 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/nfc_check_in_interaction.dart @@ -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( + color: baseColor.withValues(alpha: 0.4), + blurRadius: 25, + offset: const Offset(0, 10), + spreadRadius: -5, + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + 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 _handleTap(BuildContext context) async { + if (isLoading || isDisabled) return; + + final bool scanned = await showNfcScanDialog(context); + if (scanned && context.mounted) { + if (isCheckedIn) { + onCheckOut(); + } else { + onCheckIn(); + } + } + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/swipe_check_in_interaction.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/swipe_check_in_interaction.dart new file mode 100644 index 00000000..21273af9 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/swipe_check_in_interaction.dart @@ -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, + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart index 292afac0..c2efc4c1 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart @@ -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_event.dart'; -import '../bloc/clock_in/clock_in_state.dart'; import '../bloc/geofence/geofence_bloc.dart'; -import '../bloc/geofence/geofence_event.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 'geofence_status_banner/geofence_status_banner.dart'; import 'lunch_break_modal.dart'; -import 'nfc_scan_dialog.dart'; import 'no_shifts_banner.dart'; import 'shift_completed_banner.dart'; -import 'swipe_to_check_in.dart'; /// Orchestrates which action widget is displayed based on the current state. /// -/// Decides between the swipe-to-check-in slider, the early-arrival banner, -/// the shift-completed banner, or the no-shifts placeholder. Also shows the -/// [GeofenceStatusBanner] and manages background tracking lifecycle. +/// Uses the [CheckInInteraction] strategy pattern to delegate the actual +/// check-in/out UI to mode-specific implementations (swipe, NFC, etc.). +/// Also shows the [GeofenceStatusBanner]. Background tracking lifecycle +/// is managed by [ClockInBloc], not this widget. class ClockInActionSection extends StatelessWidget { /// Creates the action section. const ClockInActionSection({ @@ -37,6 +38,13 @@ class ClockInActionSection extends StatelessWidget { super.key, }); + /// Available check-in interaction strategies keyed by mode identifier. + static const Map _interactions = + { + 'swipe': SwipeCheckInInteraction(), + 'nfc': NfcCheckInInteraction(), + }; + /// The currently selected shift, or null if none is selected. final Shift? selectedShift; @@ -52,46 +60,14 @@ class ClockInActionSection extends StatelessWidget { /// Whether a check-in or check-out action is currently in progress. 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 Widget build(BuildContext context) { - return MultiBlocListener( - listeners: >[ - // Start background tracking after successful check-in. - BlocListener( - 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( - 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().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) { return _buildActiveShiftAction(context); } @@ -105,14 +81,14 @@ class ClockInActionSection extends StatelessWidget { /// Builds the action widget for an active (not completed) shift. Widget _buildActiveShiftAction(BuildContext context) { - if (!isCheckedIn && !ClockInHelpers.isCheckInAllowed(selectedShift!)) { + if (!isCheckedIn && !_isCheckInAllowed(selectedShift!)) { return Column( mainAxisSize: MainAxisSize.min, children: [ const GeofenceStatusBanner(), const SizedBox(height: UiConstants.space3), EarlyCheckInBanner( - availabilityTime: ClockInHelpers.getCheckInAvailabilityTime( + availabilityTime: _getAvailabilityTimeText( selectedShift!, context, ), @@ -138,11 +114,9 @@ class ClockInActionSection extends StatelessWidget { mainAxisSize: MainAxisSize.min, spacing: UiConstants.space4, children: [ - // Geofence status banner is shown even when not blocking to provide feedback const GeofenceStatusBanner(), - SwipeToCheckIn( + _currentInteraction.buildActionWidget( isCheckedIn: isCheckedIn, - mode: checkInMode, isDisabled: isGeofenceBlocking, isLoading: isActionInProgress, onCheckIn: () => _handleCheckIn(context), @@ -154,76 +128,70 @@ class ClockInActionSection extends StatelessWidget { ); } - /// Triggers the check-in flow, reading geofence state for location data. - Future _handleCheckIn(BuildContext context) async { + /// Triggers the check-in flow, passing notification strings and + /// override notes from geofence state. + void _handleCheckIn(BuildContext context) { final GeofenceState geofenceState = ReadContext( context, ).read().state; + final TranslationsStaffClockInGeofenceEn geofenceI18n = + Translations.of(context).staff.clock_in.geofence; - if (checkInMode == 'nfc') { - final bool scanned = await showNfcScanDialog(context); - if (scanned && context.mounted) { - ReadContext(context).read().add( - CheckInRequested( - shiftId: selectedShift!.id, - notes: geofenceState.overrideNotes, - isLocationVerified: geofenceState.isLocationVerified, - isLocationTimedOut: geofenceState.isLocationTimedOut, - isGeofenceOverridden: geofenceState.isGeofenceOverridden, - ), - ); - } - } else { - ReadContext(context).read().add( - CheckInRequested( - shiftId: selectedShift!.id, - notes: geofenceState.overrideNotes, - isLocationVerified: geofenceState.isLocationVerified, - isLocationTimedOut: geofenceState.isLocationTimedOut, - isGeofenceOverridden: geofenceState.isGeofenceOverridden, - ), - ); + ReadContext(context).read().add( + CheckInRequested( + shiftId: selectedShift!.id, + notes: geofenceState.overrideNotes, + clockInGreetingTitle: geofenceI18n.clock_in_greeting_title, + clockInGreetingBody: geofenceI18n.clock_in_greeting_body, + ), + ); + } + + /// Whether the user is allowed to check in for the given [shift]. + /// + /// Delegates to [TimeWindowValidator]; returns `true` if the start time + /// cannot be parsed (don't block the user). + bool _isCheckInAllowed(Shift shift) { + final DateTime? shiftStart = DateTime.tryParse(shift.startTime); + if (shiftStart == null) return true; + + final ClockInValidationContext validationContext = ClockInValidationContext( + isCheckingIn: true, + 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. void _handleCheckOut(BuildContext context) { + final TranslationsStaffClockInGeofenceEn geofenceI18n = + Translations.of(context).staff.clock_in.geofence; + showDialog( context: context, builder: (BuildContext dialogContext) => LunchBreakDialog( onComplete: () { Modular.to.popSafe(); - ReadContext( - context, - ).read().add(const CheckOutRequested()); + ReadContext(context).read().add( + 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().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().add( - BackgroundTrackingStarted( - shiftId: state.attendance.activeShiftId!, - targetLat: geofenceState.targetLat!, - targetLng: geofenceState.targetLng!, - greetingTitle: geofenceI18n.clock_in_greeting_title, - greetingBody: geofenceI18n.clock_in_greeting_body, - ), - ); - } - } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_helpers.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_helpers.dart deleted file mode 100644 index 9f64639d..00000000 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_helpers.dart +++ /dev/null @@ -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 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; - } - } -} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart index fc63f090..3b4d0d97 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart @@ -3,7 +3,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.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. /// @@ -110,7 +110,7 @@ class _ShiftTimeAndRate extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - '${ClockInHelpers.formatTime(shift.startTime)} - ${ClockInHelpers.formatTime(shift.endTime)}', + '${formatTime(shift.startTime)} - ${formatTime(shift.endTime)}', style: UiTypography.body3m.textSecondary, ), Text( diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart index b9c8599b..8c0bc42e 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart @@ -3,21 +3,35 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.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 { + /// Creates a swipe-to-check-in slider. const SwipeToCheckIn({ super.key, this.onCheckIn, this.onCheckOut, this.isLoading = false, - this.mode = 'swipe', this.isCheckedIn = false, this.isDisabled = false, }); + + /// Called when the user completes the swipe to check in. final VoidCallback? onCheckIn; + + /// Called when the user completes the swipe to check out. final VoidCallback? onCheckOut; + + /// Whether a check-in/out action is currently in progress. final bool isLoading; - final String mode; // 'swipe' or 'nfc' + + /// Whether the user is currently checked in. final bool isCheckedIn; + + /// Whether the slider is disabled (e.g. geofence blocking). final bool isDisabled; @override @@ -76,57 +90,6 @@ class _SwipeToCheckInState extends State @override Widget build(BuildContext context) { 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( - color: baseColor.withValues(alpha: 0.4), - blurRadius: 25, - offset: const Offset(0, 10), - spreadRadius: -5, - ), - ], - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - 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( builder: (BuildContext context, BoxConstraints constraints) { diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart index d509a2b7..370362fc 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart @@ -4,6 +4,7 @@ import 'package:krow_core/core.dart'; import 'data/repositories_impl/clock_in_repository_impl.dart'; import 'data/services/background_geofence_service.dart'; +import 'data/services/clock_in_notification_service.dart'; import 'data/services/geofence_service_impl.dart'; import 'domain/repositories/clock_in_repository_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/get_attendance_status_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/geofence/geofence_bloc.dart'; import 'presentation/pages/clock_in_page.dart'; /// 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 { @override List get imports => [CoreModule()]; @@ -36,23 +42,50 @@ class StaffClockInModule extends Module { i.add( () => BackgroundGeofenceService( backgroundTaskService: i.get(), - notificationService: i.get(), storageService: i.get(), ), ); + // Notification Service (clock-in / clock-out / geofence notifications) + i.add( + () => ClockInNotificationService( + notificationService: i.get(), + ), + ); + // Use Cases i.add(GetTodaysShiftUseCase.new); i.add(GetAttendanceStatusUseCase.new); i.add(ClockInUseCase.new); i.add(ClockOutUseCase.new); - // BLoCs (transient -- new instance per navigation) - i.add(ClockInBloc.new); - i.add( + // Validators + i.addLazySingleton( + () => const CompositeClockInValidator([ + 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( geofenceService: i.get(), backgroundGeofenceService: i.get(), + notificationService: i.get(), + ), + ); + i.add( + () => ClockInBloc( + getTodaysShift: i.get(), + getAttendanceStatus: i.get(), + clockIn: i.get(), + clockOut: i.get(), + geofenceBloc: i.get(), + validator: i.get(), ), ); }