diff --git a/.claude/agents/architecture-reviewer.md b/.claude/agents/architecture-reviewer.md index ebbffb75..c0c7b2a4 100644 --- a/.claude/agents/architecture-reviewer.md +++ b/.claude/agents/architecture-reviewer.md @@ -54,7 +54,7 @@ and load any additional skills as needed for specific review challenges. 2. Standalone custom `TextStyle(...)` — must use design system typography 3. Hardcoded spacing values — must use design system spacing constants 4. Direct icon library imports — must use design system icon abstractions -5. Direct `Navigator.push/pop/replace` usage — must use safe navigation extensions +5. Direct `Navigator.push/pop/replace` usage — must use safe navigation extensions from the `apps/mobile/packages/core/lib/src/routing/navigation_extensions.dart`. 6. Missing tests for use cases or repositories 7. Complex BLoC without bloc_test coverage 8. Test coverage below 70% for business logic diff --git a/apps/mobile/apps/client/android/app/build.gradle.kts b/apps/mobile/apps/client/android/app/build.gradle.kts index 837bc911..a6fe31ec 100644 --- a/apps/mobile/apps/client/android/app/build.gradle.kts +++ b/apps/mobile/apps/client/android/app/build.gradle.kts @@ -46,6 +46,7 @@ android { ndkVersion = flutter.ndkVersion compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -122,6 +123,10 @@ afterEvaluate { } } +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} + flutter { source = "../.." } diff --git a/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index e6d40294..bab9899d 100644 --- a/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -35,16 +35,31 @@ public final class GeneratedPluginRegistrant { } catch (Exception e) { Log.e(TAG, "Error registering plugin firebase_core, io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin", e); } + try { + flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin()); } catch (Exception e) { Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e); } + try { + flutterEngine.getPlugins().add(new com.baseflow.geolocator.GeolocatorPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin geolocator_android, com.baseflow.geolocator.GeolocatorPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin()); } catch (Exception e) { Log.e(TAG, "Error registering plugin image_picker_android, io.flutter.plugins.imagepicker.ImagePickerPlugin", e); } + try { + flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin()); } catch (Exception e) { @@ -65,5 +80,10 @@ public final class GeneratedPluginRegistrant { } catch (Exception e) { Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e); } + try { + flutterEngine.getPlugins().add(new dev.fluttercommunity.workmanager.WorkmanagerPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin workmanager_android, dev.fluttercommunity.workmanager.WorkmanagerPlugin", e); + } } } diff --git a/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m b/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m index 241fcf3b..adab234d 100644 --- a/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m +++ b/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m @@ -30,12 +30,30 @@ @import firebase_core; #endif +#if __has_include() +#import +#else +@import flutter_local_notifications; +#endif + +#if __has_include() +#import +#else +@import geolocator_apple; +#endif + #if __has_include() #import #else @import image_picker_ios; #endif +#if __has_include() +#import +#else +@import package_info_plus; +#endif + #if __has_include() #import #else @@ -54,6 +72,12 @@ @import url_launcher_ios; #endif +#if __has_include() +#import +#else +@import workmanager_apple; +#endif + @implementation GeneratedPluginRegistrant + (void)registerWithRegistry:(NSObject*)registry { @@ -61,10 +85,14 @@ [FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]]; [FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]]; [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; + [FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]]; + [GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]]; [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; + [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]]; [RecordIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"RecordIosPlugin"]]; [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; + [WorkmanagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"WorkmanagerPlugin"]]; } @end diff --git a/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift index 1dea22d7..288fbc2c 100644 --- a/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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 import url_launcher_macos @@ -20,6 +23,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")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc b/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc index 3c9a7f78..fc95dec8 100644 --- a/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -19,6 +20,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); RecordWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake b/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake index f2ab3101..15f2a4c5 100644 --- a/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake +++ b/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake @@ -6,11 +6,13 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows firebase_auth firebase_core + geolocator_windows record_windows url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/apps/mobile/apps/staff/android/app/build.gradle.kts b/apps/mobile/apps/staff/android/app/build.gradle.kts index 96155fc9..1a350dda 100644 --- a/apps/mobile/apps/staff/android/app/build.gradle.kts +++ b/apps/mobile/apps/staff/android/app/build.gradle.kts @@ -46,6 +46,7 @@ android { ndkVersion = flutter.ndkVersion compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -126,6 +127,10 @@ afterEvaluate { } } +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} + flutter { source = "../.." } diff --git a/apps/mobile/apps/staff/android/app/src/main/AndroidManifest.xml b/apps/mobile/apps/staff/android/app/src/main/AndroidManifest.xml index 9416b135..7e576610 100644 --- a/apps/mobile/apps/staff/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/apps/staff/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,8 @@ + + + + ) +#import +#else +@import flutter_local_notifications; +#endif + #if __has_include() #import #else @@ -48,10 +54,10 @@ @import image_picker_ios; #endif -#if __has_include() -#import +#if __has_include() +#import #else -@import permission_handler_apple; +@import package_info_plus; #endif #if __has_include() @@ -72,6 +78,12 @@ @import url_launcher_ios; #endif +#if __has_include() +#import +#else +@import workmanager_apple; +#endif + @implementation GeneratedPluginRegistrant + (void)registerWithRegistry:(NSObject*)registry { @@ -79,13 +91,15 @@ [FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]]; [FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]]; [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; + [FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]]; [GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]]; [FLTGoogleMapsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTGoogleMapsPlugin"]]; [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; - [PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]]; + [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]]; [RecordIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"RecordIosPlugin"]]; [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; + [WorkmanagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"WorkmanagerPlugin"]]; } @end diff --git a/apps/mobile/apps/staff/ios/Runner/Info.plist b/apps/mobile/apps/staff/ios/Runner/Info.plist index bdc600e2..9bb97fda 100644 --- a/apps/mobile/apps/staff/ios/Runner/Info.plist +++ b/apps/mobile/apps/staff/ios/Runner/Info.plist @@ -45,6 +45,14 @@ UIApplicationSupportsIndirectInputEvents + NSLocationWhenInUseUsageDescription + We need your location to verify you are at your assigned workplace for clock-in. + NSLocationAlwaysAndWhenInUseUsageDescription + We need your location to verify you remain at your assigned workplace during your shift. + NSLocationAlwaysUsageDescription + We need your location to verify you remain at your assigned workplace during your shift. + UIBackgroundModes + location DART_DEFINES $(DART_DEFINES) diff --git a/apps/mobile/apps/staff/lib/main.dart b/apps/mobile/apps/staff/lib/main.dart index a50744c9..19cd106b 100644 --- a/apps/mobile/apps/staff/lib/main.dart +++ b/apps/mobile/apps/staff/lib/main.dart @@ -11,13 +11,31 @@ import 'package:krowwithus_staff/firebase_options.dart'; import 'package:staff_authentication/staff_authentication.dart' as staff_authentication; import 'package:staff_main/staff_main.dart' as staff_main; +import 'package:workmanager/workmanager.dart'; import 'src/widgets/session_listener.dart'; +/// Top-level callback dispatcher for background tasks. +/// +/// Must be a top-level function because workmanager executes it in a separate +/// isolate where the DI container is not available. +@pragma('vm:entry-point') +void callbackDispatcher() { + Workmanager().executeTask((String task, Map? inputData) async { + // Background geofence check placeholder. + // Full implementation will parse inputData for target coordinates + // and perform a proximity check in the background isolate. + return true; + }); +} + void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + // Initialize background task processing for geofence checks + await const BackgroundTaskService().initialize(callbackDispatcher); + // Register global BLoC observer for centralized error logging Bloc.observer = CoreBlocObserver( logEvents: true, diff --git a/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift index e919f640..288fbc2c 100644 --- a/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,7 +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 import url_launcher_macos @@ -21,7 +23,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")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/apps/mobile/apps/staff/pubspec.yaml b/apps/mobile/apps/staff/pubspec.yaml index 21c19091..dd289c30 100644 --- a/apps/mobile/apps/staff/pubspec.yaml +++ b/apps/mobile/apps/staff/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: flutter_modular: ^6.3.0 firebase_core: ^4.4.0 flutter_bloc: ^8.1.6 + workmanager: ^0.9.0+3 dev_dependencies: flutter_test: diff --git a/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc b/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc index b6746a97..fc95dec8 100644 --- a/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc @@ -10,7 +10,6 @@ #include #include #include -#include #include #include @@ -23,8 +22,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); GeolocatorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("GeolocatorWindows")); - PermissionHandlerWindowsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); RecordWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake b/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake index 589f702c..15f2a4c5 100644 --- a/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake +++ b/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake @@ -7,12 +7,12 @@ list(APPEND FLUTTER_PLUGIN_LIST firebase_auth firebase_core geolocator_windows - permission_handler_windows record_windows url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/apps/mobile/packages/core/lib/core.dart b/apps/mobile/packages/core/lib/core.dart index e8743adc..f450c6e2 100644 --- a/apps/mobile/packages/core/lib/core.dart +++ b/apps/mobile/packages/core/lib/core.dart @@ -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'; diff --git a/apps/mobile/packages/core/lib/src/core_module.dart b/apps/mobile/packages/core/lib/src/core_module.dart index 5c71f6aa..1d2c07ea 100644 --- a/apps/mobile/packages/core/lib/src/core_module.dart +++ b/apps/mobile/packages/core/lib/src/core_module.dart @@ -48,5 +48,13 @@ class CoreModule extends Module { apiUploadService: i.get(), ), ); + + // 6. Register Geofence Device Services + i.addLazySingleton(() => const LocationService()); + i.addLazySingleton(() => NotificationService()); + i.addLazySingleton(() => StorageService()); + i.addLazySingleton( + () => const BackgroundTaskService(), + ); } } diff --git a/apps/mobile/packages/core/lib/src/services/device/background_task/background_task_service.dart b/apps/mobile/packages/core/lib/src/services/device/background_task/background_task_service.dart new file mode 100644 index 00000000..0e753716 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/background_task/background_task_service.dart @@ -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 initialize(Function callbackDispatcher) async { + return action(() async { + await Workmanager().initialize(callbackDispatcher); + }); + } + + /// Registers a periodic background task with the given [frequency]. + Future registerPeriodicTask({ + required String uniqueName, + required String taskName, + Duration frequency = const Duration(minutes: 15), + Map? inputData, + }) async { + return action(() async { + await Workmanager().registerPeriodicTask( + uniqueName, + taskName, + frequency: frequency, + inputData: inputData, + existingWorkPolicy: ExistingPeriodicWorkPolicy.replace, + ); + }); + } + + /// Registers a one-off background task. + Future registerOneOffTask({ + required String uniqueName, + required String taskName, + Map? inputData, + }) async { + return action(() async { + await Workmanager().registerOneOffTask( + uniqueName, + taskName, + inputData: inputData, + ); + }); + } + + /// Cancels a registered task by its [uniqueName]. + Future cancelByUniqueName(String uniqueName) async { + return action(() => Workmanager().cancelByUniqueName(uniqueName)); + } + + /// Cancels all registered background tasks. + Future cancelAll() async { + return action(() => Workmanager().cancelAll()); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/device/location/location_service.dart b/apps/mobile/packages/core/lib/src/services/device/location/location_service.dart new file mode 100644 index 00000000..2b583079 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/location/location_service.dart @@ -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 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 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 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 watchLocation({int distanceFilter = 10}) { + return Geolocator.getPositionStream( + locationSettings: LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: distanceFilter, + ), + ).map(_toDeviceLocation); + } + + /// Whether device location services are currently enabled. + Future isServiceEnabled() async { + return action(() => Geolocator.isLocationServiceEnabled()); + } + + /// Stream that emits when location service status changes. + /// + /// Emits `true` when enabled, `false` when disabled. + Stream get onServiceStatusChanged { + return Geolocator.getServiceStatusStream().map( + (ServiceStatus status) => status == ServiceStatus.enabled, + ); + } + + /// Opens the app settings page for the user to manually grant permissions. + Future openAppSettings() async { + return action(() => Geolocator.openAppSettings()); + } + + /// Opens the device location settings page. + Future 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, + ); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/device/notification/notification_service.dart b/apps/mobile/packages/core/lib/src/services/device/notification/notification_service.dart new file mode 100644 index 00000000..d54796ab --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/notification/notification_service.dart @@ -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 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 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 cancelNotification(int id) async { + return action(() => _plugin.cancel(id: id)); + } + + /// Cancels all active notifications. + Future cancelAll() async { + return action(() => _plugin.cancelAll()); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/device/storage/storage_service.dart b/apps/mobile/packages/core/lib/src/services/device/storage/storage_service.dart new file mode 100644 index 00000000..5f14f7f5 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/storage/storage_service.dart @@ -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 get _preferences async { + _prefs ??= await SharedPreferences.getInstance(); + return _prefs!; + } + + /// Retrieves a string value for the given [key]. + Future 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 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 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 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 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 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 remove(String key) async { + return action(() async { + final SharedPreferences prefs = await _preferences; + return prefs.remove(key); + }); + } + + /// Clears all stored values. + Future clear() async { + return action(() async { + final SharedPreferences prefs = await _preferences; + return prefs.clear(); + }); + } +} diff --git a/apps/mobile/packages/core/pubspec.yaml b/apps/mobile/packages/core/pubspec.yaml index 15f91f58..347e45af 100644 --- a/apps/mobile/packages/core/pubspec.yaml +++ b/apps/mobile/packages/core/pubspec.yaml @@ -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 diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 7178240d..24f8e555 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -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.", diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 5fce4a09..858249f1 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -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.", diff --git a/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart b/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart index 5e7df68d..69e4282d 100644 --- a/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart +++ b/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart @@ -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': diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index 87b22493..c98147f3 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -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'; diff --git a/apps/mobile/packages/domain/lib/src/core/models/device_location.dart b/apps/mobile/packages/domain/lib/src/core/models/device_location.dart new file mode 100644 index 00000000..0f83b3b7 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/core/models/device_location.dart @@ -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 get props => [latitude, longitude, accuracy, timestamp]; +} diff --git a/apps/mobile/packages/domain/lib/src/core/services/device/location_permission_status.dart b/apps/mobile/packages/domain/lib/src/core/services/device/location_permission_status.dart new file mode 100644 index 00000000..e9b5ff97 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/core/services/device/location_permission_status.dart @@ -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, +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift index 3eb92bc4..36b8a0c0 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) } diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.cc b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.cc index ec331e03..2406d471 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include 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")); } diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugins.cmake b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugins.cmake index 0125068a..0c9f9e28 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugins.cmake +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugins.cmake @@ -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) diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart new file mode 100644 index 00000000..d3a8e792 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart @@ -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 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 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 get isTrackingActive async { + final active = await _storageService.getBool(_keyTrackingActive); + return active ?? false; + } + + /// Shows a notification that the worker has left the geofence. + Future showLeftGeofenceNotification({ + required String title, + required String body, + }) async { + await _notificationService.showNotification( + title: title, + body: body, + id: _leftGeofenceNotificationId, + ); + } + + /// Shows a greeting notification upon successful clock-in. + Future showClockInGreetingNotification({ + required String title, + required String body, + }) async { + await _notificationService.showNotification( + title: title, + body: body, + id: _clockInNotificationId, + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/geofence_service_impl.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/geofence_service_impl.dart new file mode 100644 index 00000000..9071bf4c --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/geofence_service_impl.dart @@ -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 ensurePermission() { + return _locationService.checkAndRequestPermission(); + } + + @override + Future requestAlwaysPermission() { + return _locationService.requestAlwaysPermission(); + } + + @override + Stream 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 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 watchServiceStatus() { + return _locationService.onServiceStatusChanged; + } + + @override + Future openAppSettings() async { + await _locationService.openAppSettings(); + } + + @override + Future 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; +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/models/geofence_result.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/models/geofence_result.dart new file mode 100644 index 00000000..d5185375 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/models/geofence_result.dart @@ -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 get props => [ + distanceMeters, + isWithinRadius, + estimatedEtaMinutes, + location, + ]; +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/services/geofence_service_interface.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/services/geofence_service_interface.dart new file mode 100644 index 00000000..099ade09 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/services/geofence_service_interface.dart @@ -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 ensurePermission(); + + /// Requests upgrade to "Always" permission for background access. + Future requestAlwaysPermission(); + + /// Emits geofence results as the device moves relative to a target. + Stream watchGeofence({ + required double targetLat, + required double targetLng, + double radiusMeters = 500, + }); + + /// Checks geofence once with a timeout. Returns null if GPS times out. + Future 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 watchServiceStatus(); + + /// Opens the app settings page. + Future openAppSettings(); + + /// Opens the device location settings page. + Future openLocationSettings(); +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart index 6c8002e3..3a87d7f5 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart @@ -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 with BlocErrorHandler { + /// Creates a [ClockInBloc] with the required use cases. ClockInBloc({ required GetTodaysShiftUseCase getTodaysShift, required GetAttendanceStatusUseCase getAttendanceStatus, @@ -30,20 +34,16 @@ class ClockInBloc extends Bloc on(_onCheckIn); on(_onCheckOut); on(_onModeChanged); - on(_onRequestLocationPermission); - on(_onCommuteModeToggled); - on(_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 _onLoaded( ClockInPageLoaded event, Emitter emit, @@ -72,10 +72,6 @@ class ClockInBloc extends Bloc 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 ); } - Future _onRequestLocationPermission( - RequestLocationPermission event, - Emitter 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 _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 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 emit, - ) { - emit(state.copyWith(isCommuteModeOn: event.isEnabled)); - if (event.isEnabled) { - add(RequestLocationPermission()); - } - } - + /// Updates the currently selected shift. void _onShiftSelected( ShiftSelected event, Emitter emit, ) { emit(state.copyWith(selectedShift: event.shift)); - if (!state.attendance.isCheckedIn) { - _startLocationUpdates(); - } } + /// Updates the selected date for shift viewing. void _onDateSelected( DateSelected event, Emitter emit, @@ -191,6 +96,7 @@ class ClockInBloc extends Bloc emit(state.copyWith(selectedDate: event.date)); } + /// Updates the check-in interaction mode. void _onModeChanged( CheckInModeChanged event, Emitter emit, @@ -198,10 +104,44 @@ class ClockInBloc extends Bloc 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 _onCheckIn( CheckInRequested event, Emitter 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 ); } + /// Handles a clock-out request. Future _onCheckOut( CheckOutRequested event, Emitter emit, diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart index 85dd1614..39545d9f 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart @@ -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 get props => []; } +/// 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 get props => [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 get props => [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 get props => [shiftId, notes]; + List get props => + [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 get props => [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 get props => [mode]; } - -class CommuteModeToggled extends ClockInEvent { - - const CommuteModeToggled(this.isEnabled); - final bool isEnabled; - - @override - List get props => [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 get props => [position, distance, isVerified]; -} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_state.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_state.dart index 2474b519..3e69fd50 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_state.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_state.dart @@ -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 [], @@ -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 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? 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, ]; } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_bloc.dart new file mode 100644 index 00000000..f9f171ab --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_bloc.dart @@ -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 + with + BlocErrorHandler, + SafeBloc { + + /// Creates a [GeofenceBloc] instance. + GeofenceBloc({ + required GeofenceServiceInterface geofenceService, + required BackgroundGeofenceService backgroundGeofenceService, + }) : _geofenceService = geofenceService, + _backgroundGeofenceService = backgroundGeofenceService, + super(const GeofenceState.initial()) { + on(_onStarted); + on(_onResultUpdated); + on(_onTimeout); + on(_onServiceStatusChanged); + on(_onRetry); + on(_onBackgroundTrackingStarted); + on(_onBackgroundTrackingStopped); + on(_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? _geofenceSubscription; + + /// Active subscription to the location service status stream. + StreamSubscription? _serviceStatusSubscription; + + /// Handles the [GeofenceStarted] event by requesting permission, performing + /// an initial geofence check, and starting the foreground location stream. + Future _onStarted( + GeofenceStarted event, + Emitter 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 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 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 _onServiceStatusChanged( + GeofenceServiceStatusChanged event, + Emitter 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 _onRetry( + GeofenceRetryRequested event, + Emitter 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 _onBackgroundTrackingStarted( + BackgroundTrackingStarted event, + Emitter 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 _onBackgroundTrackingStopped( + BackgroundTrackingStopped event, + Emitter 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 _onStopped( + GeofenceStopped event, + Emitter emit, + ) async { + await _geofenceSubscription?.cancel(); + _geofenceSubscription = null; + await _serviceStatusSubscription?.cancel(); + _serviceStatusSubscription = null; + emit(const GeofenceState.initial()); + } + + @override + Future close() { + _geofenceSubscription?.cancel(); + _serviceStatusSubscription?.cancel(); + return super.close(); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_event.dart new file mode 100644 index 00000000..f4c68d50 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_event.dart @@ -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 get props => []; +} + +/// 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 get props => [targetLat, targetLng]; +} + +/// Emitted when a new geofence result is received from the location stream. +class GeofenceResultUpdated extends GeofenceEvent { + /// The latest geofence check result. + final GeofenceResult result; + + /// Creates a [GeofenceResultUpdated] event. + const GeofenceResultUpdated(this.result); + + @override + List get props => [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 get props => [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 get props => + [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(); +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_state.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_state.dart new file mode 100644 index 00000000..ff343569 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_state.dart @@ -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 get props => [ + permissionStatus, + isLocationServiceEnabled, + currentLocation, + distanceFromTarget, + isLocationVerified, + isLocationTimedOut, + isVerifying, + isBackgroundTrackingActive, + targetLat, + targetLng, + ]; +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/blocs/clock_in_cubit.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/blocs/clock_in_cubit.dart deleted file mode 100644 index 01067185..00000000 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/blocs/clock_in_cubit.dart +++ /dev/null @@ -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 get props => [ - isLoading, - isLocationVerified, - error, - currentLocation, - distanceFromVenue, - isClockedIn, - clockInTime, - ]; -} - -// --- Cubit --- -class ClockInCubit extends Cubit { // 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 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 _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 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 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, - )); - } -} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart index 511179ad..250494b0 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart @@ -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.value( - value: Modular.get(), + return MultiBlocProvider( + providers: >[ + BlocProvider.value( + value: Modular.get(), + ), + BlocProvider.value( + value: Modular.get(), + ), + ], child: BlocListener( listenWhen: (ClockInState previous, ClockInState current) => current.status == ClockInStatus.failure && diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/check_in_mode_tab.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/check_in_mode_tab.dart index 054e15b8..44f237d4 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/check_in_mode_tab.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/check_in_mode_tab.dart @@ -37,7 +37,7 @@ class CheckInModeTab extends StatelessWidget { return Expanded( child: GestureDetector( onTap: () => - context.read().add(CheckInModeChanged(value)), + ReadContext(context).read().add(CheckInModeChanged(value)), child: Container( padding: const EdgeInsets.symmetric(vertical: UiConstants.space2), decoration: BoxDecoration( diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart index 892f4502..5cf56ed7 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart @@ -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: >[ + // Start background tracking after successful check-in. + BlocListener( + 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( + listenWhen: (ClockInState previous, ClockInState current) => + previous.attendance.isCheckedIn && + !current.attendance.isCheckedIn, + listener: (BuildContext context, ClockInState _) { + ReadContext(context) + .read() + .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: [ + 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( + 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: [ + // 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 _handleCheckIn(BuildContext context) async { + final GeofenceState geofenceState = ReadContext(context).read().state; + if (checkInMode == 'nfc') { final bool scanned = await showNfcScanDialog(context); if (scanned && context.mounted) { - context.read().add( - CheckInRequested(shiftId: selectedShift!.id), + ReadContext(context).read().add( + CheckInRequested( + shiftId: selectedShift!.id, + isLocationVerified: geofenceState.isLocationVerified, + isLocationTimedOut: geofenceState.isLocationTimedOut, + ), ); } } else { - context.read().add( - CheckInRequested(shiftId: selectedShift!.id), + ReadContext(context).read().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().add(const CheckOutRequested()); + Modular.to.popSafe(); + ReadContext(context).read().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().state; + + if (geofenceState.targetLat != null && + geofenceState.targetLng != null && + state.attendance.activeShiftId != null) { + final TranslationsStaffClockInGeofenceEn geofenceI18n = + Translations.of(context).staff.clock_in.geofence; + + ReadContext(context).read().add( + BackgroundTrackingStarted( + shiftId: state.attendance.activeShiftId!, + targetLat: geofenceState.targetLat!, + targetLng: geofenceState.targetLng!, + greetingTitle: geofenceI18n.clock_in_greeting_title, + greetingBody: geofenceI18n.clock_in_greeting_body, + ), + ); + } + } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart index 58653815..54611a96 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart @@ -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 createState() => _ClockInBodyState(); +} + +class _ClockInBodyState extends State { + @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().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( - builder: (BuildContext context, ClockInState state) { - final List 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( + 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( + builder: (BuildContext context, ClockInState state) { + final List 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: [ - // date selector - DateSelector( - selectedDate: state.selectedDate, - onSelect: (DateTime date) => - context.read().add(DateSelected(date)), - shiftDates: [ - 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: [ + // date selector + DateSelector( + selectedDate: state.selectedDate, + onSelect: (DateTime date) => + ReadContext(context).read().add(DateSelected(date)), + shiftDates: [ + 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().add(ShiftSelected(shift)), + // today's shifts and actions + if (todayShifts.isNotEmpty) + ShiftCardList( + shifts: todayShifts, + selectedShiftId: selectedShift?.id, + onShiftSelected: (Shift shift) => ReadContext(context) + .read() + .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) ...[ - const SizedBox(height: UiConstants.space3), - CheckedInBanner(checkInTime: checkInTime), + // checked-in banner (only when checked in to the selected shift) + if (isCheckedIn && checkInTime != null) ...[ + 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(); + + if (shift != null && shift.latitude != null && shift.longitude != null) { + geofenceBloc.add( + GeofenceStarted( + targetLat: shift.latitude!, + targetLng: shift.longitude!, + ), + ); + } else { + geofenceBloc.add(const GeofenceStopped()); + } + } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner.dart new file mode 100644 index 00000000..8b422880 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner.dart @@ -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( + 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().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().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().openLocationSettings(); + } + + /// Opens the app settings page via the geofence service. + void _openAppSettings() { + Modular.get().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: [ + // 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: [ + Text(title, style: titleStyle), + if (subtitle != null) ...[ + const SizedBox(height: UiConstants.space1), + Text(subtitle!, style: subtitleStyle), + ], + ], + ), + ), + + // Optional action button. + if (action != null) ...[ + 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, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/nfc_scan_dialog.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/nfc_scan_dialog.dart index bbf24b05..3ba0e995 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/nfc_scan_dialog.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/nfc_scan_dialog.dart @@ -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 showNfcScanDialog(BuildContext context) async { const Duration(milliseconds: 1000), ); if (!context.mounted) return; - Navigator.of(dialogContext).pop(); + Modular.to.popSafe(); }, ), ); diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart index 25113d73..b9c8599b 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart @@ -151,13 +151,6 @@ class _SwipeToCheckInState extends State decoration: BoxDecoration( color: currentColor, borderRadius: UiConstants.radiusLg, - boxShadow: [ - BoxShadow( - color: UiColors.black.withValues(alpha: 0.1), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], ), child: Stack( children: [ diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart index ffd19c01..be26fb37 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart @@ -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 get imports => [CoreModule()]; + @override void binds(Injector i) { // Repositories i.add(ClockInRepositoryImpl.new); + // Geofence Services (resolve core singletons from DI) + i.add( + () => GeofenceServiceImpl( + locationService: i.get(), + ), + ); + i.add( + () => BackgroundGeofenceService( + backgroundTaskService: i.get(), + notificationService: i.get(), + storageService: i.get(), + ), + ); + // Use Cases i.add(GetTodaysShiftUseCase.new); i.add(GetAttendanceStatusUseCase.new); i.add(ClockInUseCase.new); i.add(ClockOutUseCase.new); - // BLoC + // BLoCs (transient -- new instance per navigation) i.add(ClockInBloc.new); + i.add( + () => GeofenceBloc( + geofenceService: i.get(), + backgroundGeofenceService: i.get(), + ), + ); } @override diff --git a/apps/mobile/packages/features/staff/clock_in/pubspec.yaml b/apps/mobile/packages/features/staff/clock_in/pubspec.yaml index 7ccaafe9..9b53e8e6 100644 --- a/apps/mobile/packages/features/staff/clock_in/pubspec.yaml +++ b/apps/mobile/packages/features/staff/clock_in/pubspec.yaml @@ -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 diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index c08e4dd6..e28d4536 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -289,6 +289,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" diff_match_patch: dependency: transitive description: @@ -510,6 +518,38 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: transitive + description: + name: flutter_local_notifications + sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1" + url: "https://pub.dev" + source: hosted + version: "21.0.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd + url: "https://pub.dev" + source: hosted + version: "8.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307 + url: "https://pub.dev" + source: hosted + version: "11.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c" + url: "https://pub.dev" + source: hosted + version: "3.0.0" flutter_localizations: dependency: transitive description: flutter @@ -557,22 +597,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" geolocator: dependency: transitive description: name: geolocator - sha256: f4efb8d3c4cdcad2e226af9661eb1a0dd38c71a9494b22526f9da80ab79520e5 + sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516" url: "https://pub.dev" source: hosted - version: "10.1.1" + version: "14.0.2" geolocator_android: dependency: transitive description: name: geolocator_android - sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + sha256: "179c3cb66dfa674fc9ccbf2be872a02658724d1c067634e2c427cf6df7df901a" url: "https://pub.dev" source: hosted - version: "4.6.2" + version: "5.0.2" geolocator_apple: dependency: transitive description: @@ -581,6 +629,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.13" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797 + url: "https://pub.dev" + source: hosted + version: "0.2.4" geolocator_platform_interface: dependency: transitive description: @@ -593,10 +649,10 @@ packages: dependency: transitive description: name: geolocator_web - sha256: "102e7da05b48ca6bf0a5bda0010f886b171d1a08059f01bfe02addd0175ebece" + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "4.1.3" geolocator_windows: dependency: transitive description: @@ -717,6 +773,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.4" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" hooks: dependency: transitive description: @@ -1021,6 +1085,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + url: "https://pub.dev" + source: hosted + version: "9.0.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -1077,54 +1157,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" - permission_handler: - dependency: transitive - description: - name: permission_handler - sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" - url: "https://pub.dev" - source: hosted - version: "11.4.0" - permission_handler_android: - dependency: transitive - description: - name: permission_handler_android - sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc - url: "https://pub.dev" - source: hosted - version: "12.1.0" - permission_handler_apple: - dependency: transitive - description: - name: permission_handler_apple - sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 - url: "https://pub.dev" - source: hosted - version: "9.4.7" - permission_handler_html: - dependency: transitive - description: - name: permission_handler_html - sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" - url: "https://pub.dev" - source: hosted - version: "0.1.3+5" - permission_handler_platform_interface: - dependency: transitive - description: - name: permission_handler_platform_interface - sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 - url: "https://pub.dev" - source: hosted - version: "4.3.0" - permission_handler_windows: - dependency: transitive - description: - name: permission_handler_windows - sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" - url: "https://pub.dev" - source: hosted - version: "0.2.1" petitparser: dependency: transitive description: @@ -1536,6 +1568,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.15" + timezone: + dependency: transitive + description: + name: timezone + sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b" + url: "https://pub.dev" + source: hosted + version: "0.11.0" typed_data: dependency: transitive description: @@ -1680,6 +1720,38 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + workmanager: + dependency: transitive + description: + name: workmanager + sha256: "065673b2a465865183093806925419d311a9a5e0995aa74ccf8920fd695e2d10" + url: "https://pub.dev" + source: hosted + version: "0.9.0+3" + workmanager_android: + dependency: transitive + description: + name: workmanager_android + sha256: "9ae744db4ef891f5fcd2fb8671fccc712f4f96489a487a1411e0c8675e5e8cb7" + url: "https://pub.dev" + source: hosted + version: "0.9.0+2" + workmanager_apple: + dependency: transitive + description: + name: workmanager_apple + sha256: "1cc12ae3cbf5535e72f7ba4fde0c12dd11b757caf493a28e22d684052701f2ca" + url: "https://pub.dev" + source: hosted + version: "0.9.1+2" + workmanager_platform_interface: + dependency: transitive + description: + name: workmanager_platform_interface + sha256: f40422f10b970c67abb84230b44da22b075147637532ac501729256fcea10a47 + url: "https://pub.dev" + source: hosted + version: "0.9.1+1" xdg_directories: dependency: transitive description: