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:
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
30
apps/mobile/packages/core/lib/src/utils/time_utils.dart
Normal file
30
apps/mobile/packages/core/lib/src/utils/time_utils.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user