feat: Implement notification and storage services, geofence management, and BLoC for geofence verification

- Add NotificationService for handling local notifications.
- Introduce StorageService for key-value storage using SharedPreferences.
- Create DeviceLocation model to represent geographic locations.
- Define LocationPermissionStatus enum for managing location permissions.
- Implement BackgroundGeofenceService for periodic geofence checks while clocked in.
- Develop GeofenceServiceImpl for geofence proximity verification using LocationService.
- Create GeofenceResult model to encapsulate geofence check results.
- Define GeofenceServiceInterface for geofence service abstraction.
- Implement GeofenceBloc to manage geofence verification and background tracking.
- Create events and states for GeofenceBloc to handle various geofence scenarios.
- Add GeofenceStatusBanner widget to display geofence verification status in the UI.
This commit is contained in:
Achintha Isuru
2026-03-13 16:01:26 -04:00
parent 2fc6b3139e
commit 7b576c0ed4
54 changed files with 2216 additions and 493 deletions

View File

@@ -33,3 +33,7 @@ export 'src/services/device/gallery/gallery_service.dart';
export 'src/services/device/file/file_picker_service.dart';
export 'src/services/device/file_upload/device_file_upload_service.dart';
export 'src/services/device/audio/audio_recorder_service.dart';
export 'src/services/device/location/location_service.dart';
export 'src/services/device/notification/notification_service.dart';
export 'src/services/device/storage/storage_service.dart';
export 'src/services/device/background_task/background_task_service.dart';

View File

@@ -48,5 +48,13 @@ class CoreModule extends Module {
apiUploadService: i.get<FileUploadService>(),
),
);
// 6. Register Geofence Device Services
i.addLazySingleton<LocationService>(() => const LocationService());
i.addLazySingleton<NotificationService>(() => NotificationService());
i.addLazySingleton<StorageService>(() => StorageService());
i.addLazySingleton<BackgroundTaskService>(
() => const BackgroundTaskService(),
);
}
}

View File

@@ -0,0 +1,58 @@
import 'package:krow_domain/krow_domain.dart';
import 'package:workmanager/workmanager.dart';
/// Service that wraps [Workmanager] for scheduling background tasks.
class BackgroundTaskService extends BaseDeviceService {
/// Creates a [BackgroundTaskService] instance.
const BackgroundTaskService();
/// Initializes the workmanager with the given [callbackDispatcher].
Future<void> initialize(Function callbackDispatcher) async {
return action(() async {
await Workmanager().initialize(callbackDispatcher);
});
}
/// Registers a periodic background task with the given [frequency].
Future<void> registerPeriodicTask({
required String uniqueName,
required String taskName,
Duration frequency = const Duration(minutes: 15),
Map<String, dynamic>? inputData,
}) async {
return action(() async {
await Workmanager().registerPeriodicTask(
uniqueName,
taskName,
frequency: frequency,
inputData: inputData,
existingWorkPolicy: ExistingPeriodicWorkPolicy.replace,
);
});
}
/// Registers a one-off background task.
Future<void> registerOneOffTask({
required String uniqueName,
required String taskName,
Map<String, dynamic>? inputData,
}) async {
return action(() async {
await Workmanager().registerOneOffTask(
uniqueName,
taskName,
inputData: inputData,
);
});
}
/// Cancels a registered task by its [uniqueName].
Future<void> cancelByUniqueName(String uniqueName) async {
return action(() => Workmanager().cancelByUniqueName(uniqueName));
}
/// Cancels all registered background tasks.
Future<void> cancelAll() async {
return action(() => Workmanager().cancelAll());
}
}

View File

@@ -0,0 +1,110 @@
import 'dart:async';
import 'package:geolocator/geolocator.dart';
import 'package:krow_domain/krow_domain.dart';
/// Service that wraps [Geolocator] to provide location access.
///
/// This is the only file in the core package that imports geolocator.
/// All location access across the app should go through this service.
class LocationService extends BaseDeviceService {
/// Creates a [LocationService] instance.
const LocationService();
/// Checks the current permission status and requests permission if needed.
Future<LocationPermissionStatus> checkAndRequestPermission() async {
return action(() async {
final bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) return LocationPermissionStatus.serviceDisabled;
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
return _mapPermission(permission);
});
}
/// Requests upgrade to "Always" permission for background location access.
Future<LocationPermissionStatus> requestAlwaysPermission() async {
return action(() async {
// On Android, requesting permission again after whileInUse prompts
// for Always.
final LocationPermission permission = await Geolocator.requestPermission();
return _mapPermission(permission);
});
}
/// Returns the device's current location.
Future<DeviceLocation> getCurrentLocation() async {
return action(() async {
final Position position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
);
return _toDeviceLocation(position);
});
}
/// Emits location updates as a stream, filtered by [distanceFilter] meters.
Stream<DeviceLocation> watchLocation({int distanceFilter = 10}) {
return Geolocator.getPositionStream(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: distanceFilter,
),
).map(_toDeviceLocation);
}
/// Whether device location services are currently enabled.
Future<bool> isServiceEnabled() async {
return action(() => Geolocator.isLocationServiceEnabled());
}
/// Stream that emits when location service status changes.
///
/// Emits `true` when enabled, `false` when disabled.
Stream<bool> get onServiceStatusChanged {
return Geolocator.getServiceStatusStream().map(
(ServiceStatus status) => status == ServiceStatus.enabled,
);
}
/// Opens the app settings page for the user to manually grant permissions.
Future<bool> openAppSettings() async {
return action(() => Geolocator.openAppSettings());
}
/// Opens the device location settings page.
Future<bool> openLocationSettings() async {
return action(() => Geolocator.openLocationSettings());
}
/// Maps a [LocationPermission] to a [LocationPermissionStatus].
LocationPermissionStatus _mapPermission(LocationPermission permission) {
switch (permission) {
case LocationPermission.always:
return LocationPermissionStatus.granted;
case LocationPermission.whileInUse:
return LocationPermissionStatus.whileInUse;
case LocationPermission.denied:
return LocationPermissionStatus.denied;
case LocationPermission.deniedForever:
return LocationPermissionStatus.deniedForever;
case LocationPermission.unableToDetermine:
return LocationPermissionStatus.denied;
}
}
/// Converts a geolocator [Position] to a [DeviceLocation].
DeviceLocation _toDeviceLocation(Position position) {
return DeviceLocation(
latitude: position.latitude,
longitude: position.longitude,
accuracy: position.accuracy,
timestamp: position.timestamp,
);
}
}

View File

@@ -0,0 +1,66 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:krow_domain/krow_domain.dart';
/// Service that wraps [FlutterLocalNotificationsPlugin] for local notifications.
class NotificationService extends BaseDeviceService {
/// Creates a [NotificationService] with the given [plugin] instance.
///
/// If no plugin is provided, a default instance is created.
NotificationService({FlutterLocalNotificationsPlugin? plugin})
: _plugin = plugin ?? FlutterLocalNotificationsPlugin();
/// The underlying notification plugin instance.
final FlutterLocalNotificationsPlugin _plugin;
/// Initializes notification channels and requests permissions.
Future<void> initialize() async {
return action(() async {
const AndroidInitializationSettings androidSettings = AndroidInitializationSettings(
'@mipmap/ic_launcher',
);
const DarwinInitializationSettings iosSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
const InitializationSettings settings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
);
await _plugin.initialize(settings: settings);
});
}
/// Displays a local notification with the given [title] and [body].
Future<void> showNotification({
required String title,
required String body,
int id = 0,
}) async {
return action(() async {
const AndroidNotificationDetails androidDetails = AndroidNotificationDetails(
'krow_geofence',
'Geofence Notifications',
channelDescription: 'Notifications for geofence events',
importance: Importance.high,
priority: Priority.high,
);
const DarwinNotificationDetails iosDetails = DarwinNotificationDetails();
const NotificationDetails details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _plugin.show(id: id, title: title, body: body, notificationDetails: details);
});
}
/// Cancels a specific notification by [id].
Future<void> cancelNotification(int id) async {
return action(() => _plugin.cancel(id: id));
}
/// Cancels all active notifications.
Future<void> cancelAll() async {
return action(() => _plugin.cancelAll());
}
}

View File

@@ -0,0 +1,81 @@
import 'package:krow_domain/krow_domain.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Service that wraps [SharedPreferences] for key-value storage.
class StorageService extends BaseDeviceService {
/// Creates a [StorageService] instance.
StorageService();
/// Cached preferences instance.
SharedPreferences? _prefs;
/// Returns the [SharedPreferences] instance, initializing lazily.
Future<SharedPreferences> get _preferences async {
_prefs ??= await SharedPreferences.getInstance();
return _prefs!;
}
/// Retrieves a string value for the given [key].
Future<String?> getString(String key) async {
return action(() async {
final SharedPreferences prefs = await _preferences;
return prefs.getString(key);
});
}
/// Stores a string [value] for the given [key].
Future<bool> setString(String key, String value) async {
return action(() async {
final SharedPreferences prefs = await _preferences;
return prefs.setString(key, value);
});
}
/// Retrieves a double value for the given [key].
Future<double?> getDouble(String key) async {
return action(() async {
final SharedPreferences prefs = await _preferences;
return prefs.getDouble(key);
});
}
/// Stores a double [value] for the given [key].
Future<bool> setDouble(String key, double value) async {
return action(() async {
final SharedPreferences prefs = await _preferences;
return prefs.setDouble(key, value);
});
}
/// Retrieves a boolean value for the given [key].
Future<bool?> getBool(String key) async {
return action(() async {
final SharedPreferences prefs = await _preferences;
return prefs.getBool(key);
});
}
/// Stores a boolean [value] for the given [key].
Future<bool> setBool(String key, bool value) async {
return action(() async {
final SharedPreferences prefs = await _preferences;
return prefs.setBool(key, value);
});
}
/// Removes the value for the given [key].
Future<bool> remove(String key) async {
return action(() async {
final SharedPreferences prefs = await _preferences;
return prefs.remove(key);
});
}
/// Clears all stored values.
Future<bool> clear() async {
return action(() async {
final SharedPreferences prefs = await _preferences;
return prefs.clear();
});
}
}

View File

@@ -27,3 +27,7 @@ dependencies:
file_picker: ^8.1.7
record: ^6.2.0
firebase_auth: ^6.1.4
geolocator: ^14.0.2
flutter_local_notifications: ^21.0.0
shared_preferences: ^2.5.4
workmanager: ^0.9.0+3

View File

@@ -926,6 +926,28 @@
"submit": "Submit",
"success_title": "Break Logged!",
"close": "Close"
},
"geofence": {
"service_disabled": "Location services are turned off. Enable them to clock in.",
"permission_required": "Location permission is required to clock in.",
"permission_denied_forever": "Location was permanently denied. Enable it in Settings.",
"open_settings": "Open Settings",
"grant_permission": "Grant Permission",
"verifying": "Verifying your location...",
"too_far_title": "You're Too Far Away",
"too_far_desc": "You are $distance away. Move within 500m to clock in.",
"verified": "Location Verified",
"not_in_range": "You must be at the workplace to clock in.",
"timeout_title": "Can't Verify Location",
"timeout_desc": "Unable to determine your location. You can still clock in with a note.",
"timeout_note_hint": "Why can't your location be verified?",
"clock_in_greeting_title": "You're Clocked In!",
"clock_in_greeting_body": "Have a great shift. We'll keep track of your location.",
"background_left_title": "You've Left the Workplace",
"background_left_body": "You appear to be more than 500m from your shift location.",
"always_permission_title": "Background Location Needed",
"always_permission_desc": "To verify your location during shifts, please allow location access 'Always'.",
"retry": "Retry"
}
},
"availability": {
@@ -1416,6 +1438,10 @@
"application_not_found": "Your application couldn't be found.",
"no_active_shift": "You don't have an active shift to clock out from."
},
"clock_in": {
"location_verification_required": "Please wait for location verification before clocking in.",
"notes_required_for_timeout": "Please add a note explaining why your location can't be verified."
},
"generic": {
"unknown": "Something went wrong. Please try again.",
"no_connection": "No internet connection. Please check your network and try again.",

View File

@@ -921,6 +921,28 @@
"submit": "Enviar",
"success_title": "\u00a1Descanso registrado!",
"close": "Cerrar"
},
"geofence": {
"service_disabled": "Los servicios de ubicación están desactivados. Actívelos para registrar entrada.",
"permission_required": "Se requiere permiso de ubicación para registrar entrada.",
"permission_denied_forever": "La ubicación fue denegada permanentemente. Actívela en Configuración.",
"open_settings": "Abrir Configuración",
"grant_permission": "Otorgar Permiso",
"verifying": "Verificando su ubicación...",
"too_far_title": "Está Demasiado Lejos",
"too_far_desc": "Está a $distance de distancia. Acérquese a 500m para registrar entrada.",
"verified": "Ubicación Verificada",
"not_in_range": "Debe estar en el lugar de trabajo para registrar entrada.",
"timeout_title": "No se Puede Verificar la Ubicación",
"timeout_desc": "No se pudo determinar su ubicación. Puede registrar entrada con una nota.",
"timeout_note_hint": "¿Por qué no se puede verificar su ubicación?",
"clock_in_greeting_title": "¡Entrada Registrada!",
"clock_in_greeting_body": "Buen turno. Seguiremos el registro de su ubicación.",
"background_left_title": "Ha Salido del Lugar de Trabajo",
"background_left_body": "Parece que está a más de 500m de la ubicación de su turno.",
"always_permission_title": "Se Necesita Ubicación en Segundo Plano",
"always_permission_desc": "Para verificar su ubicación durante los turnos, permita el acceso a la ubicación 'Siempre'.",
"retry": "Reintentar"
}
},
"availability": {
@@ -1411,6 +1433,10 @@
"application_not_found": "No se pudo encontrar tu solicitud.",
"no_active_shift": "No tienes un turno activo para registrar salida."
},
"clock_in": {
"location_verification_required": "Por favor, espera la verificaci\u00f3n de ubicaci\u00f3n antes de registrar entrada.",
"notes_required_for_timeout": "Por favor, agrega una nota explicando por qu\u00e9 no se puede verificar tu ubicaci\u00f3n."
},
"generic": {
"unknown": "Algo sali\u00f3 mal. Por favor, intenta de nuevo.",
"no_connection": "Sin conexi\u00f3n a internet. Por favor, verifica tu red e intenta de nuevo.",

View File

@@ -35,6 +35,8 @@ String translateErrorKey(String key) {
return _translateProfileError(errorType);
case 'shift':
return _translateShiftError(errorType);
case 'clock_in':
return _translateClockInError(errorType);
case 'generic':
return _translateGenericError(errorType);
default:
@@ -127,6 +129,18 @@ String _translateShiftError(String errorType) {
}
}
/// Translates clock-in error keys to localized strings.
String _translateClockInError(String errorType) {
switch (errorType) {
case 'location_verification_required':
return t.errors.clock_in.location_verification_required;
case 'notes_required_for_timeout':
return t.errors.clock_in.notes_required_for_timeout;
default:
return t.errors.generic.unknown;
}
}
String _translateGenericError(String errorType) {
switch (errorType) {
case 'unknown':

View File

@@ -14,6 +14,10 @@ export 'src/core/services/api_services/file_visibility.dart';
// Device
export 'src/core/services/device/base_device_service.dart';
export 'src/core/services/device/location_permission_status.dart';
// Models
export 'src/core/models/device_location.dart';
// Users & Membership
export 'src/entities/users/user.dart';

View File

@@ -0,0 +1,27 @@
import 'package:equatable/equatable.dart';
/// Represents a geographic location obtained from the device.
class DeviceLocation extends Equatable {
/// Latitude in degrees.
final double latitude;
/// Longitude in degrees.
final double longitude;
/// Estimated horizontal accuracy in meters.
final double accuracy;
/// Time when this location was determined.
final DateTime timestamp;
/// Creates a [DeviceLocation] instance.
const DeviceLocation({
required this.latitude,
required this.longitude,
required this.accuracy,
required this.timestamp,
});
@override
List<Object?> get props => [latitude, longitude, accuracy, timestamp];
}

View File

@@ -0,0 +1,17 @@
/// Represents the current state of location permission granted by the user.
enum LocationPermissionStatus {
/// Full location access granted.
granted,
/// Location access granted only while the app is in use.
whileInUse,
/// Location permission was denied by the user.
denied,
/// Location permission was permanently denied by the user.
deniedForever,
/// Device location services are disabled.
serviceDisabled,
}

View File

@@ -10,6 +10,9 @@ import file_selector_macos
import firebase_app_check
import firebase_auth
import firebase_core
import flutter_local_notifications
import geolocator_apple
import package_info_plus
import record_macos
import shared_preferences_foundation
@@ -19,6 +22,9 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
}

View File

@@ -9,6 +9,7 @@
#include <file_selector_windows/file_selector_windows.h>
#include <firebase_auth/firebase_auth_plugin_c_api.h>
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <geolocator_windows/geolocator_windows.h>
#include <record_windows/record_windows_plugin_c_api.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
@@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
GeolocatorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GeolocatorWindows"));
RecordWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
}

View File

@@ -6,10 +6,12 @@ list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
firebase_auth
firebase_core
geolocator_windows
record_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
flutter_local_notifications_windows
)
set(PLUGIN_BUNDLED_LIBRARIES)

View File

@@ -0,0 +1,121 @@
import 'package:krow_core/core.dart';
/// Service that manages periodic background geofence checks while clocked in.
///
/// Uses core services exclusively -- no direct imports of workmanager,
/// flutter_local_notifications, or shared_preferences.
class BackgroundGeofenceService {
/// The core background task service for scheduling periodic work.
final BackgroundTaskService _backgroundTaskService;
/// The core notification service for displaying local notifications.
final NotificationService _notificationService;
/// The core storage service for persisting geofence target data.
final StorageService _storageService;
/// Storage key for the target latitude.
static const _keyTargetLat = 'geofence_target_lat';
/// Storage key for the target longitude.
static const _keyTargetLng = 'geofence_target_lng';
/// Storage key for the shift identifier.
static const _keyShiftId = 'geofence_shift_id';
/// Storage key for the active tracking flag.
static const _keyTrackingActive = 'geofence_tracking_active';
/// Unique task name for the periodic background check.
static const taskUniqueName = 'geofence_background_check';
/// Task name identifier for the workmanager callback.
static const taskName = 'geofenceCheck';
/// Notification ID for clock-in greeting notifications.
static const _clockInNotificationId = 1;
/// Notification ID for left-geofence warnings.
static const _leftGeofenceNotificationId = 2;
/// Creates a [BackgroundGeofenceService] instance.
BackgroundGeofenceService({
required BackgroundTaskService backgroundTaskService,
required NotificationService notificationService,
required StorageService storageService,
}) : _backgroundTaskService = backgroundTaskService,
_notificationService = notificationService,
_storageService = storageService;
/// Starts periodic 15-minute background geofence checks.
///
/// Called after a successful clock-in. Persists the target coordinates
/// so the background isolate can access them.
Future<void> startBackgroundTracking({
required double targetLat,
required double targetLng,
required String shiftId,
}) async {
await Future.wait([
_storageService.setDouble(_keyTargetLat, targetLat),
_storageService.setDouble(_keyTargetLng, targetLng),
_storageService.setString(_keyShiftId, shiftId),
_storageService.setBool(_keyTrackingActive, true),
]);
await _backgroundTaskService.registerPeriodicTask(
uniqueName: taskUniqueName,
taskName: taskName,
frequency: const Duration(minutes: 15),
inputData: {
'targetLat': targetLat,
'targetLng': targetLng,
'shiftId': shiftId,
},
);
}
/// Stops background geofence checks and clears persisted data.
///
/// Called after clock-out or when the shift ends.
Future<void> stopBackgroundTracking() async {
await _backgroundTaskService.cancelByUniqueName(taskUniqueName);
await Future.wait([
_storageService.remove(_keyTargetLat),
_storageService.remove(_keyTargetLng),
_storageService.remove(_keyShiftId),
_storageService.setBool(_keyTrackingActive, false),
]);
}
/// Whether background tracking is currently active.
Future<bool> get isTrackingActive async {
final active = await _storageService.getBool(_keyTrackingActive);
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,
);
}
}

View File

@@ -0,0 +1,136 @@
import 'dart:async';
import 'dart:math';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/models/geofence_result.dart';
import '../../domain/services/geofence_service_interface.dart';
/// Implementation of [GeofenceServiceInterface] using core [LocationService].
class GeofenceServiceImpl implements GeofenceServiceInterface {
/// The core location service for device GPS access.
final LocationService _locationService;
/// When true, always reports the device as within radius. For dev builds.
final bool debugAlwaysInRange;
/// Average walking speed in meters per minute for ETA estimation.
static const double _walkingSpeedMetersPerMinute = 80;
/// Creates a [GeofenceServiceImpl] instance.
GeofenceServiceImpl({
required LocationService locationService,
this.debugAlwaysInRange = false,
}) : _locationService = locationService;
@override
Future<LocationPermissionStatus> ensurePermission() {
return _locationService.checkAndRequestPermission();
}
@override
Future<LocationPermissionStatus> requestAlwaysPermission() {
return _locationService.requestAlwaysPermission();
}
@override
Stream<GeofenceResult> watchGeofence({
required double targetLat,
required double targetLng,
double radiusMeters = 500,
}) {
return _locationService.watchLocation(distanceFilter: 10).map(
(location) => _buildResult(
location: location,
targetLat: targetLat,
targetLng: targetLng,
radiusMeters: radiusMeters,
),
);
}
@override
Future<GeofenceResult?> checkGeofenceWithTimeout({
required double targetLat,
required double targetLng,
double radiusMeters = 500,
Duration timeout = const Duration(seconds: 30),
}) async {
try {
final location =
await _locationService.getCurrentLocation().timeout(timeout);
return _buildResult(
location: location,
targetLat: targetLat,
targetLng: targetLng,
radiusMeters: radiusMeters,
);
} on TimeoutException {
return null;
}
}
@override
Stream<bool> watchServiceStatus() {
return _locationService.onServiceStatusChanged;
}
@override
Future<void> openAppSettings() async {
await _locationService.openAppSettings();
}
@override
Future<void> openLocationSettings() async {
await _locationService.openLocationSettings();
}
/// Builds a [GeofenceResult] from a location and target coordinates.
GeofenceResult _buildResult({
required DeviceLocation location,
required double targetLat,
required double targetLng,
required double radiusMeters,
}) {
final distance = _calculateDistance(
location.latitude,
location.longitude,
targetLat,
targetLng,
);
final isWithin = debugAlwaysInRange || distance <= radiusMeters;
final eta =
isWithin ? 0 : (distance / _walkingSpeedMetersPerMinute).round();
return GeofenceResult(
distanceMeters: distance,
isWithinRadius: isWithin,
estimatedEtaMinutes: eta,
location: location,
);
}
/// Haversine formula for distance between two coordinates in meters.
double _calculateDistance(
double lat1,
double lng1,
double lat2,
double lng2,
) {
const earthRadius = 6371000.0;
final dLat = _toRadians(lat2 - lat1);
final dLng = _toRadians(lng2 - lng1);
final a = sin(dLat / 2) * sin(dLat / 2) +
cos(_toRadians(lat1)) *
cos(_toRadians(lat2)) *
sin(dLng / 2) *
sin(dLng / 2);
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
return earthRadius * c;
}
/// Converts degrees to radians.
double _toRadians(double degrees) => degrees * pi / 180;
}

View File

@@ -0,0 +1,33 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
/// Result of a geofence proximity check.
class GeofenceResult extends Equatable {
/// Distance from the target location in meters.
final double distanceMeters;
/// Whether the device is within the allowed geofence radius.
final bool isWithinRadius;
/// Estimated time of arrival in minutes if outside the radius.
final int estimatedEtaMinutes;
/// The device location at the time of the check.
final DeviceLocation location;
/// Creates a [GeofenceResult] instance.
const GeofenceResult({
required this.distanceMeters,
required this.isWithinRadius,
required this.estimatedEtaMinutes,
required this.location,
});
@override
List<Object?> get props => [
distanceMeters,
isWithinRadius,
estimatedEtaMinutes,
location,
];
}

View File

@@ -0,0 +1,36 @@
import 'package:krow_domain/krow_domain.dart';
import '../models/geofence_result.dart';
/// Interface for geofence proximity verification.
abstract class GeofenceServiceInterface {
/// Checks and requests location permission.
Future<LocationPermissionStatus> ensurePermission();
/// Requests upgrade to "Always" permission for background access.
Future<LocationPermissionStatus> requestAlwaysPermission();
/// Emits geofence results as the device moves relative to a target.
Stream<GeofenceResult> watchGeofence({
required double targetLat,
required double targetLng,
double radiusMeters = 500,
});
/// Checks geofence once with a timeout. Returns null if GPS times out.
Future<GeofenceResult?> checkGeofenceWithTimeout({
required double targetLat,
required double targetLng,
double radiusMeters = 500,
Duration timeout = const Duration(seconds: 30),
});
/// Stream of location service status changes (enabled/disabled).
Stream<bool> watchServiceStatus();
/// Opens the app settings page.
Future<void> openAppSettings();
/// Opens the device location settings page.
Future<void> openLocationSettings();
}

View File

@@ -1,5 +1,4 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:geolocator/geolocator.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
@@ -12,8 +11,13 @@ import '../../domain/usecases/get_todays_shift_usecase.dart';
import 'clock_in_event.dart';
import 'clock_in_state.dart';
/// BLoC responsible for clock-in/clock-out operations and shift management.
///
/// Location and geofence concerns are delegated to [GeofenceBloc].
/// The UI bridges geofence state into [CheckInRequested] event parameters.
class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
with BlocErrorHandler<ClockInState> {
/// Creates a [ClockInBloc] with the required use cases.
ClockInBloc({
required GetTodaysShiftUseCase getTodaysShift,
required GetAttendanceStatusUseCase getAttendanceStatus,
@@ -30,20 +34,16 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
on<CheckInRequested>(_onCheckIn);
on<CheckOutRequested>(_onCheckOut);
on<CheckInModeChanged>(_onModeChanged);
on<RequestLocationPermission>(_onRequestLocationPermission);
on<CommuteModeToggled>(_onCommuteModeToggled);
on<LocationUpdated>(_onLocationUpdated);
add(ClockInPageLoaded());
}
final GetTodaysShiftUseCase _getTodaysShift;
final GetAttendanceStatusUseCase _getAttendanceStatus;
final ClockInUseCase _clockIn;
final ClockOutUseCase _clockOut;
// Mock Venue Location (e.g., Grand Hotel, NYC)
static const double allowedRadiusMeters = 500;
/// Loads today's shifts and the current attendance status.
Future<void> _onLoaded(
ClockInPageLoaded event,
Emitter<ClockInState> emit,
@@ -72,10 +72,6 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
selectedShift: selectedShift,
attendance: status,
));
if (selectedShift != null && !status.isCheckedIn) {
add(RequestLocationPermission());
}
},
onError: (String errorKey) => state.copyWith(
status: ClockInStatus.failure,
@@ -84,106 +80,15 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
);
}
Future<void> _onRequestLocationPermission(
RequestLocationPermission event,
Emitter<ClockInState> emit,
) async {
await handleError(
emit: emit.call,
action: () async {
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
final bool hasConsent =
permission == LocationPermission.always ||
permission == LocationPermission.whileInUse;
emit(state.copyWith(hasLocationConsent: hasConsent));
if (hasConsent) {
await _startLocationUpdates();
}
},
onError: (String errorKey) => state.copyWith(
errorMessage: errorKey,
),
);
}
Future<void> _startLocationUpdates() async {
// Note: handleErrorWithResult could be used here too if we want centralized logging/conversion
try {
final Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
);
double distance = 0;
bool isVerified =
false; // Require location match by default if shift has location
if (state.selectedShift != null &&
state.selectedShift!.latitude != null &&
state.selectedShift!.longitude != null) {
distance = Geolocator.distanceBetween(
position.latitude,
position.longitude,
state.selectedShift!.latitude!,
state.selectedShift!.longitude!,
);
isVerified = distance <= allowedRadiusMeters;
} else {
isVerified = true;
}
if (!isClosed) {
add(
LocationUpdated(
position: position,
distance: distance,
isVerified: isVerified,
),
);
}
} catch (_) {
// Geolocator errors usually handled via onRequestLocationPermission
}
}
void _onLocationUpdated(
LocationUpdated event,
Emitter<ClockInState> emit,
) {
emit(state.copyWith(
currentLocation: event.position,
distanceFromVenue: event.distance,
isLocationVerified: event.isVerified,
etaMinutes:
(event.distance / 80).round(), // Rough estimate: 80m/min walking speed
));
}
void _onCommuteModeToggled(
CommuteModeToggled event,
Emitter<ClockInState> emit,
) {
emit(state.copyWith(isCommuteModeOn: event.isEnabled));
if (event.isEnabled) {
add(RequestLocationPermission());
}
}
/// Updates the currently selected shift.
void _onShiftSelected(
ShiftSelected event,
Emitter<ClockInState> emit,
) {
emit(state.copyWith(selectedShift: event.shift));
if (!state.attendance.isCheckedIn) {
_startLocationUpdates();
}
}
/// Updates the selected date for shift viewing.
void _onDateSelected(
DateSelected event,
Emitter<ClockInState> emit,
@@ -191,6 +96,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
emit(state.copyWith(selectedDate: event.date));
}
/// Updates the check-in interaction mode.
void _onModeChanged(
CheckInModeChanged event,
Emitter<ClockInState> emit,
@@ -198,10 +104,44 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
emit(state.copyWith(checkInMode: event.mode));
}
/// Handles a clock-in request.
///
/// Geofence state is passed via event parameters from the UI layer:
/// - If the shift has a venue (lat/lng) and location is neither verified
/// nor timed out, the clock-in is rejected.
/// - If the location timed out, notes are required to proceed.
/// - Otherwise the clock-in proceeds normally.
Future<void> _onCheckIn(
CheckInRequested event,
Emitter<ClockInState> emit,
) async {
final Shift? shift = state.selectedShift;
final bool shiftHasLocation =
shift != null && shift.latitude != null && shift.longitude != null;
// If the shift requires location verification but geofence has not
// confirmed proximity and has not timed out, reject the attempt.
if (shiftHasLocation &&
!event.isLocationVerified &&
!event.isLocationTimedOut) {
emit(state.copyWith(
status: ClockInStatus.failure,
errorMessage: 'errors.clock_in.location_verification_required',
));
return;
}
// When location timed out, require the user to provide notes explaining
// why they are clocking in without verified proximity.
if (event.isLocationTimedOut &&
(event.notes == null || event.notes!.trim().isEmpty)) {
emit(state.copyWith(
status: ClockInStatus.failure,
errorMessage: 'errors.clock_in.notes_required_for_timeout',
));
return;
}
emit(state.copyWith(status: ClockInStatus.actionInProgress));
await handleError(
emit: emit.call,
@@ -221,6 +161,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
);
}
/// Handles a clock-out request.
Future<void> _onCheckOut(
CheckOutRequested event,
Emitter<ClockInState> emit,

View File

@@ -1,7 +1,7 @@
import 'package:equatable/equatable.dart';
import 'package:geolocator/geolocator.dart';
import 'package:krow_domain/krow_domain.dart';
/// Base class for all clock-in related events.
abstract class ClockInEvent extends Equatable {
const ClockInEvent();
@@ -9,72 +9,81 @@ abstract class ClockInEvent extends Equatable {
List<Object?> get props => <Object?>[];
}
/// Emitted when the clock-in page is first loaded.
class ClockInPageLoaded extends ClockInEvent {}
/// Emitted when the user selects a shift from the list.
class ShiftSelected extends ClockInEvent {
const ShiftSelected(this.shift);
/// The shift the user selected.
final Shift shift;
@override
List<Object?> get props => <Object?>[shift];
}
/// Emitted when the user picks a different date.
class DateSelected extends ClockInEvent {
const DateSelected(this.date);
/// The newly selected date.
final DateTime date;
@override
List<Object?> get props => <Object?>[date];
}
/// Emitted when the user requests to clock in.
///
/// [isLocationVerified] and [isLocationTimedOut] are provided by the UI layer
/// from the GeofenceBloc state, bridging the two BLoCs.
class CheckInRequested extends ClockInEvent {
const CheckInRequested({
required this.shiftId,
this.notes,
this.isLocationVerified = false,
this.isLocationTimedOut = false,
});
const CheckInRequested({required this.shiftId, this.notes});
/// The ID of the shift to clock into.
final String shiftId;
/// Optional notes provided by the user.
final String? notes;
/// Whether the geofence verification passed (user is within radius).
final bool isLocationVerified;
/// Whether the geofence verification timed out (GPS unavailable).
final bool isLocationTimedOut;
@override
List<Object?> get props => <Object?>[shiftId, notes];
List<Object?> get props =>
<Object?>[shiftId, notes, isLocationVerified, isLocationTimedOut];
}
/// Emitted when the user requests to clock out.
class CheckOutRequested extends ClockInEvent {
const CheckOutRequested({this.notes, this.breakTimeMinutes});
/// Optional notes provided by the user.
final String? notes;
/// Break time taken during the shift, in minutes.
final int? breakTimeMinutes;
@override
List<Object?> get props => <Object?>[notes, breakTimeMinutes];
}
/// Emitted when the user changes the check-in mode (e.g. swipe vs tap).
class CheckInModeChanged extends ClockInEvent {
const CheckInModeChanged(this.mode);
/// The new check-in mode identifier.
final String mode;
@override
List<Object?> get props => <Object?>[mode];
}
class CommuteModeToggled extends ClockInEvent {
const CommuteModeToggled(this.isEnabled);
final bool isEnabled;
@override
List<Object?> get props => <Object?>[isEnabled];
}
class RequestLocationPermission extends ClockInEvent {}
class LocationUpdated extends ClockInEvent {
const LocationUpdated({required this.position, required this.distance, required this.isVerified});
final Position position;
final double distance;
final bool isVerified;
@override
List<Object?> get props => <Object?>[position, distance, isVerified];
}

View File

@@ -1,12 +1,15 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:geolocator/geolocator.dart';
/// Represents the possible statuses of the clock-in page.
enum ClockInStatus { initial, loading, success, failure, actionInProgress }
/// State for the [ClockInBloc].
///
/// Contains today's shifts, the selected shift, attendance status,
/// and clock-in UI configuration. Location/geofence concerns are
/// managed separately by [GeofenceBloc].
class ClockInState extends Equatable {
const ClockInState({
this.status = ClockInStatus.initial,
this.todayShifts = const <Shift>[],
@@ -15,28 +18,30 @@ class ClockInState extends Equatable {
required this.selectedDate,
this.checkInMode = 'swipe',
this.errorMessage,
this.currentLocation,
this.distanceFromVenue,
this.isLocationVerified = false,
this.isCommuteModeOn = false,
this.hasLocationConsent = false,
this.etaMinutes,
});
/// Current page status.
final ClockInStatus status;
/// List of shifts scheduled for the selected date.
final List<Shift> todayShifts;
/// The shift currently selected by the user.
final Shift? selectedShift;
/// Current attendance/check-in status from the backend.
final AttendanceStatus attendance;
/// The date the user is viewing shifts for.
final DateTime selectedDate;
/// The current check-in interaction mode (e.g. 'swipe').
final String checkInMode;
/// Error message key for displaying failures.
final String? errorMessage;
final Position? currentLocation;
final double? distanceFromVenue;
final bool isLocationVerified;
final bool isCommuteModeOn;
final bool hasLocationConsent;
final int? etaMinutes;
/// Creates a copy of this state with the given fields replaced.
ClockInState copyWith({
ClockInStatus? status,
List<Shift>? todayShifts,
@@ -45,12 +50,6 @@ class ClockInState extends Equatable {
DateTime? selectedDate,
String? checkInMode,
String? errorMessage,
Position? currentLocation,
double? distanceFromVenue,
bool? isLocationVerified,
bool? isCommuteModeOn,
bool? hasLocationConsent,
int? etaMinutes,
}) {
return ClockInState(
status: status ?? this.status,
@@ -60,12 +59,6 @@ class ClockInState extends Equatable {
selectedDate: selectedDate ?? this.selectedDate,
checkInMode: checkInMode ?? this.checkInMode,
errorMessage: errorMessage,
currentLocation: currentLocation ?? this.currentLocation,
distanceFromVenue: distanceFromVenue ?? this.distanceFromVenue,
isLocationVerified: isLocationVerified ?? this.isLocationVerified,
isCommuteModeOn: isCommuteModeOn ?? this.isCommuteModeOn,
hasLocationConsent: hasLocationConsent ?? this.hasLocationConsent,
etaMinutes: etaMinutes ?? this.etaMinutes,
);
}
@@ -78,11 +71,5 @@ class ClockInState extends Equatable {
selectedDate,
checkInMode,
errorMessage,
currentLocation,
distanceFromVenue,
isLocationVerified,
isCommuteModeOn,
hasLocationConsent,
etaMinutes,
];
}

View File

@@ -0,0 +1,262 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../data/services/background_geofence_service.dart';
import '../../domain/models/geofence_result.dart';
import '../../domain/services/geofence_service_interface.dart';
import 'geofence_event.dart';
import 'geofence_state.dart';
/// BLoC that manages geofence verification and background tracking.
///
/// Handles foreground location stream monitoring, GPS timeout fallback,
/// and background periodic checks while clocked in.
class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
with
BlocErrorHandler<GeofenceState>,
SafeBloc<GeofenceEvent, GeofenceState> {
/// Creates a [GeofenceBloc] instance.
GeofenceBloc({
required GeofenceServiceInterface geofenceService,
required BackgroundGeofenceService backgroundGeofenceService,
}) : _geofenceService = geofenceService,
_backgroundGeofenceService = backgroundGeofenceService,
super(const GeofenceState.initial()) {
on<GeofenceStarted>(_onStarted);
on<GeofenceResultUpdated>(_onResultUpdated);
on<GeofenceTimeoutReached>(_onTimeout);
on<GeofenceServiceStatusChanged>(_onServiceStatusChanged);
on<GeofenceRetryRequested>(_onRetry);
on<BackgroundTrackingStarted>(_onBackgroundTrackingStarted);
on<BackgroundTrackingStopped>(_onBackgroundTrackingStopped);
on<GeofenceStopped>(_onStopped);
}
/// The geofence service for foreground proximity checks.
final GeofenceServiceInterface _geofenceService;
/// The background service for periodic tracking while clocked in.
final BackgroundGeofenceService _backgroundGeofenceService;
/// Active subscription to the foreground geofence location stream.
StreamSubscription<GeofenceResult>? _geofenceSubscription;
/// Active subscription to the location service status stream.
StreamSubscription<bool>? _serviceStatusSubscription;
/// Handles the [GeofenceStarted] event by requesting permission, performing
/// an initial geofence check, and starting the foreground location stream.
Future<void> _onStarted(
GeofenceStarted event,
Emitter<GeofenceState> emit,
) async {
emit(state.copyWith(
isVerifying: true,
targetLat: event.targetLat,
targetLng: event.targetLng,
));
await handleError(
emit: emit.call,
action: () async {
// Check permission first.
final permission = await _geofenceService.ensurePermission();
emit(state.copyWith(permissionStatus: permission));
if (permission == LocationPermissionStatus.denied ||
permission == LocationPermissionStatus.deniedForever ||
permission == LocationPermissionStatus.serviceDisabled) {
emit(state.copyWith(
isVerifying: false,
isLocationServiceEnabled:
permission != LocationPermissionStatus.serviceDisabled,
));
return;
}
// Start monitoring location service status changes.
await _serviceStatusSubscription?.cancel();
_serviceStatusSubscription =
_geofenceService.watchServiceStatus().listen((isEnabled) {
add(GeofenceServiceStatusChanged(isEnabled));
});
// Get initial position with a 30s timeout.
final result = await _geofenceService.checkGeofenceWithTimeout(
targetLat: event.targetLat,
targetLng: event.targetLng,
);
if (result == null) {
add(const GeofenceTimeoutReached());
} else {
add(GeofenceResultUpdated(result));
}
// Start continuous foreground location stream.
await _geofenceSubscription?.cancel();
_geofenceSubscription = _geofenceService
.watchGeofence(
targetLat: event.targetLat,
targetLng: event.targetLng,
)
.listen(
(result) => add(GeofenceResultUpdated(result)),
);
},
onError: (String errorKey) => state.copyWith(
isVerifying: false,
),
);
}
/// Handles the [GeofenceResultUpdated] event by updating the state with
/// the latest location and distance data.
void _onResultUpdated(
GeofenceResultUpdated event,
Emitter<GeofenceState> emit,
) {
emit(state.copyWith(
isVerifying: false,
isLocationTimedOut: false,
currentLocation: event.result.location,
distanceFromTarget: event.result.distanceMeters,
isLocationVerified: event.result.isWithinRadius,
isLocationServiceEnabled: true,
));
}
/// Handles the [GeofenceTimeoutReached] event by marking the state as
/// timed out.
void _onTimeout(
GeofenceTimeoutReached event,
Emitter<GeofenceState> emit,
) {
emit(state.copyWith(
isVerifying: false,
isLocationTimedOut: true,
));
}
/// Handles the [GeofenceServiceStatusChanged] event. If services are
/// re-enabled after a timeout, automatically retries the check.
Future<void> _onServiceStatusChanged(
GeofenceServiceStatusChanged event,
Emitter<GeofenceState> emit,
) async {
emit(state.copyWith(isLocationServiceEnabled: event.isEnabled));
// If service re-enabled and we were timed out, retry automatically.
if (event.isEnabled && state.isLocationTimedOut) {
add(const GeofenceRetryRequested());
}
}
/// Handles the [GeofenceRetryRequested] event by re-checking the geofence
/// with the stored target coordinates.
Future<void> _onRetry(
GeofenceRetryRequested event,
Emitter<GeofenceState> emit,
) async {
if (state.targetLat == null || state.targetLng == null) return;
emit(state.copyWith(
isVerifying: true,
isLocationTimedOut: false,
));
await handleError(
emit: emit.call,
action: () async {
final result = await _geofenceService.checkGeofenceWithTimeout(
targetLat: state.targetLat!,
targetLng: state.targetLng!,
);
if (result == null) {
add(const GeofenceTimeoutReached());
} else {
add(GeofenceResultUpdated(result));
}
},
onError: (String errorKey) => state.copyWith(
isVerifying: false,
),
);
}
/// Handles the [BackgroundTrackingStarted] event by requesting "Always"
/// permission and starting periodic background checks.
Future<void> _onBackgroundTrackingStarted(
BackgroundTrackingStarted event,
Emitter<GeofenceState> emit,
) async {
await handleError(
emit: emit.call,
action: () async {
// Request upgrade to "Always" permission for background tracking.
final permission = await _geofenceService.requestAlwaysPermission();
emit(state.copyWith(permissionStatus: permission));
// Start background tracking regardless (degrades gracefully).
await _backgroundGeofenceService.startBackgroundTracking(
targetLat: event.targetLat,
targetLng: event.targetLng,
shiftId: event.shiftId,
);
// Show greeting notification using localized strings from the UI.
await _backgroundGeofenceService.showClockInGreetingNotification(
title: event.greetingTitle,
body: event.greetingBody,
);
emit(state.copyWith(isBackgroundTrackingActive: true));
},
onError: (String errorKey) => state.copyWith(
isBackgroundTrackingActive: false,
),
);
}
/// Handles the [BackgroundTrackingStopped] event by stopping background
/// tracking.
Future<void> _onBackgroundTrackingStopped(
BackgroundTrackingStopped event,
Emitter<GeofenceState> emit,
) async {
await handleError(
emit: emit.call,
action: () async {
await _backgroundGeofenceService.stopBackgroundTracking();
emit(state.copyWith(isBackgroundTrackingActive: false));
},
onError: (String errorKey) => state.copyWith(
isBackgroundTrackingActive: false,
),
);
}
/// Handles the [GeofenceStopped] event by cancelling all subscriptions
/// and resetting the state.
Future<void> _onStopped(
GeofenceStopped event,
Emitter<GeofenceState> emit,
) async {
await _geofenceSubscription?.cancel();
_geofenceSubscription = null;
await _serviceStatusSubscription?.cancel();
_serviceStatusSubscription = null;
emit(const GeofenceState.initial());
}
@override
Future<void> close() {
_geofenceSubscription?.cancel();
_serviceStatusSubscription?.cancel();
return super.close();
}
}

View File

@@ -0,0 +1,106 @@
import 'package:equatable/equatable.dart';
import '../../domain/models/geofence_result.dart';
/// Base event for the [GeofenceBloc].
abstract class GeofenceEvent extends Equatable {
/// Creates a [GeofenceEvent].
const GeofenceEvent();
@override
List<Object?> get props => <Object?>[];
}
/// Starts foreground geofence verification for a target location.
class GeofenceStarted extends GeofenceEvent {
/// Target latitude of the shift location.
final double targetLat;
/// Target longitude of the shift location.
final double targetLng;
/// Creates a [GeofenceStarted] event.
const GeofenceStarted({required this.targetLat, required this.targetLng});
@override
List<Object?> get props => <Object?>[targetLat, targetLng];
}
/// Emitted when a new geofence result is received from the location stream.
class GeofenceResultUpdated extends GeofenceEvent {
/// The latest geofence check result.
final GeofenceResult result;
/// Creates a [GeofenceResultUpdated] event.
const GeofenceResultUpdated(this.result);
@override
List<Object?> get props => <Object?>[result];
}
/// Emitted when the GPS timeout (30s) is reached without a location fix.
class GeofenceTimeoutReached extends GeofenceEvent {
/// Creates a [GeofenceTimeoutReached] event.
const GeofenceTimeoutReached();
}
/// Emitted when the device location service status changes.
class GeofenceServiceStatusChanged extends GeofenceEvent {
/// Whether location services are now enabled.
final bool isEnabled;
/// Creates a [GeofenceServiceStatusChanged] event.
const GeofenceServiceStatusChanged(this.isEnabled);
@override
List<Object?> get props => <Object?>[isEnabled];
}
/// User manually requests a geofence re-check.clock_in_body.dart
class GeofenceRetryRequested extends GeofenceEvent {
/// Creates a [GeofenceRetryRequested] event.
const GeofenceRetryRequested();
}
/// Starts background tracking after successful clock-in.
class BackgroundTrackingStarted extends GeofenceEvent {
/// The shift ID being tracked.
final String shiftId;
/// Target latitude of the shift location.
final double targetLat;
/// Target longitude of the shift location.
final double targetLng;
/// Localized greeting notification title passed from the UI layer.
final String greetingTitle;
/// Localized greeting notification body passed from the UI layer.
final String greetingBody;
/// Creates a [BackgroundTrackingStarted] event.
const BackgroundTrackingStarted({
required this.shiftId,
required this.targetLat,
required this.targetLng,
required this.greetingTitle,
required this.greetingBody,
});
@override
List<Object?> get props =>
<Object?>[shiftId, targetLat, targetLng, greetingTitle, greetingBody];
}
/// Stops background tracking after clock-out.
class BackgroundTrackingStopped extends GeofenceEvent {
/// Creates a [BackgroundTrackingStopped] event.
const BackgroundTrackingStopped();
}
/// Stops all geofence monitoring (foreground and background).
class GeofenceStopped extends GeofenceEvent {
/// Creates a [GeofenceStopped] event.
const GeofenceStopped();
}

View File

@@ -0,0 +1,95 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
/// State for the [GeofenceBloc].
class GeofenceState extends Equatable {
/// Creates a [GeofenceState] instance.
const GeofenceState({
this.permissionStatus,
this.isLocationServiceEnabled = true,
this.currentLocation,
this.distanceFromTarget,
this.isLocationVerified = false,
this.isLocationTimedOut = false,
this.isVerifying = false,
this.isBackgroundTrackingActive = false,
this.targetLat,
this.targetLng,
});
/// Current location permission status.
final LocationPermissionStatus? permissionStatus;
/// Whether device location services are enabled.
final bool isLocationServiceEnabled;
/// The device's current location, if available.
final DeviceLocation? currentLocation;
/// Distance from the target location in meters.
final double? distanceFromTarget;
/// Whether the device is within the 500m geofence radius.
final bool isLocationVerified;
/// Whether GPS timed out trying to get a fix.
final bool isLocationTimedOut;
/// Whether the BLoC is actively verifying location.
final bool isVerifying;
/// Whether background tracking is active.
final bool isBackgroundTrackingActive;
/// Target latitude being monitored.
final double? targetLat;
/// Target longitude being monitored.
final double? targetLng;
/// Initial state before any geofence operations.
const GeofenceState.initial() : this();
/// Creates a copy with the given fields replaced.
GeofenceState copyWith({
LocationPermissionStatus? permissionStatus,
bool? isLocationServiceEnabled,
DeviceLocation? currentLocation,
double? distanceFromTarget,
bool? isLocationVerified,
bool? isLocationTimedOut,
bool? isVerifying,
bool? isBackgroundTrackingActive,
double? targetLat,
double? targetLng,
}) {
return GeofenceState(
permissionStatus: permissionStatus ?? this.permissionStatus,
isLocationServiceEnabled:
isLocationServiceEnabled ?? this.isLocationServiceEnabled,
currentLocation: currentLocation ?? this.currentLocation,
distanceFromTarget: distanceFromTarget ?? this.distanceFromTarget,
isLocationVerified: isLocationVerified ?? this.isLocationVerified,
isLocationTimedOut: isLocationTimedOut ?? this.isLocationTimedOut,
isVerifying: isVerifying ?? this.isVerifying,
isBackgroundTrackingActive:
isBackgroundTrackingActive ?? this.isBackgroundTrackingActive,
targetLat: targetLat ?? this.targetLat,
targetLng: targetLng ?? this.targetLng,
);
}
@override
List<Object?> get props => <Object?>[
permissionStatus,
isLocationServiceEnabled,
currentLocation,
distanceFromTarget,
isLocationVerified,
isLocationTimedOut,
isVerifying,
isBackgroundTrackingActive,
targetLat,
targetLng,
];
}

View File

@@ -1,155 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:geolocator/geolocator.dart';
// --- State ---
class ClockInState extends Equatable {
const ClockInState({
this.isLoading = false,
this.isLocationVerified = false,
this.error,
this.currentLocation,
this.distanceFromVenue,
this.isClockedIn = false,
this.clockInTime,
});
final bool isLoading;
final bool isLocationVerified;
final String? error;
final Position? currentLocation;
final double? distanceFromVenue;
final bool isClockedIn;
final DateTime? clockInTime;
ClockInState copyWith({
bool? isLoading,
bool? isLocationVerified,
String? error,
Position? currentLocation,
double? distanceFromVenue,
bool? isClockedIn,
DateTime? clockInTime,
}) {
return ClockInState(
isLoading: isLoading ?? this.isLoading,
isLocationVerified: isLocationVerified ?? this.isLocationVerified,
error: error, // Clear error if not provided
currentLocation: currentLocation ?? this.currentLocation,
distanceFromVenue: distanceFromVenue ?? this.distanceFromVenue,
isClockedIn: isClockedIn ?? this.isClockedIn,
clockInTime: clockInTime ?? this.clockInTime,
);
}
@override
List<Object?> get props => <Object?>[
isLoading,
isLocationVerified,
error,
currentLocation,
distanceFromVenue,
isClockedIn,
clockInTime,
];
}
// --- Cubit ---
class ClockInCubit extends Cubit<ClockInState> { // 500m radius
ClockInCubit() : super(const ClockInState());
// Mock Venue Location (e.g., Grand Hotel, NYC)
static const double venueLat = 40.7128;
static const double venueLng = -74.0060;
static const double allowedRadiusMeters = 500;
Future<void> checkLocationPermission() async {
emit(state.copyWith(isLoading: true, error: null));
try {
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
emit(state.copyWith(
isLoading: false,
error: 'Location permissions are denied',
));
return;
}
}
if (permission == LocationPermission.deniedForever) {
emit(state.copyWith(
isLoading: false,
error: 'Location permissions are permanently denied, we cannot request permissions.',
));
return;
}
await _getCurrentLocation();
} catch (e) {
emit(state.copyWith(isLoading: false, error: e.toString()));
}
}
Future<void> _getCurrentLocation() async {
try {
final Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
);
final double distance = Geolocator.distanceBetween(
position.latitude,
position.longitude,
venueLat,
venueLng,
);
final bool isWithinRadius = distance <= allowedRadiusMeters;
emit(state.copyWith(
isLoading: false,
currentLocation: position,
distanceFromVenue: distance,
isLocationVerified: isWithinRadius,
error: isWithinRadius ? null : 'You are ${distance.toStringAsFixed(0)}m away. You must be within ${allowedRadiusMeters}m.',
));
} catch (e) {
emit(state.copyWith(isLoading: false, error: 'Failed to get location: $e'));
}
}
Future<void> clockIn() async {
if (state.currentLocation == null) {
await checkLocationPermission();
if (state.currentLocation == null) return;
}
emit(state.copyWith(isLoading: true));
await Future.delayed(const Duration(seconds: 2));
emit(state.copyWith(
isLoading: false,
isClockedIn: true,
clockInTime: DateTime.now(),
));
}
Future<void> clockOut() async {
if (state.currentLocation == null) {
await checkLocationPermission();
if (state.currentLocation == null) return;
}
emit(state.copyWith(isLoading: true));
await Future.delayed(const Duration(seconds: 2));
emit(state.copyWith(
isLoading: false,
isClockedIn: false,
clockInTime: null,
));
}
}

View File

@@ -6,14 +6,15 @@ import 'package:flutter_modular/flutter_modular.dart';
import '../bloc/clock_in_bloc.dart';
import '../bloc/clock_in_state.dart';
import '../bloc/geofence_bloc.dart';
import '../widgets/clock_in_body.dart';
import '../widgets/clock_in_page_skeleton/clock_in_page_skeleton.dart';
/// Top-level page for the staff clock-in feature.
///
/// Acts as a thin shell that provides the [ClockInBloc] and delegates
/// rendering to [ClockInBody] (loaded state) or [ClockInPageSkeleton]
/// (loading state). Error snackbars are handled via [BlocListener].
/// Provides [ClockInBloc] and [GeofenceBloc], then delegates rendering to
/// [ClockInBody] (loaded) or [ClockInPageSkeleton] (loading). Error
/// snackbars are handled via [BlocListener].
class ClockInPage extends StatelessWidget {
/// Creates the clock-in page.
const ClockInPage({super.key});
@@ -24,8 +25,15 @@ class ClockInPage extends StatelessWidget {
context,
).staff.clock_in;
return BlocProvider<ClockInBloc>.value(
value: Modular.get<ClockInBloc>(),
return MultiBlocProvider(
providers: <BlocProvider<dynamic>>[
BlocProvider<ClockInBloc>.value(
value: Modular.get<ClockInBloc>(),
),
BlocProvider<GeofenceBloc>.value(
value: Modular.get<GeofenceBloc>(),
),
],
child: BlocListener<ClockInBloc, ClockInState>(
listenWhen: (ClockInState previous, ClockInState current) =>
current.status == ClockInStatus.failure &&

View File

@@ -37,7 +37,7 @@ class CheckInModeTab extends StatelessWidget {
return Expanded(
child: GestureDetector(
onTap: () =>
context.read<ClockInBloc>().add(CheckInModeChanged(value)),
ReadContext(context).read<ClockInBloc>().add(CheckInModeChanged(value)),
child: Container(
padding: const EdgeInsets.symmetric(vertical: UiConstants.space2),
decoration: BoxDecoration(

View File

@@ -1,11 +1,20 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../bloc/clock_in_bloc.dart';
import '../bloc/clock_in_event.dart';
import '../bloc/clock_in_state.dart';
import '../bloc/geofence_bloc.dart';
import '../bloc/geofence_event.dart';
import '../bloc/geofence_state.dart';
import 'clock_in_helpers.dart';
import 'early_check_in_banner.dart';
import 'geofence_status_banner.dart';
import 'lunch_break_modal.dart';
import 'nfc_scan_dialog.dart';
import 'no_shifts_banner.dart';
@@ -15,7 +24,8 @@ import 'swipe_to_check_in.dart';
/// Orchestrates which action widget is displayed based on the current state.
///
/// Decides between the swipe-to-check-in slider, the early-arrival banner,
/// the shift-completed banner, or the no-shifts placeholder.
/// the shift-completed banner, or the no-shifts placeholder. Also shows the
/// [GeofenceStatusBanner] and manages background tracking lifecycle.
class ClockInActionSection extends StatelessWidget {
/// Creates the action section.
const ClockInActionSection({
@@ -44,6 +54,37 @@ class ClockInActionSection extends StatelessWidget {
@override
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) =>
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 _) {
ReadContext(context)
.read<GeofenceBloc>()
.add(const BackgroundTrackingStopped());
},
),
],
child: _buildContent(context),
);
}
/// Builds the main content column with geofence banner and action widget.
Widget _buildContent(BuildContext context) {
if (selectedShift != null && checkOutTime == null) {
return _buildActiveShiftAction(context);
}
@@ -58,36 +99,74 @@ class ClockInActionSection extends StatelessWidget {
/// Builds the action widget for an active (not completed) shift.
Widget _buildActiveShiftAction(BuildContext context) {
if (!isCheckedIn && !ClockInHelpers.isCheckInAllowed(selectedShift!)) {
return EarlyCheckInBanner(
availabilityTime: ClockInHelpers.getCheckInAvailabilityTime(
selectedShift!,
context,
),
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const GeofenceStatusBanner(),
const SizedBox(height: UiConstants.space3),
EarlyCheckInBanner(
availabilityTime: ClockInHelpers.getCheckInAvailabilityTime(
selectedShift!,
context,
),
),
],
);
}
return SwipeToCheckIn(
isCheckedIn: isCheckedIn,
mode: checkInMode,
isDisabled: isCheckedIn,
isLoading: isActionInProgress,
onCheckIn: () => _handleCheckIn(context),
onCheckOut: () => _handleCheckOut(context),
return BlocBuilder<GeofenceBloc, GeofenceState>(
builder: (BuildContext context, GeofenceState geofenceState) {
final bool hasCoordinates = selectedShift?.latitude != null &&
selectedShift?.longitude != null;
// Disable swipe when the shift has coordinates and the user is
// not verified and the timeout has not been reached.
final bool isGeofenceBlocking = hasCoordinates &&
!geofenceState.isLocationVerified &&
!geofenceState.isLocationTimedOut;
return Column(
mainAxisSize: MainAxisSize.min,
spacing: UiConstants.space4,
children: <Widget>[
// Geofence status banner is shown even when not blocking to provide feedback
const GeofenceStatusBanner(),
SwipeToCheckIn(
isCheckedIn: isCheckedIn,
mode: checkInMode,
isDisabled: isCheckedIn || isGeofenceBlocking,
isLoading: isActionInProgress,
onCheckIn: () => _handleCheckIn(context),
onCheckOut: () => _handleCheckOut(context),
),
],
);
},
);
}
/// Triggers the check-in flow, showing an NFC dialog when needed.
/// Triggers the check-in flow, reading geofence state for location data.
Future<void> _handleCheckIn(BuildContext context) async {
final GeofenceState geofenceState = ReadContext(context).read<GeofenceBloc>().state;
if (checkInMode == 'nfc') {
final bool scanned = await showNfcScanDialog(context);
if (scanned && context.mounted) {
context.read<ClockInBloc>().add(
CheckInRequested(shiftId: selectedShift!.id),
ReadContext(context).read<ClockInBloc>().add(
CheckInRequested(
shiftId: selectedShift!.id,
isLocationVerified: geofenceState.isLocationVerified,
isLocationTimedOut: geofenceState.isLocationTimedOut,
),
);
}
} else {
context.read<ClockInBloc>().add(
CheckInRequested(shiftId: selectedShift!.id),
ReadContext(context).read<ClockInBloc>().add(
CheckInRequested(
shiftId: selectedShift!.id,
isLocationVerified: geofenceState.isLocationVerified,
isLocationTimedOut: geofenceState.isLocationTimedOut,
),
);
}
}
@@ -98,10 +177,33 @@ class ClockInActionSection extends StatelessWidget {
context: context,
builder: (BuildContext dialogContext) => LunchBreakDialog(
onComplete: () {
Navigator.of(dialogContext).pop();
context.read<ClockInBloc>().add(const CheckOutRequested());
Modular.to.popSafe();
ReadContext(context).read<ClockInBloc>().add(const CheckOutRequested());
},
),
);
}
/// Dispatches [BackgroundTrackingStarted] if the geofence has target
/// coordinates after a successful check-in.
void _startBackgroundTracking(BuildContext context, ClockInState state) {
final GeofenceState geofenceState = ReadContext(context).read<GeofenceBloc>().state;
if (geofenceState.targetLat != null &&
geofenceState.targetLng != null &&
state.attendance.activeShiftId != null) {
final TranslationsStaffClockInGeofenceEn geofenceI18n =
Translations.of(context).staff.clock_in.geofence;
ReadContext(context).read<GeofenceBloc>().add(
BackgroundTrackingStarted(
shiftId: state.attendance.activeShiftId!,
targetLat: geofenceState.targetLat!,
targetLng: geofenceState.targetLng!,
greetingTitle: geofenceI18n.clock_in_greeting_title,
greetingBody: geofenceI18n.clock_in_greeting_body,
),
);
}
}
}

View File

@@ -8,6 +8,8 @@ import 'package:krow_domain/krow_domain.dart';
import '../bloc/clock_in_bloc.dart';
import '../bloc/clock_in_event.dart';
import '../bloc/clock_in_state.dart';
import '../bloc/geofence_bloc.dart';
import '../bloc/geofence_event.dart';
import 'checked_in_banner.dart';
import 'clock_in_action_section.dart';
import 'date_selector.dart';
@@ -17,89 +19,129 @@ import 'shift_card_list.dart';
///
/// Composes the date selector, activity header, shift cards, action section,
/// and the checked-in status banner into a single scrollable column.
class ClockInBody extends StatelessWidget {
/// Triggers geofence verification on mount and on shift selection changes.
class ClockInBody extends StatefulWidget {
/// Creates the clock-in body.
const ClockInBody({super.key});
@override
State<ClockInBody> createState() => _ClockInBodyState();
}
class _ClockInBodyState extends State<ClockInBody> {
@override
void initState() {
super.initState();
// Sync geofence on initial mount if a shift is already selected.
WidgetsBinding.instance.addPostFrameCallback((_) {
final Shift? selectedShift =
ReadContext(context).read<ClockInBloc>().state.selectedShift;
_syncGeofence(context, selectedShift);
});
}
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInEn i18n = Translations.of(
context,
).staff.clock_in;
return SingleChildScrollView(
padding: const EdgeInsets.only(
bottom: UiConstants.space24,
top: UiConstants.space6,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
child: BlocBuilder<ClockInBloc, ClockInState>(
builder: (BuildContext context, ClockInState state) {
final List<Shift> todayShifts = state.todayShifts;
final Shift? selectedShift = state.selectedShift;
final String? activeShiftId = state.attendance.activeShiftId;
final bool isActiveSelected =
selectedShift != null && selectedShift.id == activeShiftId;
final DateTime? checkInTime = isActiveSelected
? state.attendance.checkInTime
: null;
final DateTime? checkOutTime = isActiveSelected
? state.attendance.checkOutTime
: null;
final bool isCheckedIn =
state.attendance.isCheckedIn && isActiveSelected;
return BlocListener<ClockInBloc, ClockInState>(
listenWhen: (ClockInState previous, ClockInState current) =>
previous.selectedShift != current.selectedShift,
listener: (BuildContext context, ClockInState state) {
_syncGeofence(context, state.selectedShift);
},
child: SingleChildScrollView(
padding: const EdgeInsets.only(
bottom: UiConstants.space24,
top: UiConstants.space6,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
child: BlocBuilder<ClockInBloc, ClockInState>(
builder: (BuildContext context, ClockInState state) {
final List<Shift> todayShifts = state.todayShifts;
final Shift? selectedShift = state.selectedShift;
final String? activeShiftId = state.attendance.activeShiftId;
final bool isActiveSelected =
selectedShift != null && selectedShift.id == activeShiftId;
final DateTime? checkInTime =
isActiveSelected ? state.attendance.checkInTime : null;
final DateTime? checkOutTime =
isActiveSelected ? state.attendance.checkOutTime : null;
final bool isCheckedIn =
state.attendance.isCheckedIn && isActiveSelected;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// date selector
DateSelector(
selectedDate: state.selectedDate,
onSelect: (DateTime date) =>
context.read<ClockInBloc>().add(DateSelected(date)),
shiftDates: <String>[
DateFormat('yyyy-MM-dd').format(DateTime.now()),
],
),
const SizedBox(height: UiConstants.space5),
Text(
i18n.your_activity,
textAlign: TextAlign.start,
style: UiTypography.headline4m,
),
const SizedBox(height: UiConstants.space4),
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// date selector
DateSelector(
selectedDate: state.selectedDate,
onSelect: (DateTime date) =>
ReadContext(context).read<ClockInBloc>().add(DateSelected(date)),
shiftDates: <String>[
DateFormat('yyyy-MM-dd').format(DateTime.now()),
],
),
const SizedBox(height: UiConstants.space5),
Text(
i18n.your_activity,
textAlign: TextAlign.start,
style: UiTypography.headline4m,
),
const SizedBox(height: UiConstants.space4),
// today's shifts and actions
if (todayShifts.isNotEmpty)
ShiftCardList(
shifts: todayShifts,
selectedShiftId: selectedShift?.id,
onShiftSelected: (Shift shift) =>
context.read<ClockInBloc>().add(ShiftSelected(shift)),
// today's shifts and actions
if (todayShifts.isNotEmpty)
ShiftCardList(
shifts: todayShifts,
selectedShiftId: selectedShift?.id,
onShiftSelected: (Shift shift) => ReadContext(context)
.read<ClockInBloc>()
.add(ShiftSelected(shift)),
),
// action section (check-in/out buttons)
ClockInActionSection(
selectedShift: selectedShift,
isCheckedIn: isCheckedIn,
checkOutTime: checkOutTime,
checkInMode: state.checkInMode,
isActionInProgress:
state.status == ClockInStatus.actionInProgress,
),
// action section (check-in/out buttons)
ClockInActionSection(
selectedShift: selectedShift,
isCheckedIn: isCheckedIn,
checkOutTime: checkOutTime,
checkInMode: state.checkInMode,
isActionInProgress:
state.status == ClockInStatus.actionInProgress,
),
// checked-in banner (only if currently checked in to the selected shift)
if (isCheckedIn && checkInTime != null) ...<Widget>[
const SizedBox(height: UiConstants.space3),
CheckedInBanner(checkInTime: checkInTime),
// checked-in banner (only when checked in to the selected shift)
if (isCheckedIn && checkInTime != null) ...<Widget>[
const SizedBox(height: UiConstants.space3),
CheckedInBanner(checkInTime: checkInTime),
],
const SizedBox(height: UiConstants.space4),
],
const SizedBox(height: UiConstants.space4),
],
);
},
);
},
),
),
),
);
}
/// Dispatches [GeofenceStarted] or [GeofenceStopped] based on whether
/// the selected shift has coordinates.
void _syncGeofence(BuildContext context, Shift? shift) {
final GeofenceBloc geofenceBloc = ReadContext(context).read<GeofenceBloc>();
if (shift != null && shift.latitude != null && shift.longitude != null) {
geofenceBloc.add(
GeofenceStarted(
targetLat: shift.latitude!,
targetLng: shift.longitude!,
),
);
} else {
geofenceBloc.add(const GeofenceStopped());
}
}
}

View File

@@ -0,0 +1,324 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/services/geofence_service_interface.dart';
import '../bloc/geofence_bloc.dart';
import '../bloc/geofence_event.dart';
import '../bloc/geofence_state.dart';
/// Banner that displays the current geofence verification status.
///
/// Reads [GeofenceBloc] state directly and renders the appropriate
/// status message with action buttons based on permission, location,
/// and verification conditions.
class GeofenceStatusBanner extends StatelessWidget {
/// Creates a [GeofenceStatusBanner].
const GeofenceStatusBanner({super.key});
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
context,
).staff.clock_in.geofence;
return BlocBuilder<GeofenceBloc, GeofenceState>(
builder: (BuildContext context, GeofenceState state) {
// Hide banner when no target coordinates are set.
if (state.targetLat == null) {
return const SizedBox.shrink();
}
return _buildBannerForState(context, state, i18n);
},
);
}
/// Determines which banner variant to display based on the current state.
Widget _buildBannerForState(
BuildContext context,
GeofenceState state,
TranslationsStaffClockInGeofenceEn i18n,
) {
// 1. Location services disabled.
if (state.permissionStatus == LocationPermissionStatus.serviceDisabled ||
(state.isLocationTimedOut && !state.isLocationServiceEnabled)) {
return _BannerContainer(
backgroundColor: UiColors.tagError,
borderColor: UiColors.error,
icon: UiIcons.error,
iconColor: UiColors.textError,
title: i18n.service_disabled,
titleStyle: UiTypography.body3m.textError,
action: _BannerActionButton(
label: i18n.open_settings,
onPressed: () => _openLocationSettings(),
),
);
}
// 2. Permission denied (can re-request).
if (state.permissionStatus == LocationPermissionStatus.denied) {
return _BannerContainer(
backgroundColor: UiColors.tagError,
borderColor: UiColors.error,
icon: UiIcons.error,
iconColor: UiColors.textError,
title: i18n.permission_required,
titleStyle: UiTypography.body3m.textError,
action: _BannerActionButton(
label: i18n.grant_permission,
onPressed: () {
if (state.targetLat != null && state.targetLng != null) {
ReadContext(context).read<GeofenceBloc>().add(
GeofenceStarted(
targetLat: state.targetLat!,
targetLng: state.targetLng!,
),
);
}
},
),
);
}
// 3. Permission permanently denied.
if (state.permissionStatus == LocationPermissionStatus.deniedForever) {
return _BannerContainer(
backgroundColor: UiColors.tagError,
borderColor: UiColors.error,
icon: UiIcons.error,
iconColor: UiColors.textError,
title: i18n.permission_denied_forever,
titleStyle: UiTypography.body3m.textError,
action: _BannerActionButton(
label: i18n.open_settings,
onPressed: () => _openAppSettings(),
),
);
}
// 4. Actively verifying location.
if (state.isVerifying) {
return _BannerContainer(
backgroundColor: UiColors.tagInProgress,
borderColor: UiColors.primary,
icon: null,
iconColor: UiColors.primary,
title: i18n.verifying,
titleStyle: UiTypography.body3m.primary,
leading: const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: UiColors.primary,
),
),
);
}
// 5. Location verified successfully.
if (state.isLocationVerified) {
return _BannerContainer(
backgroundColor: UiColors.tagSuccess,
borderColor: UiColors.success,
icon: UiIcons.checkCircle,
iconColor: UiColors.textSuccess,
title: i18n.verified,
titleStyle: UiTypography.body3m.textSuccess,
);
}
// 6. Timed out but location services are enabled.
if (state.isLocationTimedOut && state.isLocationServiceEnabled) {
return _BannerContainer(
backgroundColor: UiColors.tagPending,
borderColor: UiColors.textWarning,
icon: UiIcons.warning,
iconColor: UiColors.textWarning,
title: i18n.timeout_title,
titleStyle: UiTypography.body3m.textWarning,
subtitle: i18n.timeout_desc,
subtitleStyle: UiTypography.body3r.textWarning,
action: _BannerActionButton(
label: i18n.retry,
color: UiColors.textWarning,
onPressed: () {
ReadContext(
context,
).read<GeofenceBloc>().add(const GeofenceRetryRequested());
},
),
);
}
// 7. Not verified and too far away (distance known).
if (!state.isLocationVerified &&
!state.isLocationTimedOut &&
state.distanceFromTarget != null) {
return _BannerContainer(
backgroundColor: UiColors.tagPending,
borderColor: UiColors.textWarning,
icon: UiIcons.warning,
iconColor: UiColors.textWarning,
title: i18n.too_far_title,
titleStyle: UiTypography.body3m.textWarning,
subtitle: i18n.too_far_desc(
distance: _formatDistance(state.distanceFromTarget!),
),
subtitleStyle: UiTypography.body3r.textWarning,
);
}
// Default: hide banner for unmatched states.
return const SizedBox.shrink();
}
/// Opens the device location settings via the geofence service.
void _openLocationSettings() {
Modular.get<GeofenceServiceInterface>().openLocationSettings();
}
/// Opens the app settings page via the geofence service.
void _openAppSettings() {
Modular.get<GeofenceServiceInterface>().openAppSettings();
}
/// Formats a distance in meters to a human-readable string.
String _formatDistance(double meters) {
if (meters >= 1000) {
return '${(meters / 1000).toStringAsFixed(1)} km';
}
return '${meters.round()} m';
}
}
/// Internal container widget that provides consistent banner styling.
///
/// Renders a rounded container with an icon (or custom leading widget),
/// title/subtitle text, and an optional action button.
class _BannerContainer extends StatelessWidget {
/// Creates a [_BannerContainer].
const _BannerContainer({
required this.backgroundColor,
required this.borderColor,
required this.icon,
required this.iconColor,
required this.title,
required this.titleStyle,
this.subtitle,
this.subtitleStyle,
this.action,
this.leading,
});
/// Background color of the banner container.
final Color backgroundColor;
/// Border color of the banner container.
final Color borderColor;
/// Icon to display on the left side, or null if [leading] is used.
final IconData? icon;
/// Color for the icon.
final Color iconColor;
/// Primary message displayed in the banner.
final String title;
/// Text style for the title.
final TextStyle titleStyle;
/// Optional secondary message below the title.
final String? subtitle;
/// Text style for the subtitle.
final TextStyle? subtitleStyle;
/// Optional action button on the right side.
final Widget? action;
/// Optional custom leading widget, used instead of the icon.
final Widget? leading;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: borderColor.withValues(alpha: 0.3)),
),
child: Row(
children: <Widget>[
// Icon or custom leading widget.
if (leading != null)
leading!
else if (icon != null)
Icon(icon, color: iconColor, size: 20),
const SizedBox(width: UiConstants.space2),
// Title and optional subtitle.
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(title, style: titleStyle),
if (subtitle != null) ...<Widget>[
const SizedBox(height: UiConstants.space1),
Text(subtitle!, style: subtitleStyle),
],
],
),
),
// Optional action button.
if (action != null) ...<Widget>[
const SizedBox(width: UiConstants.space2),
action!,
],
],
),
);
}
}
/// Tappable text button used as a banner action.
class _BannerActionButton extends StatelessWidget {
/// Creates a [_BannerActionButton].
const _BannerActionButton({
required this.label,
required this.onPressed,
this.color,
});
/// Text label for the button.
final String label;
/// Callback when the button is pressed.
final VoidCallback onPressed;
/// Optional override color for the button text.
final Color? color;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onPressed,
child: Text(
label,
style: UiTypography.body3m.copyWith(
color: color ?? UiColors.primary,
decoration: TextDecoration.underline,
decorationColor: color ?? UiColors.primary,
),
),
);
}
}

View File

@@ -1,6 +1,8 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
/// Shows the NFC scanning dialog and returns `true` when a scan completes.
///
@@ -35,7 +37,7 @@ Future<bool> showNfcScanDialog(BuildContext context) async {
const Duration(milliseconds: 1000),
);
if (!context.mounted) return;
Navigator.of(dialogContext).pop();
Modular.to.popSafe();
},
),
);

View File

@@ -151,13 +151,6 @@ class _SwipeToCheckInState extends State<SwipeToCheckIn>
decoration: BoxDecoration(
color: currentColor,
borderRadius: UiConstants.radiusLg,
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Stack(
children: <Widget>[

View File

@@ -3,28 +3,58 @@ import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'data/repositories_impl/clock_in_repository_impl.dart';
import 'data/services/background_geofence_service.dart';
import 'data/services/geofence_service_impl.dart';
import 'domain/repositories/clock_in_repository_interface.dart';
import 'domain/services/geofence_service_interface.dart';
import 'domain/usecases/clock_in_usecase.dart';
import 'domain/usecases/clock_out_usecase.dart';
import 'domain/usecases/get_attendance_status_usecase.dart';
import 'domain/usecases/get_todays_shift_usecase.dart';
import 'presentation/bloc/clock_in_bloc.dart';
import 'presentation/bloc/geofence_bloc.dart';
import 'presentation/pages/clock_in_page.dart';
/// Module for the staff clock-in feature.
///
/// Registers repositories, use cases, geofence services, and BLoCs.
class StaffClockInModule extends Module {
@override
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// Repositories
i.add<ClockInRepositoryInterface>(ClockInRepositoryImpl.new);
// Geofence Services (resolve core singletons from DI)
i.add<GeofenceServiceInterface>(
() => GeofenceServiceImpl(
locationService: i.get<LocationService>(),
),
);
i.add<BackgroundGeofenceService>(
() => BackgroundGeofenceService(
backgroundTaskService: i.get<BackgroundTaskService>(),
notificationService: i.get<NotificationService>(),
storageService: i.get<StorageService>(),
),
);
// Use Cases
i.add<GetTodaysShiftUseCase>(GetTodaysShiftUseCase.new);
i.add<GetAttendanceStatusUseCase>(GetAttendanceStatusUseCase.new);
i.add<ClockInUseCase>(ClockInUseCase.new);
i.add<ClockOutUseCase>(ClockOutUseCase.new);
// BLoC
// BLoCs (transient -- new instance per navigation)
i.add<ClockInBloc>(ClockInBloc.new);
i.add<GeofenceBloc>(
() => GeofenceBloc(
geofenceService: i.get<GeofenceServiceInterface>(),
backgroundGeofenceService: i.get<BackgroundGeofenceService>(),
),
);
}
@override

View File

@@ -28,6 +28,4 @@ dependencies:
krow_core:
path: ../../../core
firebase_data_connect: ^0.2.2+2
geolocator: ^10.1.0
permission_handler: ^11.0.1
firebase_auth: ^6.1.4