feat: Implement notification and storage services, geofence management, and BLoC for geofence verification
- Add NotificationService for handling local notifications. - Introduce StorageService for key-value storage using SharedPreferences. - Create DeviceLocation model to represent geographic locations. - Define LocationPermissionStatus enum for managing location permissions. - Implement BackgroundGeofenceService for periodic geofence checks while clocked in. - Develop GeofenceServiceImpl for geofence proximity verification using LocationService. - Create GeofenceResult model to encapsulate geofence check results. - Define GeofenceServiceInterface for geofence service abstraction. - Implement GeofenceBloc to manage geofence verification and background tracking. - Create events and states for GeofenceBloc to handle various geofence scenarios. - Add GeofenceStatusBanner widget to display geofence verification status in the UI.
This commit is contained in:
@@ -54,7 +54,7 @@ and load any additional skills as needed for specific review challenges.
|
|||||||
2. Standalone custom `TextStyle(...)` — must use design system typography
|
2. Standalone custom `TextStyle(...)` — must use design system typography
|
||||||
3. Hardcoded spacing values — must use design system spacing constants
|
3. Hardcoded spacing values — must use design system spacing constants
|
||||||
4. Direct icon library imports — must use design system icon abstractions
|
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
|
6. Missing tests for use cases or repositories
|
||||||
7. Complex BLoC without bloc_test coverage
|
7. Complex BLoC without bloc_test coverage
|
||||||
8. Test coverage below 70% for business logic
|
8. Test coverage below 70% for business logic
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ android {
|
|||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
@@ -122,6 +123,10 @@ afterEvaluate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
|
}
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,16 +35,31 @@ public final class GeneratedPluginRegistrant {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Error registering plugin firebase_core, io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin", 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 {
|
try {
|
||||||
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
|
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", 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 {
|
try {
|
||||||
flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin());
|
flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Error registering plugin image_picker_android, io.flutter.plugins.imagepicker.ImagePickerPlugin", 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 {
|
try {
|
||||||
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
|
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -65,5 +80,10 @@ public final class GeneratedPluginRegistrant {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,12 +30,30 @@
|
|||||||
@import firebase_core;
|
@import firebase_core;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if __has_include(<flutter_local_notifications/FlutterLocalNotificationsPlugin.h>)
|
||||||
|
#import <flutter_local_notifications/FlutterLocalNotificationsPlugin.h>
|
||||||
|
#else
|
||||||
|
@import flutter_local_notifications;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if __has_include(<geolocator_apple/GeolocatorPlugin.h>)
|
||||||
|
#import <geolocator_apple/GeolocatorPlugin.h>
|
||||||
|
#else
|
||||||
|
@import geolocator_apple;
|
||||||
|
#endif
|
||||||
|
|
||||||
#if __has_include(<image_picker_ios/FLTImagePickerPlugin.h>)
|
#if __has_include(<image_picker_ios/FLTImagePickerPlugin.h>)
|
||||||
#import <image_picker_ios/FLTImagePickerPlugin.h>
|
#import <image_picker_ios/FLTImagePickerPlugin.h>
|
||||||
#else
|
#else
|
||||||
@import image_picker_ios;
|
@import image_picker_ios;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if __has_include(<package_info_plus/FPPPackageInfoPlusPlugin.h>)
|
||||||
|
#import <package_info_plus/FPPPackageInfoPlusPlugin.h>
|
||||||
|
#else
|
||||||
|
@import package_info_plus;
|
||||||
|
#endif
|
||||||
|
|
||||||
#if __has_include(<record_ios/RecordIosPlugin.h>)
|
#if __has_include(<record_ios/RecordIosPlugin.h>)
|
||||||
#import <record_ios/RecordIosPlugin.h>
|
#import <record_ios/RecordIosPlugin.h>
|
||||||
#else
|
#else
|
||||||
@@ -54,6 +72,12 @@
|
|||||||
@import url_launcher_ios;
|
@import url_launcher_ios;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if __has_include(<workmanager_apple/WorkmanagerPlugin.h>)
|
||||||
|
#import <workmanager_apple/WorkmanagerPlugin.h>
|
||||||
|
#else
|
||||||
|
@import workmanager_apple;
|
||||||
|
#endif
|
||||||
|
|
||||||
@implementation GeneratedPluginRegistrant
|
@implementation GeneratedPluginRegistrant
|
||||||
|
|
||||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
||||||
@@ -61,10 +85,14 @@
|
|||||||
[FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]];
|
[FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]];
|
||||||
[FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]];
|
[FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]];
|
||||||
[FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]];
|
[FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]];
|
||||||
|
[FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]];
|
||||||
|
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
|
||||||
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
||||||
|
[FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]];
|
||||||
[RecordIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"RecordIosPlugin"]];
|
[RecordIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"RecordIosPlugin"]];
|
||||||
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
|
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
|
||||||
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
|
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
|
||||||
|
[WorkmanagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"WorkmanagerPlugin"]];
|
||||||
}
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import file_selector_macos
|
|||||||
import firebase_app_check
|
import firebase_app_check
|
||||||
import firebase_auth
|
import firebase_auth
|
||||||
import firebase_core
|
import firebase_core
|
||||||
|
import flutter_local_notifications
|
||||||
|
import geolocator_apple
|
||||||
|
import package_info_plus
|
||||||
import record_macos
|
import record_macos
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
@@ -20,6 +23,9 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
|
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
|
||||||
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
||||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
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"))
|
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include <file_selector_windows/file_selector_windows.h>
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
#include <firebase_auth/firebase_auth_plugin_c_api.h>
|
#include <firebase_auth/firebase_auth_plugin_c_api.h>
|
||||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||||
|
#include <geolocator_windows/geolocator_windows.h>
|
||||||
#include <record_windows/record_windows_plugin_c_api.h>
|
#include <record_windows/record_windows_plugin_c_api.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
|
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
|
||||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||||
|
GeolocatorWindowsRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
||||||
RecordWindowsPluginCApiRegisterWithRegistrar(
|
RecordWindowsPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
|
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
file_selector_windows
|
file_selector_windows
|
||||||
firebase_auth
|
firebase_auth
|
||||||
firebase_core
|
firebase_core
|
||||||
|
geolocator_windows
|
||||||
record_windows
|
record_windows
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
flutter_local_notifications_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ android {
|
|||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
@@ -126,6 +127,10 @@ afterEvaluate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
|
}
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<application
|
<application
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ public final class GeneratedPluginRegistrant {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Error registering plugin firebase_core, io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin", 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 {
|
try {
|
||||||
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
|
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -55,16 +60,16 @@ public final class GeneratedPluginRegistrant {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Error registering plugin image_picker_android, io.flutter.plugins.imagepicker.ImagePickerPlugin", 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 {
|
try {
|
||||||
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
|
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
|
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new com.baseflow.permissionhandler.PermissionHandlerPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin permission_handler_android, com.baseflow.permissionhandler.PermissionHandlerPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
flutterEngine.getPlugins().add(new com.llfbandit.record.RecordPlugin());
|
flutterEngine.getPlugins().add(new com.llfbandit.record.RecordPlugin());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -80,5 +85,10 @@ public final class GeneratedPluginRegistrant {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,12 @@
|
|||||||
@import firebase_core;
|
@import firebase_core;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if __has_include(<flutter_local_notifications/FlutterLocalNotificationsPlugin.h>)
|
||||||
|
#import <flutter_local_notifications/FlutterLocalNotificationsPlugin.h>
|
||||||
|
#else
|
||||||
|
@import flutter_local_notifications;
|
||||||
|
#endif
|
||||||
|
|
||||||
#if __has_include(<geolocator_apple/GeolocatorPlugin.h>)
|
#if __has_include(<geolocator_apple/GeolocatorPlugin.h>)
|
||||||
#import <geolocator_apple/GeolocatorPlugin.h>
|
#import <geolocator_apple/GeolocatorPlugin.h>
|
||||||
#else
|
#else
|
||||||
@@ -48,10 +54,10 @@
|
|||||||
@import image_picker_ios;
|
@import image_picker_ios;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if __has_include(<permission_handler_apple/PermissionHandlerPlugin.h>)
|
#if __has_include(<package_info_plus/FPPPackageInfoPlusPlugin.h>)
|
||||||
#import <permission_handler_apple/PermissionHandlerPlugin.h>
|
#import <package_info_plus/FPPPackageInfoPlusPlugin.h>
|
||||||
#else
|
#else
|
||||||
@import permission_handler_apple;
|
@import package_info_plus;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if __has_include(<record_ios/RecordIosPlugin.h>)
|
#if __has_include(<record_ios/RecordIosPlugin.h>)
|
||||||
@@ -72,6 +78,12 @@
|
|||||||
@import url_launcher_ios;
|
@import url_launcher_ios;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if __has_include(<workmanager_apple/WorkmanagerPlugin.h>)
|
||||||
|
#import <workmanager_apple/WorkmanagerPlugin.h>
|
||||||
|
#else
|
||||||
|
@import workmanager_apple;
|
||||||
|
#endif
|
||||||
|
|
||||||
@implementation GeneratedPluginRegistrant
|
@implementation GeneratedPluginRegistrant
|
||||||
|
|
||||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
||||||
@@ -79,13 +91,15 @@
|
|||||||
[FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]];
|
[FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]];
|
||||||
[FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]];
|
[FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]];
|
||||||
[FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]];
|
[FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]];
|
||||||
|
[FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]];
|
||||||
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
|
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
|
||||||
[FLTGoogleMapsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTGoogleMapsPlugin"]];
|
[FLTGoogleMapsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTGoogleMapsPlugin"]];
|
||||||
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
||||||
[PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]];
|
[FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]];
|
||||||
[RecordIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"RecordIosPlugin"]];
|
[RecordIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"RecordIosPlugin"]];
|
||||||
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
|
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
|
||||||
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
|
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
|
||||||
|
[WorkmanagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"WorkmanagerPlugin"]];
|
||||||
}
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -45,6 +45,14 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>We need your location to verify you are at your assigned workplace for clock-in.</string>
|
||||||
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
|
<string>We need your location to verify you remain at your assigned workplace during your shift.</string>
|
||||||
|
<key>NSLocationAlwaysUsageDescription</key>
|
||||||
|
<string>We need your location to verify you remain at your assigned workplace during your shift.</string>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array><string>location</string></array>
|
||||||
<key>DART_DEFINES</key>
|
<key>DART_DEFINES</key>
|
||||||
<string>$(DART_DEFINES)</string>
|
<string>$(DART_DEFINES)</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -11,13 +11,31 @@ import 'package:krowwithus_staff/firebase_options.dart';
|
|||||||
import 'package:staff_authentication/staff_authentication.dart'
|
import 'package:staff_authentication/staff_authentication.dart'
|
||||||
as staff_authentication;
|
as staff_authentication;
|
||||||
import 'package:staff_main/staff_main.dart' as staff_main;
|
import 'package:staff_main/staff_main.dart' as staff_main;
|
||||||
|
import 'package:workmanager/workmanager.dart';
|
||||||
|
|
||||||
import 'src/widgets/session_listener.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<String, dynamic>? 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 {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
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
|
// Register global BLoC observer for centralized error logging
|
||||||
Bloc.observer = CoreBlocObserver(
|
Bloc.observer = CoreBlocObserver(
|
||||||
logEvents: true,
|
logEvents: true,
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import file_selector_macos
|
|||||||
import firebase_app_check
|
import firebase_app_check
|
||||||
import firebase_auth
|
import firebase_auth
|
||||||
import firebase_core
|
import firebase_core
|
||||||
|
import flutter_local_notifications
|
||||||
import geolocator_apple
|
import geolocator_apple
|
||||||
|
import package_info_plus
|
||||||
import record_macos
|
import record_macos
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
@@ -21,7 +23,9 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
|
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
|
||||||
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
||||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||||
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||||
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ dependencies:
|
|||||||
flutter_modular: ^6.3.0
|
flutter_modular: ^6.3.0
|
||||||
firebase_core: ^4.4.0
|
firebase_core: ^4.4.0
|
||||||
flutter_bloc: ^8.1.6
|
flutter_bloc: ^8.1.6
|
||||||
|
workmanager: ^0.9.0+3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
#include <firebase_auth/firebase_auth_plugin_c_api.h>
|
#include <firebase_auth/firebase_auth_plugin_c_api.h>
|
||||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||||
#include <geolocator_windows/geolocator_windows.h>
|
#include <geolocator_windows/geolocator_windows.h>
|
||||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
|
||||||
#include <record_windows/record_windows_plugin_c_api.h>
|
#include <record_windows/record_windows_plugin_c_api.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
@@ -23,8 +22,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||||
GeolocatorWindowsRegisterWithRegistrar(
|
GeolocatorWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
||||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
|
||||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
|
||||||
RecordWindowsPluginCApiRegisterWithRegistrar(
|
RecordWindowsPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
|
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
firebase_auth
|
firebase_auth
|
||||||
firebase_core
|
firebase_core
|
||||||
geolocator_windows
|
geolocator_windows
|
||||||
permission_handler_windows
|
|
||||||
record_windows
|
record_windows
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
flutter_local_notifications_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
@@ -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/file_picker_service.dart';
|
||||||
export 'src/services/device/file_upload/device_file_upload_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/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';
|
||||||
|
|||||||
@@ -48,5 +48,13 @@ class CoreModule extends Module {
|
|||||||
apiUploadService: i.get<FileUploadService>(),
|
apiUploadService: i.get<FileUploadService>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 6. Register Geofence Device Services
|
||||||
|
i.addLazySingleton<LocationService>(() => const LocationService());
|
||||||
|
i.addLazySingleton<NotificationService>(() => NotificationService());
|
||||||
|
i.addLazySingleton<StorageService>(() => StorageService());
|
||||||
|
i.addLazySingleton<BackgroundTaskService>(
|
||||||
|
() => const BackgroundTaskService(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
import 'package:workmanager/workmanager.dart';
|
||||||
|
|
||||||
|
/// Service that wraps [Workmanager] for scheduling background tasks.
|
||||||
|
class BackgroundTaskService extends BaseDeviceService {
|
||||||
|
/// Creates a [BackgroundTaskService] instance.
|
||||||
|
const BackgroundTaskService();
|
||||||
|
|
||||||
|
/// Initializes the workmanager with the given [callbackDispatcher].
|
||||||
|
Future<void> initialize(Function callbackDispatcher) async {
|
||||||
|
return action(() async {
|
||||||
|
await Workmanager().initialize(callbackDispatcher);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers a periodic background task with the given [frequency].
|
||||||
|
Future<void> registerPeriodicTask({
|
||||||
|
required String uniqueName,
|
||||||
|
required String taskName,
|
||||||
|
Duration frequency = const Duration(minutes: 15),
|
||||||
|
Map<String, dynamic>? inputData,
|
||||||
|
}) async {
|
||||||
|
return action(() async {
|
||||||
|
await Workmanager().registerPeriodicTask(
|
||||||
|
uniqueName,
|
||||||
|
taskName,
|
||||||
|
frequency: frequency,
|
||||||
|
inputData: inputData,
|
||||||
|
existingWorkPolicy: ExistingPeriodicWorkPolicy.replace,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers a one-off background task.
|
||||||
|
Future<void> registerOneOffTask({
|
||||||
|
required String uniqueName,
|
||||||
|
required String taskName,
|
||||||
|
Map<String, dynamic>? inputData,
|
||||||
|
}) async {
|
||||||
|
return action(() async {
|
||||||
|
await Workmanager().registerOneOffTask(
|
||||||
|
uniqueName,
|
||||||
|
taskName,
|
||||||
|
inputData: inputData,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancels a registered task by its [uniqueName].
|
||||||
|
Future<void> cancelByUniqueName(String uniqueName) async {
|
||||||
|
return action(() => Workmanager().cancelByUniqueName(uniqueName));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancels all registered background tasks.
|
||||||
|
Future<void> cancelAll() async {
|
||||||
|
return action(() => Workmanager().cancelAll());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
/// Service that wraps [Geolocator] to provide location access.
|
||||||
|
///
|
||||||
|
/// This is the only file in the core package that imports geolocator.
|
||||||
|
/// All location access across the app should go through this service.
|
||||||
|
class LocationService extends BaseDeviceService {
|
||||||
|
/// Creates a [LocationService] instance.
|
||||||
|
const LocationService();
|
||||||
|
|
||||||
|
/// Checks the current permission status and requests permission if needed.
|
||||||
|
Future<LocationPermissionStatus> checkAndRequestPermission() async {
|
||||||
|
return action(() async {
|
||||||
|
final bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||||
|
if (!serviceEnabled) return LocationPermissionStatus.serviceDisabled;
|
||||||
|
|
||||||
|
LocationPermission permission = await Geolocator.checkPermission();
|
||||||
|
if (permission == LocationPermission.denied) {
|
||||||
|
permission = await Geolocator.requestPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
return _mapPermission(permission);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requests upgrade to "Always" permission for background location access.
|
||||||
|
Future<LocationPermissionStatus> requestAlwaysPermission() async {
|
||||||
|
return action(() async {
|
||||||
|
// On Android, requesting permission again after whileInUse prompts
|
||||||
|
// for Always.
|
||||||
|
final LocationPermission permission = await Geolocator.requestPermission();
|
||||||
|
return _mapPermission(permission);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the device's current location.
|
||||||
|
Future<DeviceLocation> getCurrentLocation() async {
|
||||||
|
return action(() async {
|
||||||
|
final Position position = await Geolocator.getCurrentPosition(
|
||||||
|
locationSettings: const LocationSettings(
|
||||||
|
accuracy: LocationAccuracy.high,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return _toDeviceLocation(position);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits location updates as a stream, filtered by [distanceFilter] meters.
|
||||||
|
Stream<DeviceLocation> watchLocation({int distanceFilter = 10}) {
|
||||||
|
return Geolocator.getPositionStream(
|
||||||
|
locationSettings: LocationSettings(
|
||||||
|
accuracy: LocationAccuracy.high,
|
||||||
|
distanceFilter: distanceFilter,
|
||||||
|
),
|
||||||
|
).map(_toDeviceLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether device location services are currently enabled.
|
||||||
|
Future<bool> isServiceEnabled() async {
|
||||||
|
return action(() => Geolocator.isLocationServiceEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream that emits when location service status changes.
|
||||||
|
///
|
||||||
|
/// Emits `true` when enabled, `false` when disabled.
|
||||||
|
Stream<bool> get onServiceStatusChanged {
|
||||||
|
return Geolocator.getServiceStatusStream().map(
|
||||||
|
(ServiceStatus status) => status == ServiceStatus.enabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the app settings page for the user to manually grant permissions.
|
||||||
|
Future<bool> openAppSettings() async {
|
||||||
|
return action(() => Geolocator.openAppSettings());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the device location settings page.
|
||||||
|
Future<bool> openLocationSettings() async {
|
||||||
|
return action(() => Geolocator.openLocationSettings());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a [LocationPermission] to a [LocationPermissionStatus].
|
||||||
|
LocationPermissionStatus _mapPermission(LocationPermission permission) {
|
||||||
|
switch (permission) {
|
||||||
|
case LocationPermission.always:
|
||||||
|
return LocationPermissionStatus.granted;
|
||||||
|
case LocationPermission.whileInUse:
|
||||||
|
return LocationPermissionStatus.whileInUse;
|
||||||
|
case LocationPermission.denied:
|
||||||
|
return LocationPermissionStatus.denied;
|
||||||
|
case LocationPermission.deniedForever:
|
||||||
|
return LocationPermissionStatus.deniedForever;
|
||||||
|
case LocationPermission.unableToDetermine:
|
||||||
|
return LocationPermissionStatus.denied;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a geolocator [Position] to a [DeviceLocation].
|
||||||
|
DeviceLocation _toDeviceLocation(Position position) {
|
||||||
|
return DeviceLocation(
|
||||||
|
latitude: position.latitude,
|
||||||
|
longitude: position.longitude,
|
||||||
|
accuracy: position.accuracy,
|
||||||
|
timestamp: position.timestamp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
/// Service that wraps [FlutterLocalNotificationsPlugin] for local notifications.
|
||||||
|
class NotificationService extends BaseDeviceService {
|
||||||
|
|
||||||
|
/// Creates a [NotificationService] with the given [plugin] instance.
|
||||||
|
///
|
||||||
|
/// If no plugin is provided, a default instance is created.
|
||||||
|
NotificationService({FlutterLocalNotificationsPlugin? plugin})
|
||||||
|
: _plugin = plugin ?? FlutterLocalNotificationsPlugin();
|
||||||
|
/// The underlying notification plugin instance.
|
||||||
|
final FlutterLocalNotificationsPlugin _plugin;
|
||||||
|
|
||||||
|
/// Initializes notification channels and requests permissions.
|
||||||
|
Future<void> initialize() async {
|
||||||
|
return action(() async {
|
||||||
|
const AndroidInitializationSettings androidSettings = AndroidInitializationSettings(
|
||||||
|
'@mipmap/ic_launcher',
|
||||||
|
);
|
||||||
|
const DarwinInitializationSettings iosSettings = DarwinInitializationSettings(
|
||||||
|
requestAlertPermission: true,
|
||||||
|
requestBadgePermission: true,
|
||||||
|
requestSoundPermission: true,
|
||||||
|
);
|
||||||
|
const InitializationSettings settings = InitializationSettings(
|
||||||
|
android: androidSettings,
|
||||||
|
iOS: iosSettings,
|
||||||
|
);
|
||||||
|
await _plugin.initialize(settings: settings);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Displays a local notification with the given [title] and [body].
|
||||||
|
Future<void> showNotification({
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
int id = 0,
|
||||||
|
}) async {
|
||||||
|
return action(() async {
|
||||||
|
const AndroidNotificationDetails androidDetails = AndroidNotificationDetails(
|
||||||
|
'krow_geofence',
|
||||||
|
'Geofence Notifications',
|
||||||
|
channelDescription: 'Notifications for geofence events',
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
);
|
||||||
|
const DarwinNotificationDetails iosDetails = DarwinNotificationDetails();
|
||||||
|
const NotificationDetails details = NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: iosDetails,
|
||||||
|
);
|
||||||
|
await _plugin.show(id: id, title: title, body: body, notificationDetails: details);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancels a specific notification by [id].
|
||||||
|
Future<void> cancelNotification(int id) async {
|
||||||
|
return action(() => _plugin.cancel(id: id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancels all active notifications.
|
||||||
|
Future<void> cancelAll() async {
|
||||||
|
return action(() => _plugin.cancelAll());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Service that wraps [SharedPreferences] for key-value storage.
|
||||||
|
class StorageService extends BaseDeviceService {
|
||||||
|
|
||||||
|
/// Creates a [StorageService] instance.
|
||||||
|
StorageService();
|
||||||
|
/// Cached preferences instance.
|
||||||
|
SharedPreferences? _prefs;
|
||||||
|
|
||||||
|
/// Returns the [SharedPreferences] instance, initializing lazily.
|
||||||
|
Future<SharedPreferences> get _preferences async {
|
||||||
|
_prefs ??= await SharedPreferences.getInstance();
|
||||||
|
return _prefs!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves a string value for the given [key].
|
||||||
|
Future<String?> getString(String key) async {
|
||||||
|
return action(() async {
|
||||||
|
final SharedPreferences prefs = await _preferences;
|
||||||
|
return prefs.getString(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores a string [value] for the given [key].
|
||||||
|
Future<bool> setString(String key, String value) async {
|
||||||
|
return action(() async {
|
||||||
|
final SharedPreferences prefs = await _preferences;
|
||||||
|
return prefs.setString(key, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves a double value for the given [key].
|
||||||
|
Future<double?> getDouble(String key) async {
|
||||||
|
return action(() async {
|
||||||
|
final SharedPreferences prefs = await _preferences;
|
||||||
|
return prefs.getDouble(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores a double [value] for the given [key].
|
||||||
|
Future<bool> setDouble(String key, double value) async {
|
||||||
|
return action(() async {
|
||||||
|
final SharedPreferences prefs = await _preferences;
|
||||||
|
return prefs.setDouble(key, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves a boolean value for the given [key].
|
||||||
|
Future<bool?> getBool(String key) async {
|
||||||
|
return action(() async {
|
||||||
|
final SharedPreferences prefs = await _preferences;
|
||||||
|
return prefs.getBool(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores a boolean [value] for the given [key].
|
||||||
|
Future<bool> setBool(String key, bool value) async {
|
||||||
|
return action(() async {
|
||||||
|
final SharedPreferences prefs = await _preferences;
|
||||||
|
return prefs.setBool(key, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the value for the given [key].
|
||||||
|
Future<bool> remove(String key) async {
|
||||||
|
return action(() async {
|
||||||
|
final SharedPreferences prefs = await _preferences;
|
||||||
|
return prefs.remove(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all stored values.
|
||||||
|
Future<bool> clear() async {
|
||||||
|
return action(() async {
|
||||||
|
final SharedPreferences prefs = await _preferences;
|
||||||
|
return prefs.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,3 +27,7 @@ dependencies:
|
|||||||
file_picker: ^8.1.7
|
file_picker: ^8.1.7
|
||||||
record: ^6.2.0
|
record: ^6.2.0
|
||||||
firebase_auth: ^6.1.4
|
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
|
||||||
|
|||||||
@@ -926,6 +926,28 @@
|
|||||||
"submit": "Submit",
|
"submit": "Submit",
|
||||||
"success_title": "Break Logged!",
|
"success_title": "Break Logged!",
|
||||||
"close": "Close"
|
"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": {
|
"availability": {
|
||||||
@@ -1416,6 +1438,10 @@
|
|||||||
"application_not_found": "Your application couldn't be found.",
|
"application_not_found": "Your application couldn't be found.",
|
||||||
"no_active_shift": "You don't have an active shift to clock out from."
|
"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": {
|
"generic": {
|
||||||
"unknown": "Something went wrong. Please try again.",
|
"unknown": "Something went wrong. Please try again.",
|
||||||
"no_connection": "No internet connection. Please check your network and try again.",
|
"no_connection": "No internet connection. Please check your network and try again.",
|
||||||
|
|||||||
@@ -921,6 +921,28 @@
|
|||||||
"submit": "Enviar",
|
"submit": "Enviar",
|
||||||
"success_title": "\u00a1Descanso registrado!",
|
"success_title": "\u00a1Descanso registrado!",
|
||||||
"close": "Cerrar"
|
"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": {
|
"availability": {
|
||||||
@@ -1411,6 +1433,10 @@
|
|||||||
"application_not_found": "No se pudo encontrar tu solicitud.",
|
"application_not_found": "No se pudo encontrar tu solicitud.",
|
||||||
"no_active_shift": "No tienes un turno activo para registrar salida."
|
"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": {
|
"generic": {
|
||||||
"unknown": "Algo sali\u00f3 mal. Por favor, intenta de nuevo.",
|
"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.",
|
"no_connection": "Sin conexi\u00f3n a internet. Por favor, verifica tu red e intenta de nuevo.",
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ String translateErrorKey(String key) {
|
|||||||
return _translateProfileError(errorType);
|
return _translateProfileError(errorType);
|
||||||
case 'shift':
|
case 'shift':
|
||||||
return _translateShiftError(errorType);
|
return _translateShiftError(errorType);
|
||||||
|
case 'clock_in':
|
||||||
|
return _translateClockInError(errorType);
|
||||||
case 'generic':
|
case 'generic':
|
||||||
return _translateGenericError(errorType);
|
return _translateGenericError(errorType);
|
||||||
default:
|
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) {
|
String _translateGenericError(String errorType) {
|
||||||
switch (errorType) {
|
switch (errorType) {
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export 'src/core/services/api_services/file_visibility.dart';
|
|||||||
|
|
||||||
// Device
|
// Device
|
||||||
export 'src/core/services/device/base_device_service.dart';
|
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
|
// Users & Membership
|
||||||
export 'src/entities/users/user.dart';
|
export 'src/entities/users/user.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<Object?> get props => [latitude, longitude, accuracy, timestamp];
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ import file_selector_macos
|
|||||||
import firebase_app_check
|
import firebase_app_check
|
||||||
import firebase_auth
|
import firebase_auth
|
||||||
import firebase_core
|
import firebase_core
|
||||||
|
import flutter_local_notifications
|
||||||
|
import geolocator_apple
|
||||||
|
import package_info_plus
|
||||||
import record_macos
|
import record_macos
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
|
||||||
@@ -19,6 +22,9 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
|
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
|
||||||
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
||||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
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"))
|
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include <file_selector_windows/file_selector_windows.h>
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
#include <firebase_auth/firebase_auth_plugin_c_api.h>
|
#include <firebase_auth/firebase_auth_plugin_c_api.h>
|
||||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||||
|
#include <geolocator_windows/geolocator_windows.h>
|
||||||
#include <record_windows/record_windows_plugin_c_api.h>
|
#include <record_windows/record_windows_plugin_c_api.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
@@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
|
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
|
||||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||||
|
GeolocatorWindowsRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
||||||
RecordWindowsPluginCApiRegisterWithRegistrar(
|
RecordWindowsPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
|
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
file_selector_windows
|
file_selector_windows
|
||||||
firebase_auth
|
firebase_auth
|
||||||
firebase_core
|
firebase_core
|
||||||
|
geolocator_windows
|
||||||
record_windows
|
record_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
flutter_local_notifications_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
|
||||||
|
/// Service that manages periodic background geofence checks while clocked in.
|
||||||
|
///
|
||||||
|
/// Uses core services exclusively -- no direct imports of workmanager,
|
||||||
|
/// flutter_local_notifications, or shared_preferences.
|
||||||
|
class BackgroundGeofenceService {
|
||||||
|
/// The core background task service for scheduling periodic work.
|
||||||
|
final BackgroundTaskService _backgroundTaskService;
|
||||||
|
|
||||||
|
/// The core notification service for displaying local notifications.
|
||||||
|
final NotificationService _notificationService;
|
||||||
|
|
||||||
|
/// The core storage service for persisting geofence target data.
|
||||||
|
final StorageService _storageService;
|
||||||
|
|
||||||
|
/// Storage key for the target latitude.
|
||||||
|
static const _keyTargetLat = 'geofence_target_lat';
|
||||||
|
|
||||||
|
/// Storage key for the target longitude.
|
||||||
|
static const _keyTargetLng = 'geofence_target_lng';
|
||||||
|
|
||||||
|
/// Storage key for the shift identifier.
|
||||||
|
static const _keyShiftId = 'geofence_shift_id';
|
||||||
|
|
||||||
|
/// Storage key for the active tracking flag.
|
||||||
|
static const _keyTrackingActive = 'geofence_tracking_active';
|
||||||
|
|
||||||
|
/// Unique task name for the periodic background check.
|
||||||
|
static const taskUniqueName = 'geofence_background_check';
|
||||||
|
|
||||||
|
/// Task name identifier for the workmanager callback.
|
||||||
|
static const taskName = 'geofenceCheck';
|
||||||
|
|
||||||
|
/// Notification ID for clock-in greeting notifications.
|
||||||
|
static const _clockInNotificationId = 1;
|
||||||
|
|
||||||
|
/// Notification ID for left-geofence warnings.
|
||||||
|
static const _leftGeofenceNotificationId = 2;
|
||||||
|
|
||||||
|
/// Creates a [BackgroundGeofenceService] instance.
|
||||||
|
BackgroundGeofenceService({
|
||||||
|
required BackgroundTaskService backgroundTaskService,
|
||||||
|
required NotificationService notificationService,
|
||||||
|
required StorageService storageService,
|
||||||
|
}) : _backgroundTaskService = backgroundTaskService,
|
||||||
|
_notificationService = notificationService,
|
||||||
|
_storageService = storageService;
|
||||||
|
|
||||||
|
/// Starts periodic 15-minute background geofence checks.
|
||||||
|
///
|
||||||
|
/// Called after a successful clock-in. Persists the target coordinates
|
||||||
|
/// so the background isolate can access them.
|
||||||
|
Future<void> startBackgroundTracking({
|
||||||
|
required double targetLat,
|
||||||
|
required double targetLng,
|
||||||
|
required String shiftId,
|
||||||
|
}) async {
|
||||||
|
await Future.wait([
|
||||||
|
_storageService.setDouble(_keyTargetLat, targetLat),
|
||||||
|
_storageService.setDouble(_keyTargetLng, targetLng),
|
||||||
|
_storageService.setString(_keyShiftId, shiftId),
|
||||||
|
_storageService.setBool(_keyTrackingActive, true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await _backgroundTaskService.registerPeriodicTask(
|
||||||
|
uniqueName: taskUniqueName,
|
||||||
|
taskName: taskName,
|
||||||
|
frequency: const Duration(minutes: 15),
|
||||||
|
inputData: {
|
||||||
|
'targetLat': targetLat,
|
||||||
|
'targetLng': targetLng,
|
||||||
|
'shiftId': shiftId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops background geofence checks and clears persisted data.
|
||||||
|
///
|
||||||
|
/// Called after clock-out or when the shift ends.
|
||||||
|
Future<void> stopBackgroundTracking() async {
|
||||||
|
await _backgroundTaskService.cancelByUniqueName(taskUniqueName);
|
||||||
|
|
||||||
|
await Future.wait([
|
||||||
|
_storageService.remove(_keyTargetLat),
|
||||||
|
_storageService.remove(_keyTargetLng),
|
||||||
|
_storageService.remove(_keyShiftId),
|
||||||
|
_storageService.setBool(_keyTrackingActive, false),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether background tracking is currently active.
|
||||||
|
Future<bool> get isTrackingActive async {
|
||||||
|
final active = await _storageService.getBool(_keyTrackingActive);
|
||||||
|
return active ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows a notification that the worker has left the geofence.
|
||||||
|
Future<void> showLeftGeofenceNotification({
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
}) async {
|
||||||
|
await _notificationService.showNotification(
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
id: _leftGeofenceNotificationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows a greeting notification upon successful clock-in.
|
||||||
|
Future<void> showClockInGreetingNotification({
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
}) async {
|
||||||
|
await _notificationService.showNotification(
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
id: _clockInNotificationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
import '../../domain/models/geofence_result.dart';
|
||||||
|
import '../../domain/services/geofence_service_interface.dart';
|
||||||
|
|
||||||
|
/// Implementation of [GeofenceServiceInterface] using core [LocationService].
|
||||||
|
class GeofenceServiceImpl implements GeofenceServiceInterface {
|
||||||
|
/// The core location service for device GPS access.
|
||||||
|
final LocationService _locationService;
|
||||||
|
|
||||||
|
/// When true, always reports the device as within radius. For dev builds.
|
||||||
|
final bool debugAlwaysInRange;
|
||||||
|
|
||||||
|
/// Average walking speed in meters per minute for ETA estimation.
|
||||||
|
static const double _walkingSpeedMetersPerMinute = 80;
|
||||||
|
|
||||||
|
/// Creates a [GeofenceServiceImpl] instance.
|
||||||
|
GeofenceServiceImpl({
|
||||||
|
required LocationService locationService,
|
||||||
|
this.debugAlwaysInRange = false,
|
||||||
|
}) : _locationService = locationService;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LocationPermissionStatus> ensurePermission() {
|
||||||
|
return _locationService.checkAndRequestPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LocationPermissionStatus> requestAlwaysPermission() {
|
||||||
|
return _locationService.requestAlwaysPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<GeofenceResult> watchGeofence({
|
||||||
|
required double targetLat,
|
||||||
|
required double targetLng,
|
||||||
|
double radiusMeters = 500,
|
||||||
|
}) {
|
||||||
|
return _locationService.watchLocation(distanceFilter: 10).map(
|
||||||
|
(location) => _buildResult(
|
||||||
|
location: location,
|
||||||
|
targetLat: targetLat,
|
||||||
|
targetLng: targetLng,
|
||||||
|
radiusMeters: radiusMeters,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<GeofenceResult?> checkGeofenceWithTimeout({
|
||||||
|
required double targetLat,
|
||||||
|
required double targetLng,
|
||||||
|
double radiusMeters = 500,
|
||||||
|
Duration timeout = const Duration(seconds: 30),
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final location =
|
||||||
|
await _locationService.getCurrentLocation().timeout(timeout);
|
||||||
|
return _buildResult(
|
||||||
|
location: location,
|
||||||
|
targetLat: targetLat,
|
||||||
|
targetLng: targetLng,
|
||||||
|
radiusMeters: radiusMeters,
|
||||||
|
);
|
||||||
|
} on TimeoutException {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<bool> watchServiceStatus() {
|
||||||
|
return _locationService.onServiceStatusChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> openAppSettings() async {
|
||||||
|
await _locationService.openAppSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> openLocationSettings() async {
|
||||||
|
await _locationService.openLocationSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a [GeofenceResult] from a location and target coordinates.
|
||||||
|
GeofenceResult _buildResult({
|
||||||
|
required DeviceLocation location,
|
||||||
|
required double targetLat,
|
||||||
|
required double targetLng,
|
||||||
|
required double radiusMeters,
|
||||||
|
}) {
|
||||||
|
final distance = _calculateDistance(
|
||||||
|
location.latitude,
|
||||||
|
location.longitude,
|
||||||
|
targetLat,
|
||||||
|
targetLng,
|
||||||
|
);
|
||||||
|
|
||||||
|
final isWithin = debugAlwaysInRange || distance <= radiusMeters;
|
||||||
|
final eta =
|
||||||
|
isWithin ? 0 : (distance / _walkingSpeedMetersPerMinute).round();
|
||||||
|
|
||||||
|
return GeofenceResult(
|
||||||
|
distanceMeters: distance,
|
||||||
|
isWithinRadius: isWithin,
|
||||||
|
estimatedEtaMinutes: eta,
|
||||||
|
location: location,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Haversine formula for distance between two coordinates in meters.
|
||||||
|
double _calculateDistance(
|
||||||
|
double lat1,
|
||||||
|
double lng1,
|
||||||
|
double lat2,
|
||||||
|
double lng2,
|
||||||
|
) {
|
||||||
|
const earthRadius = 6371000.0;
|
||||||
|
final dLat = _toRadians(lat2 - lat1);
|
||||||
|
final dLng = _toRadians(lng2 - lng1);
|
||||||
|
final a = sin(dLat / 2) * sin(dLat / 2) +
|
||||||
|
cos(_toRadians(lat1)) *
|
||||||
|
cos(_toRadians(lat2)) *
|
||||||
|
sin(dLng / 2) *
|
||||||
|
sin(dLng / 2);
|
||||||
|
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
|
||||||
|
return earthRadius * c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts degrees to radians.
|
||||||
|
double _toRadians(double degrees) => degrees * pi / 180;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
/// Result of a geofence proximity check.
|
||||||
|
class GeofenceResult extends Equatable {
|
||||||
|
/// Distance from the target location in meters.
|
||||||
|
final double distanceMeters;
|
||||||
|
|
||||||
|
/// Whether the device is within the allowed geofence radius.
|
||||||
|
final bool isWithinRadius;
|
||||||
|
|
||||||
|
/// Estimated time of arrival in minutes if outside the radius.
|
||||||
|
final int estimatedEtaMinutes;
|
||||||
|
|
||||||
|
/// The device location at the time of the check.
|
||||||
|
final DeviceLocation location;
|
||||||
|
|
||||||
|
/// Creates a [GeofenceResult] instance.
|
||||||
|
const GeofenceResult({
|
||||||
|
required this.distanceMeters,
|
||||||
|
required this.isWithinRadius,
|
||||||
|
required this.estimatedEtaMinutes,
|
||||||
|
required this.location,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
distanceMeters,
|
||||||
|
isWithinRadius,
|
||||||
|
estimatedEtaMinutes,
|
||||||
|
location,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
import '../models/geofence_result.dart';
|
||||||
|
|
||||||
|
/// Interface for geofence proximity verification.
|
||||||
|
abstract class GeofenceServiceInterface {
|
||||||
|
/// Checks and requests location permission.
|
||||||
|
Future<LocationPermissionStatus> ensurePermission();
|
||||||
|
|
||||||
|
/// Requests upgrade to "Always" permission for background access.
|
||||||
|
Future<LocationPermissionStatus> requestAlwaysPermission();
|
||||||
|
|
||||||
|
/// Emits geofence results as the device moves relative to a target.
|
||||||
|
Stream<GeofenceResult> watchGeofence({
|
||||||
|
required double targetLat,
|
||||||
|
required double targetLng,
|
||||||
|
double radiusMeters = 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Checks geofence once with a timeout. Returns null if GPS times out.
|
||||||
|
Future<GeofenceResult?> checkGeofenceWithTimeout({
|
||||||
|
required double targetLat,
|
||||||
|
required double targetLng,
|
||||||
|
double radiusMeters = 500,
|
||||||
|
Duration timeout = const Duration(seconds: 30),
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Stream of location service status changes (enabled/disabled).
|
||||||
|
Stream<bool> watchServiceStatus();
|
||||||
|
|
||||||
|
/// Opens the app settings page.
|
||||||
|
Future<void> openAppSettings();
|
||||||
|
|
||||||
|
/// Opens the device location settings page.
|
||||||
|
Future<void> openLocationSettings();
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.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_event.dart';
|
||||||
import 'clock_in_state.dart';
|
import 'clock_in_state.dart';
|
||||||
|
|
||||||
|
/// BLoC responsible for clock-in/clock-out operations and shift management.
|
||||||
|
///
|
||||||
|
/// Location and geofence concerns are delegated to [GeofenceBloc].
|
||||||
|
/// The UI bridges geofence state into [CheckInRequested] event parameters.
|
||||||
class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||||
with BlocErrorHandler<ClockInState> {
|
with BlocErrorHandler<ClockInState> {
|
||||||
|
/// Creates a [ClockInBloc] with the required use cases.
|
||||||
ClockInBloc({
|
ClockInBloc({
|
||||||
required GetTodaysShiftUseCase getTodaysShift,
|
required GetTodaysShiftUseCase getTodaysShift,
|
||||||
required GetAttendanceStatusUseCase getAttendanceStatus,
|
required GetAttendanceStatusUseCase getAttendanceStatus,
|
||||||
@@ -30,20 +34,16 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
|||||||
on<CheckInRequested>(_onCheckIn);
|
on<CheckInRequested>(_onCheckIn);
|
||||||
on<CheckOutRequested>(_onCheckOut);
|
on<CheckOutRequested>(_onCheckOut);
|
||||||
on<CheckInModeChanged>(_onModeChanged);
|
on<CheckInModeChanged>(_onModeChanged);
|
||||||
on<RequestLocationPermission>(_onRequestLocationPermission);
|
|
||||||
on<CommuteModeToggled>(_onCommuteModeToggled);
|
|
||||||
on<LocationUpdated>(_onLocationUpdated);
|
|
||||||
|
|
||||||
add(ClockInPageLoaded());
|
add(ClockInPageLoaded());
|
||||||
}
|
}
|
||||||
|
|
||||||
final GetTodaysShiftUseCase _getTodaysShift;
|
final GetTodaysShiftUseCase _getTodaysShift;
|
||||||
final GetAttendanceStatusUseCase _getAttendanceStatus;
|
final GetAttendanceStatusUseCase _getAttendanceStatus;
|
||||||
final ClockInUseCase _clockIn;
|
final ClockInUseCase _clockIn;
|
||||||
final ClockOutUseCase _clockOut;
|
final ClockOutUseCase _clockOut;
|
||||||
|
|
||||||
// Mock Venue Location (e.g., Grand Hotel, NYC)
|
/// Loads today's shifts and the current attendance status.
|
||||||
static const double allowedRadiusMeters = 500;
|
|
||||||
|
|
||||||
Future<void> _onLoaded(
|
Future<void> _onLoaded(
|
||||||
ClockInPageLoaded event,
|
ClockInPageLoaded event,
|
||||||
Emitter<ClockInState> emit,
|
Emitter<ClockInState> emit,
|
||||||
@@ -72,10 +72,6 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
|||||||
selectedShift: selectedShift,
|
selectedShift: selectedShift,
|
||||||
attendance: status,
|
attendance: status,
|
||||||
));
|
));
|
||||||
|
|
||||||
if (selectedShift != null && !status.isCheckedIn) {
|
|
||||||
add(RequestLocationPermission());
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onError: (String errorKey) => state.copyWith(
|
onError: (String errorKey) => state.copyWith(
|
||||||
status: ClockInStatus.failure,
|
status: ClockInStatus.failure,
|
||||||
@@ -84,106 +80,15 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onRequestLocationPermission(
|
/// Updates the currently selected shift.
|
||||||
RequestLocationPermission event,
|
|
||||||
Emitter<ClockInState> emit,
|
|
||||||
) async {
|
|
||||||
await handleError(
|
|
||||||
emit: emit.call,
|
|
||||||
action: () async {
|
|
||||||
LocationPermission permission = await Geolocator.checkPermission();
|
|
||||||
if (permission == LocationPermission.denied) {
|
|
||||||
permission = await Geolocator.requestPermission();
|
|
||||||
}
|
|
||||||
|
|
||||||
final bool hasConsent =
|
|
||||||
permission == LocationPermission.always ||
|
|
||||||
permission == LocationPermission.whileInUse;
|
|
||||||
|
|
||||||
emit(state.copyWith(hasLocationConsent: hasConsent));
|
|
||||||
|
|
||||||
if (hasConsent) {
|
|
||||||
await _startLocationUpdates();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (String errorKey) => state.copyWith(
|
|
||||||
errorMessage: errorKey,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _startLocationUpdates() async {
|
|
||||||
// Note: handleErrorWithResult could be used here too if we want centralized logging/conversion
|
|
||||||
try {
|
|
||||||
final Position position = await Geolocator.getCurrentPosition(
|
|
||||||
desiredAccuracy: LocationAccuracy.high,
|
|
||||||
);
|
|
||||||
|
|
||||||
double distance = 0;
|
|
||||||
bool isVerified =
|
|
||||||
false; // Require location match by default if shift has location
|
|
||||||
|
|
||||||
if (state.selectedShift != null &&
|
|
||||||
state.selectedShift!.latitude != null &&
|
|
||||||
state.selectedShift!.longitude != null) {
|
|
||||||
distance = Geolocator.distanceBetween(
|
|
||||||
position.latitude,
|
|
||||||
position.longitude,
|
|
||||||
state.selectedShift!.latitude!,
|
|
||||||
state.selectedShift!.longitude!,
|
|
||||||
);
|
|
||||||
isVerified = distance <= allowedRadiusMeters;
|
|
||||||
} else {
|
|
||||||
isVerified = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isClosed) {
|
|
||||||
add(
|
|
||||||
LocationUpdated(
|
|
||||||
position: position,
|
|
||||||
distance: distance,
|
|
||||||
isVerified: isVerified,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// Geolocator errors usually handled via onRequestLocationPermission
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onLocationUpdated(
|
|
||||||
LocationUpdated event,
|
|
||||||
Emitter<ClockInState> emit,
|
|
||||||
) {
|
|
||||||
emit(state.copyWith(
|
|
||||||
currentLocation: event.position,
|
|
||||||
distanceFromVenue: event.distance,
|
|
||||||
isLocationVerified: event.isVerified,
|
|
||||||
etaMinutes:
|
|
||||||
(event.distance / 80).round(), // Rough estimate: 80m/min walking speed
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onCommuteModeToggled(
|
|
||||||
CommuteModeToggled event,
|
|
||||||
Emitter<ClockInState> emit,
|
|
||||||
) {
|
|
||||||
emit(state.copyWith(isCommuteModeOn: event.isEnabled));
|
|
||||||
if (event.isEnabled) {
|
|
||||||
add(RequestLocationPermission());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onShiftSelected(
|
void _onShiftSelected(
|
||||||
ShiftSelected event,
|
ShiftSelected event,
|
||||||
Emitter<ClockInState> emit,
|
Emitter<ClockInState> emit,
|
||||||
) {
|
) {
|
||||||
emit(state.copyWith(selectedShift: event.shift));
|
emit(state.copyWith(selectedShift: event.shift));
|
||||||
if (!state.attendance.isCheckedIn) {
|
|
||||||
_startLocationUpdates();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates the selected date for shift viewing.
|
||||||
void _onDateSelected(
|
void _onDateSelected(
|
||||||
DateSelected event,
|
DateSelected event,
|
||||||
Emitter<ClockInState> emit,
|
Emitter<ClockInState> emit,
|
||||||
@@ -191,6 +96,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
|||||||
emit(state.copyWith(selectedDate: event.date));
|
emit(state.copyWith(selectedDate: event.date));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates the check-in interaction mode.
|
||||||
void _onModeChanged(
|
void _onModeChanged(
|
||||||
CheckInModeChanged event,
|
CheckInModeChanged event,
|
||||||
Emitter<ClockInState> emit,
|
Emitter<ClockInState> emit,
|
||||||
@@ -198,10 +104,44 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
|||||||
emit(state.copyWith(checkInMode: event.mode));
|
emit(state.copyWith(checkInMode: event.mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handles a clock-in request.
|
||||||
|
///
|
||||||
|
/// Geofence state is passed via event parameters from the UI layer:
|
||||||
|
/// - If the shift has a venue (lat/lng) and location is neither verified
|
||||||
|
/// nor timed out, the clock-in is rejected.
|
||||||
|
/// - If the location timed out, notes are required to proceed.
|
||||||
|
/// - Otherwise the clock-in proceeds normally.
|
||||||
Future<void> _onCheckIn(
|
Future<void> _onCheckIn(
|
||||||
CheckInRequested event,
|
CheckInRequested event,
|
||||||
Emitter<ClockInState> emit,
|
Emitter<ClockInState> emit,
|
||||||
) async {
|
) 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));
|
emit(state.copyWith(status: ClockInStatus.actionInProgress));
|
||||||
await handleError(
|
await handleError(
|
||||||
emit: emit.call,
|
emit: emit.call,
|
||||||
@@ -221,6 +161,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handles a clock-out request.
|
||||||
Future<void> _onCheckOut(
|
Future<void> _onCheckOut(
|
||||||
CheckOutRequested event,
|
CheckOutRequested event,
|
||||||
Emitter<ClockInState> emit,
|
Emitter<ClockInState> emit,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
/// Base class for all clock-in related events.
|
||||||
abstract class ClockInEvent extends Equatable {
|
abstract class ClockInEvent extends Equatable {
|
||||||
const ClockInEvent();
|
const ClockInEvent();
|
||||||
|
|
||||||
@@ -9,72 +9,81 @@ abstract class ClockInEvent extends Equatable {
|
|||||||
List<Object?> get props => <Object?>[];
|
List<Object?> get props => <Object?>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emitted when the clock-in page is first loaded.
|
||||||
class ClockInPageLoaded extends ClockInEvent {}
|
class ClockInPageLoaded extends ClockInEvent {}
|
||||||
|
|
||||||
|
/// Emitted when the user selects a shift from the list.
|
||||||
class ShiftSelected extends ClockInEvent {
|
class ShiftSelected extends ClockInEvent {
|
||||||
const ShiftSelected(this.shift);
|
const ShiftSelected(this.shift);
|
||||||
|
|
||||||
|
/// The shift the user selected.
|
||||||
final Shift shift;
|
final Shift shift;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => <Object?>[shift];
|
List<Object?> get props => <Object?>[shift];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emitted when the user picks a different date.
|
||||||
class DateSelected extends ClockInEvent {
|
class DateSelected extends ClockInEvent {
|
||||||
|
|
||||||
const DateSelected(this.date);
|
const DateSelected(this.date);
|
||||||
|
|
||||||
|
/// The newly selected date.
|
||||||
final DateTime date;
|
final DateTime date;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => <Object?>[date];
|
List<Object?> get props => <Object?>[date];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emitted when the user requests to clock in.
|
||||||
|
///
|
||||||
|
/// [isLocationVerified] and [isLocationTimedOut] are provided by the UI layer
|
||||||
|
/// from the GeofenceBloc state, bridging the two BLoCs.
|
||||||
class CheckInRequested extends ClockInEvent {
|
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;
|
final String shiftId;
|
||||||
|
|
||||||
|
/// Optional notes provided by the user.
|
||||||
final String? notes;
|
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
|
@override
|
||||||
List<Object?> get props => <Object?>[shiftId, notes];
|
List<Object?> get props =>
|
||||||
|
<Object?>[shiftId, notes, isLocationVerified, isLocationTimedOut];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emitted when the user requests to clock out.
|
||||||
class CheckOutRequested extends ClockInEvent {
|
class CheckOutRequested extends ClockInEvent {
|
||||||
|
|
||||||
const CheckOutRequested({this.notes, this.breakTimeMinutes});
|
const CheckOutRequested({this.notes, this.breakTimeMinutes});
|
||||||
|
|
||||||
|
/// Optional notes provided by the user.
|
||||||
final String? notes;
|
final String? notes;
|
||||||
|
|
||||||
|
/// Break time taken during the shift, in minutes.
|
||||||
final int? breakTimeMinutes;
|
final int? breakTimeMinutes;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => <Object?>[notes, breakTimeMinutes];
|
List<Object?> get props => <Object?>[notes, breakTimeMinutes];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emitted when the user changes the check-in mode (e.g. swipe vs tap).
|
||||||
class CheckInModeChanged extends ClockInEvent {
|
class CheckInModeChanged extends ClockInEvent {
|
||||||
|
|
||||||
const CheckInModeChanged(this.mode);
|
const CheckInModeChanged(this.mode);
|
||||||
|
|
||||||
|
/// The new check-in mode identifier.
|
||||||
final String mode;
|
final String mode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => <Object?>[mode];
|
List<Object?> get props => <Object?>[mode];
|
||||||
}
|
}
|
||||||
|
|
||||||
class CommuteModeToggled extends ClockInEvent {
|
|
||||||
|
|
||||||
const CommuteModeToggled(this.isEnabled);
|
|
||||||
final bool isEnabled;
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => <Object?>[isEnabled];
|
|
||||||
}
|
|
||||||
|
|
||||||
class RequestLocationPermission extends ClockInEvent {}
|
|
||||||
|
|
||||||
class LocationUpdated extends ClockInEvent {
|
|
||||||
|
|
||||||
const LocationUpdated({required this.position, required this.distance, required this.isVerified});
|
|
||||||
final Position position;
|
|
||||||
final double distance;
|
|
||||||
final bool isVerified;
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => <Object?>[position, distance, isVerified];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:krow_domain/krow_domain.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 }
|
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 {
|
class ClockInState extends Equatable {
|
||||||
|
|
||||||
const ClockInState({
|
const ClockInState({
|
||||||
this.status = ClockInStatus.initial,
|
this.status = ClockInStatus.initial,
|
||||||
this.todayShifts = const <Shift>[],
|
this.todayShifts = const <Shift>[],
|
||||||
@@ -15,28 +18,30 @@ class ClockInState extends Equatable {
|
|||||||
required this.selectedDate,
|
required this.selectedDate,
|
||||||
this.checkInMode = 'swipe',
|
this.checkInMode = 'swipe',
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
this.currentLocation,
|
|
||||||
this.distanceFromVenue,
|
|
||||||
this.isLocationVerified = false,
|
|
||||||
this.isCommuteModeOn = false,
|
|
||||||
this.hasLocationConsent = false,
|
|
||||||
this.etaMinutes,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Current page status.
|
||||||
final ClockInStatus status;
|
final ClockInStatus status;
|
||||||
|
|
||||||
|
/// List of shifts scheduled for the selected date.
|
||||||
final List<Shift> todayShifts;
|
final List<Shift> todayShifts;
|
||||||
|
|
||||||
|
/// The shift currently selected by the user.
|
||||||
final Shift? selectedShift;
|
final Shift? selectedShift;
|
||||||
|
|
||||||
|
/// Current attendance/check-in status from the backend.
|
||||||
final AttendanceStatus attendance;
|
final AttendanceStatus attendance;
|
||||||
|
|
||||||
|
/// The date the user is viewing shifts for.
|
||||||
final DateTime selectedDate;
|
final DateTime selectedDate;
|
||||||
|
|
||||||
|
/// The current check-in interaction mode (e.g. 'swipe').
|
||||||
final String checkInMode;
|
final String checkInMode;
|
||||||
|
|
||||||
|
/// Error message key for displaying failures.
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
|
||||||
final Position? currentLocation;
|
/// Creates a copy of this state with the given fields replaced.
|
||||||
final double? distanceFromVenue;
|
|
||||||
final bool isLocationVerified;
|
|
||||||
final bool isCommuteModeOn;
|
|
||||||
final bool hasLocationConsent;
|
|
||||||
final int? etaMinutes;
|
|
||||||
|
|
||||||
ClockInState copyWith({
|
ClockInState copyWith({
|
||||||
ClockInStatus? status,
|
ClockInStatus? status,
|
||||||
List<Shift>? todayShifts,
|
List<Shift>? todayShifts,
|
||||||
@@ -45,12 +50,6 @@ class ClockInState extends Equatable {
|
|||||||
DateTime? selectedDate,
|
DateTime? selectedDate,
|
||||||
String? checkInMode,
|
String? checkInMode,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
Position? currentLocation,
|
|
||||||
double? distanceFromVenue,
|
|
||||||
bool? isLocationVerified,
|
|
||||||
bool? isCommuteModeOn,
|
|
||||||
bool? hasLocationConsent,
|
|
||||||
int? etaMinutes,
|
|
||||||
}) {
|
}) {
|
||||||
return ClockInState(
|
return ClockInState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
@@ -60,12 +59,6 @@ class ClockInState extends Equatable {
|
|||||||
selectedDate: selectedDate ?? this.selectedDate,
|
selectedDate: selectedDate ?? this.selectedDate,
|
||||||
checkInMode: checkInMode ?? this.checkInMode,
|
checkInMode: checkInMode ?? this.checkInMode,
|
||||||
errorMessage: errorMessage,
|
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,
|
selectedDate,
|
||||||
checkInMode,
|
checkInMode,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
currentLocation,
|
|
||||||
distanceFromVenue,
|
|
||||||
isLocationVerified,
|
|
||||||
isCommuteModeOn,
|
|
||||||
hasLocationConsent,
|
|
||||||
etaMinutes,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
import '../../data/services/background_geofence_service.dart';
|
||||||
|
import '../../domain/models/geofence_result.dart';
|
||||||
|
import '../../domain/services/geofence_service_interface.dart';
|
||||||
|
import 'geofence_event.dart';
|
||||||
|
import 'geofence_state.dart';
|
||||||
|
|
||||||
|
/// BLoC that manages geofence verification and background tracking.
|
||||||
|
///
|
||||||
|
/// Handles foreground location stream monitoring, GPS timeout fallback,
|
||||||
|
/// and background periodic checks while clocked in.
|
||||||
|
class GeofenceBloc extends Bloc<GeofenceEvent, GeofenceState>
|
||||||
|
with
|
||||||
|
BlocErrorHandler<GeofenceState>,
|
||||||
|
SafeBloc<GeofenceEvent, GeofenceState> {
|
||||||
|
|
||||||
|
/// Creates a [GeofenceBloc] instance.
|
||||||
|
GeofenceBloc({
|
||||||
|
required GeofenceServiceInterface geofenceService,
|
||||||
|
required BackgroundGeofenceService backgroundGeofenceService,
|
||||||
|
}) : _geofenceService = geofenceService,
|
||||||
|
_backgroundGeofenceService = backgroundGeofenceService,
|
||||||
|
super(const GeofenceState.initial()) {
|
||||||
|
on<GeofenceStarted>(_onStarted);
|
||||||
|
on<GeofenceResultUpdated>(_onResultUpdated);
|
||||||
|
on<GeofenceTimeoutReached>(_onTimeout);
|
||||||
|
on<GeofenceServiceStatusChanged>(_onServiceStatusChanged);
|
||||||
|
on<GeofenceRetryRequested>(_onRetry);
|
||||||
|
on<BackgroundTrackingStarted>(_onBackgroundTrackingStarted);
|
||||||
|
on<BackgroundTrackingStopped>(_onBackgroundTrackingStopped);
|
||||||
|
on<GeofenceStopped>(_onStopped);
|
||||||
|
}
|
||||||
|
/// The geofence service for foreground proximity checks.
|
||||||
|
final GeofenceServiceInterface _geofenceService;
|
||||||
|
|
||||||
|
/// The background service for periodic tracking while clocked in.
|
||||||
|
final BackgroundGeofenceService _backgroundGeofenceService;
|
||||||
|
|
||||||
|
/// Active subscription to the foreground geofence location stream.
|
||||||
|
StreamSubscription<GeofenceResult>? _geofenceSubscription;
|
||||||
|
|
||||||
|
/// Active subscription to the location service status stream.
|
||||||
|
StreamSubscription<bool>? _serviceStatusSubscription;
|
||||||
|
|
||||||
|
/// Handles the [GeofenceStarted] event by requesting permission, performing
|
||||||
|
/// an initial geofence check, and starting the foreground location stream.
|
||||||
|
Future<void> _onStarted(
|
||||||
|
GeofenceStarted event,
|
||||||
|
Emitter<GeofenceState> emit,
|
||||||
|
) async {
|
||||||
|
emit(state.copyWith(
|
||||||
|
isVerifying: true,
|
||||||
|
targetLat: event.targetLat,
|
||||||
|
targetLng: event.targetLng,
|
||||||
|
));
|
||||||
|
|
||||||
|
await handleError(
|
||||||
|
emit: emit.call,
|
||||||
|
action: () async {
|
||||||
|
// Check permission first.
|
||||||
|
final permission = await _geofenceService.ensurePermission();
|
||||||
|
emit(state.copyWith(permissionStatus: permission));
|
||||||
|
|
||||||
|
if (permission == LocationPermissionStatus.denied ||
|
||||||
|
permission == LocationPermissionStatus.deniedForever ||
|
||||||
|
permission == LocationPermissionStatus.serviceDisabled) {
|
||||||
|
emit(state.copyWith(
|
||||||
|
isVerifying: false,
|
||||||
|
isLocationServiceEnabled:
|
||||||
|
permission != LocationPermissionStatus.serviceDisabled,
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start monitoring location service status changes.
|
||||||
|
await _serviceStatusSubscription?.cancel();
|
||||||
|
_serviceStatusSubscription =
|
||||||
|
_geofenceService.watchServiceStatus().listen((isEnabled) {
|
||||||
|
add(GeofenceServiceStatusChanged(isEnabled));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get initial position with a 30s timeout.
|
||||||
|
final result = await _geofenceService.checkGeofenceWithTimeout(
|
||||||
|
targetLat: event.targetLat,
|
||||||
|
targetLng: event.targetLng,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == null) {
|
||||||
|
add(const GeofenceTimeoutReached());
|
||||||
|
} else {
|
||||||
|
add(GeofenceResultUpdated(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start continuous foreground location stream.
|
||||||
|
await _geofenceSubscription?.cancel();
|
||||||
|
_geofenceSubscription = _geofenceService
|
||||||
|
.watchGeofence(
|
||||||
|
targetLat: event.targetLat,
|
||||||
|
targetLng: event.targetLng,
|
||||||
|
)
|
||||||
|
.listen(
|
||||||
|
(result) => add(GeofenceResultUpdated(result)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (String errorKey) => state.copyWith(
|
||||||
|
isVerifying: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the [GeofenceResultUpdated] event by updating the state with
|
||||||
|
/// the latest location and distance data.
|
||||||
|
void _onResultUpdated(
|
||||||
|
GeofenceResultUpdated event,
|
||||||
|
Emitter<GeofenceState> emit,
|
||||||
|
) {
|
||||||
|
emit(state.copyWith(
|
||||||
|
isVerifying: false,
|
||||||
|
isLocationTimedOut: false,
|
||||||
|
currentLocation: event.result.location,
|
||||||
|
distanceFromTarget: event.result.distanceMeters,
|
||||||
|
isLocationVerified: event.result.isWithinRadius,
|
||||||
|
isLocationServiceEnabled: true,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the [GeofenceTimeoutReached] event by marking the state as
|
||||||
|
/// timed out.
|
||||||
|
void _onTimeout(
|
||||||
|
GeofenceTimeoutReached event,
|
||||||
|
Emitter<GeofenceState> emit,
|
||||||
|
) {
|
||||||
|
emit(state.copyWith(
|
||||||
|
isVerifying: false,
|
||||||
|
isLocationTimedOut: true,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the [GeofenceServiceStatusChanged] event. If services are
|
||||||
|
/// re-enabled after a timeout, automatically retries the check.
|
||||||
|
Future<void> _onServiceStatusChanged(
|
||||||
|
GeofenceServiceStatusChanged event,
|
||||||
|
Emitter<GeofenceState> emit,
|
||||||
|
) async {
|
||||||
|
emit(state.copyWith(isLocationServiceEnabled: event.isEnabled));
|
||||||
|
|
||||||
|
// If service re-enabled and we were timed out, retry automatically.
|
||||||
|
if (event.isEnabled && state.isLocationTimedOut) {
|
||||||
|
add(const GeofenceRetryRequested());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the [GeofenceRetryRequested] event by re-checking the geofence
|
||||||
|
/// with the stored target coordinates.
|
||||||
|
Future<void> _onRetry(
|
||||||
|
GeofenceRetryRequested event,
|
||||||
|
Emitter<GeofenceState> emit,
|
||||||
|
) async {
|
||||||
|
if (state.targetLat == null || state.targetLng == null) return;
|
||||||
|
|
||||||
|
emit(state.copyWith(
|
||||||
|
isVerifying: true,
|
||||||
|
isLocationTimedOut: false,
|
||||||
|
));
|
||||||
|
|
||||||
|
await handleError(
|
||||||
|
emit: emit.call,
|
||||||
|
action: () async {
|
||||||
|
final result = await _geofenceService.checkGeofenceWithTimeout(
|
||||||
|
targetLat: state.targetLat!,
|
||||||
|
targetLng: state.targetLng!,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == null) {
|
||||||
|
add(const GeofenceTimeoutReached());
|
||||||
|
} else {
|
||||||
|
add(GeofenceResultUpdated(result));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (String errorKey) => state.copyWith(
|
||||||
|
isVerifying: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the [BackgroundTrackingStarted] event by requesting "Always"
|
||||||
|
/// permission and starting periodic background checks.
|
||||||
|
Future<void> _onBackgroundTrackingStarted(
|
||||||
|
BackgroundTrackingStarted event,
|
||||||
|
Emitter<GeofenceState> emit,
|
||||||
|
) async {
|
||||||
|
await handleError(
|
||||||
|
emit: emit.call,
|
||||||
|
action: () async {
|
||||||
|
// Request upgrade to "Always" permission for background tracking.
|
||||||
|
final permission = await _geofenceService.requestAlwaysPermission();
|
||||||
|
emit(state.copyWith(permissionStatus: permission));
|
||||||
|
|
||||||
|
// Start background tracking regardless (degrades gracefully).
|
||||||
|
await _backgroundGeofenceService.startBackgroundTracking(
|
||||||
|
targetLat: event.targetLat,
|
||||||
|
targetLng: event.targetLng,
|
||||||
|
shiftId: event.shiftId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show greeting notification using localized strings from the UI.
|
||||||
|
await _backgroundGeofenceService.showClockInGreetingNotification(
|
||||||
|
title: event.greetingTitle,
|
||||||
|
body: event.greetingBody,
|
||||||
|
);
|
||||||
|
|
||||||
|
emit(state.copyWith(isBackgroundTrackingActive: true));
|
||||||
|
},
|
||||||
|
onError: (String errorKey) => state.copyWith(
|
||||||
|
isBackgroundTrackingActive: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the [BackgroundTrackingStopped] event by stopping background
|
||||||
|
/// tracking.
|
||||||
|
Future<void> _onBackgroundTrackingStopped(
|
||||||
|
BackgroundTrackingStopped event,
|
||||||
|
Emitter<GeofenceState> emit,
|
||||||
|
) async {
|
||||||
|
await handleError(
|
||||||
|
emit: emit.call,
|
||||||
|
action: () async {
|
||||||
|
await _backgroundGeofenceService.stopBackgroundTracking();
|
||||||
|
emit(state.copyWith(isBackgroundTrackingActive: false));
|
||||||
|
},
|
||||||
|
onError: (String errorKey) => state.copyWith(
|
||||||
|
isBackgroundTrackingActive: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the [GeofenceStopped] event by cancelling all subscriptions
|
||||||
|
/// and resetting the state.
|
||||||
|
Future<void> _onStopped(
|
||||||
|
GeofenceStopped event,
|
||||||
|
Emitter<GeofenceState> emit,
|
||||||
|
) async {
|
||||||
|
await _geofenceSubscription?.cancel();
|
||||||
|
_geofenceSubscription = null;
|
||||||
|
await _serviceStatusSubscription?.cancel();
|
||||||
|
_serviceStatusSubscription = null;
|
||||||
|
emit(const GeofenceState.initial());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() {
|
||||||
|
_geofenceSubscription?.cancel();
|
||||||
|
_serviceStatusSubscription?.cancel();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../../domain/models/geofence_result.dart';
|
||||||
|
|
||||||
|
/// Base event for the [GeofenceBloc].
|
||||||
|
abstract class GeofenceEvent extends Equatable {
|
||||||
|
/// Creates a [GeofenceEvent].
|
||||||
|
const GeofenceEvent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => <Object?>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts foreground geofence verification for a target location.
|
||||||
|
class GeofenceStarted extends GeofenceEvent {
|
||||||
|
/// Target latitude of the shift location.
|
||||||
|
final double targetLat;
|
||||||
|
|
||||||
|
/// Target longitude of the shift location.
|
||||||
|
final double targetLng;
|
||||||
|
|
||||||
|
/// Creates a [GeofenceStarted] event.
|
||||||
|
const GeofenceStarted({required this.targetLat, required this.targetLng});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => <Object?>[targetLat, targetLng];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emitted when a new geofence result is received from the location stream.
|
||||||
|
class GeofenceResultUpdated extends GeofenceEvent {
|
||||||
|
/// The latest geofence check result.
|
||||||
|
final GeofenceResult result;
|
||||||
|
|
||||||
|
/// Creates a [GeofenceResultUpdated] event.
|
||||||
|
const GeofenceResultUpdated(this.result);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => <Object?>[result];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emitted when the GPS timeout (30s) is reached without a location fix.
|
||||||
|
class GeofenceTimeoutReached extends GeofenceEvent {
|
||||||
|
/// Creates a [GeofenceTimeoutReached] event.
|
||||||
|
const GeofenceTimeoutReached();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emitted when the device location service status changes.
|
||||||
|
class GeofenceServiceStatusChanged extends GeofenceEvent {
|
||||||
|
/// Whether location services are now enabled.
|
||||||
|
final bool isEnabled;
|
||||||
|
|
||||||
|
/// Creates a [GeofenceServiceStatusChanged] event.
|
||||||
|
const GeofenceServiceStatusChanged(this.isEnabled);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => <Object?>[isEnabled];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User manually requests a geofence re-check.clock_in_body.dart
|
||||||
|
class GeofenceRetryRequested extends GeofenceEvent {
|
||||||
|
/// Creates a [GeofenceRetryRequested] event.
|
||||||
|
const GeofenceRetryRequested();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts background tracking after successful clock-in.
|
||||||
|
class BackgroundTrackingStarted extends GeofenceEvent {
|
||||||
|
/// The shift ID being tracked.
|
||||||
|
final String shiftId;
|
||||||
|
|
||||||
|
/// Target latitude of the shift location.
|
||||||
|
final double targetLat;
|
||||||
|
|
||||||
|
/// Target longitude of the shift location.
|
||||||
|
final double targetLng;
|
||||||
|
|
||||||
|
/// Localized greeting notification title passed from the UI layer.
|
||||||
|
final String greetingTitle;
|
||||||
|
|
||||||
|
/// Localized greeting notification body passed from the UI layer.
|
||||||
|
final String greetingBody;
|
||||||
|
|
||||||
|
/// Creates a [BackgroundTrackingStarted] event.
|
||||||
|
const BackgroundTrackingStarted({
|
||||||
|
required this.shiftId,
|
||||||
|
required this.targetLat,
|
||||||
|
required this.targetLng,
|
||||||
|
required this.greetingTitle,
|
||||||
|
required this.greetingBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props =>
|
||||||
|
<Object?>[shiftId, targetLat, targetLng, greetingTitle, greetingBody];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops background tracking after clock-out.
|
||||||
|
class BackgroundTrackingStopped extends GeofenceEvent {
|
||||||
|
/// Creates a [BackgroundTrackingStopped] event.
|
||||||
|
const BackgroundTrackingStopped();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops all geofence monitoring (foreground and background).
|
||||||
|
class GeofenceStopped extends GeofenceEvent {
|
||||||
|
/// Creates a [GeofenceStopped] event.
|
||||||
|
const GeofenceStopped();
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
/// State for the [GeofenceBloc].
|
||||||
|
class GeofenceState extends Equatable {
|
||||||
|
|
||||||
|
/// Creates a [GeofenceState] instance.
|
||||||
|
const GeofenceState({
|
||||||
|
this.permissionStatus,
|
||||||
|
this.isLocationServiceEnabled = true,
|
||||||
|
this.currentLocation,
|
||||||
|
this.distanceFromTarget,
|
||||||
|
this.isLocationVerified = false,
|
||||||
|
this.isLocationTimedOut = false,
|
||||||
|
this.isVerifying = false,
|
||||||
|
this.isBackgroundTrackingActive = false,
|
||||||
|
this.targetLat,
|
||||||
|
this.targetLng,
|
||||||
|
});
|
||||||
|
/// Current location permission status.
|
||||||
|
final LocationPermissionStatus? permissionStatus;
|
||||||
|
|
||||||
|
/// Whether device location services are enabled.
|
||||||
|
final bool isLocationServiceEnabled;
|
||||||
|
|
||||||
|
/// The device's current location, if available.
|
||||||
|
final DeviceLocation? currentLocation;
|
||||||
|
|
||||||
|
/// Distance from the target location in meters.
|
||||||
|
final double? distanceFromTarget;
|
||||||
|
|
||||||
|
/// Whether the device is within the 500m geofence radius.
|
||||||
|
final bool isLocationVerified;
|
||||||
|
|
||||||
|
/// Whether GPS timed out trying to get a fix.
|
||||||
|
final bool isLocationTimedOut;
|
||||||
|
|
||||||
|
/// Whether the BLoC is actively verifying location.
|
||||||
|
final bool isVerifying;
|
||||||
|
|
||||||
|
/// Whether background tracking is active.
|
||||||
|
final bool isBackgroundTrackingActive;
|
||||||
|
|
||||||
|
/// Target latitude being monitored.
|
||||||
|
final double? targetLat;
|
||||||
|
|
||||||
|
/// Target longitude being monitored.
|
||||||
|
final double? targetLng;
|
||||||
|
|
||||||
|
/// Initial state before any geofence operations.
|
||||||
|
const GeofenceState.initial() : this();
|
||||||
|
|
||||||
|
/// Creates a copy with the given fields replaced.
|
||||||
|
GeofenceState copyWith({
|
||||||
|
LocationPermissionStatus? permissionStatus,
|
||||||
|
bool? isLocationServiceEnabled,
|
||||||
|
DeviceLocation? currentLocation,
|
||||||
|
double? distanceFromTarget,
|
||||||
|
bool? isLocationVerified,
|
||||||
|
bool? isLocationTimedOut,
|
||||||
|
bool? isVerifying,
|
||||||
|
bool? isBackgroundTrackingActive,
|
||||||
|
double? targetLat,
|
||||||
|
double? targetLng,
|
||||||
|
}) {
|
||||||
|
return GeofenceState(
|
||||||
|
permissionStatus: permissionStatus ?? this.permissionStatus,
|
||||||
|
isLocationServiceEnabled:
|
||||||
|
isLocationServiceEnabled ?? this.isLocationServiceEnabled,
|
||||||
|
currentLocation: currentLocation ?? this.currentLocation,
|
||||||
|
distanceFromTarget: distanceFromTarget ?? this.distanceFromTarget,
|
||||||
|
isLocationVerified: isLocationVerified ?? this.isLocationVerified,
|
||||||
|
isLocationTimedOut: isLocationTimedOut ?? this.isLocationTimedOut,
|
||||||
|
isVerifying: isVerifying ?? this.isVerifying,
|
||||||
|
isBackgroundTrackingActive:
|
||||||
|
isBackgroundTrackingActive ?? this.isBackgroundTrackingActive,
|
||||||
|
targetLat: targetLat ?? this.targetLat,
|
||||||
|
targetLng: targetLng ?? this.targetLng,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => <Object?>[
|
||||||
|
permissionStatus,
|
||||||
|
isLocationServiceEnabled,
|
||||||
|
currentLocation,
|
||||||
|
distanceFromTarget,
|
||||||
|
isLocationVerified,
|
||||||
|
isLocationTimedOut,
|
||||||
|
isVerifying,
|
||||||
|
isBackgroundTrackingActive,
|
||||||
|
targetLat,
|
||||||
|
targetLng,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
import 'package:geolocator/geolocator.dart';
|
|
||||||
|
|
||||||
// --- State ---
|
|
||||||
class ClockInState extends Equatable {
|
|
||||||
|
|
||||||
const ClockInState({
|
|
||||||
this.isLoading = false,
|
|
||||||
this.isLocationVerified = false,
|
|
||||||
this.error,
|
|
||||||
this.currentLocation,
|
|
||||||
this.distanceFromVenue,
|
|
||||||
this.isClockedIn = false,
|
|
||||||
this.clockInTime,
|
|
||||||
});
|
|
||||||
final bool isLoading;
|
|
||||||
final bool isLocationVerified;
|
|
||||||
final String? error;
|
|
||||||
final Position? currentLocation;
|
|
||||||
final double? distanceFromVenue;
|
|
||||||
final bool isClockedIn;
|
|
||||||
final DateTime? clockInTime;
|
|
||||||
|
|
||||||
ClockInState copyWith({
|
|
||||||
bool? isLoading,
|
|
||||||
bool? isLocationVerified,
|
|
||||||
String? error,
|
|
||||||
Position? currentLocation,
|
|
||||||
double? distanceFromVenue,
|
|
||||||
bool? isClockedIn,
|
|
||||||
DateTime? clockInTime,
|
|
||||||
}) {
|
|
||||||
return ClockInState(
|
|
||||||
isLoading: isLoading ?? this.isLoading,
|
|
||||||
isLocationVerified: isLocationVerified ?? this.isLocationVerified,
|
|
||||||
error: error, // Clear error if not provided
|
|
||||||
currentLocation: currentLocation ?? this.currentLocation,
|
|
||||||
distanceFromVenue: distanceFromVenue ?? this.distanceFromVenue,
|
|
||||||
isClockedIn: isClockedIn ?? this.isClockedIn,
|
|
||||||
clockInTime: clockInTime ?? this.clockInTime,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => <Object?>[
|
|
||||||
isLoading,
|
|
||||||
isLocationVerified,
|
|
||||||
error,
|
|
||||||
currentLocation,
|
|
||||||
distanceFromVenue,
|
|
||||||
isClockedIn,
|
|
||||||
clockInTime,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Cubit ---
|
|
||||||
class ClockInCubit extends Cubit<ClockInState> { // 500m radius
|
|
||||||
|
|
||||||
ClockInCubit() : super(const ClockInState());
|
|
||||||
// Mock Venue Location (e.g., Grand Hotel, NYC)
|
|
||||||
static const double venueLat = 40.7128;
|
|
||||||
static const double venueLng = -74.0060;
|
|
||||||
static const double allowedRadiusMeters = 500;
|
|
||||||
|
|
||||||
Future<void> checkLocationPermission() async {
|
|
||||||
emit(state.copyWith(isLoading: true, error: null));
|
|
||||||
try {
|
|
||||||
LocationPermission permission = await Geolocator.checkPermission();
|
|
||||||
if (permission == LocationPermission.denied) {
|
|
||||||
permission = await Geolocator.requestPermission();
|
|
||||||
if (permission == LocationPermission.denied) {
|
|
||||||
emit(state.copyWith(
|
|
||||||
isLoading: false,
|
|
||||||
error: 'Location permissions are denied',
|
|
||||||
));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (permission == LocationPermission.deniedForever) {
|
|
||||||
emit(state.copyWith(
|
|
||||||
isLoading: false,
|
|
||||||
error: 'Location permissions are permanently denied, we cannot request permissions.',
|
|
||||||
));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _getCurrentLocation();
|
|
||||||
} catch (e) {
|
|
||||||
emit(state.copyWith(isLoading: false, error: e.toString()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _getCurrentLocation() async {
|
|
||||||
try {
|
|
||||||
final Position position = await Geolocator.getCurrentPosition(
|
|
||||||
desiredAccuracy: LocationAccuracy.high,
|
|
||||||
);
|
|
||||||
|
|
||||||
final double distance = Geolocator.distanceBetween(
|
|
||||||
position.latitude,
|
|
||||||
position.longitude,
|
|
||||||
venueLat,
|
|
||||||
venueLng,
|
|
||||||
);
|
|
||||||
|
|
||||||
final bool isWithinRadius = distance <= allowedRadiusMeters;
|
|
||||||
|
|
||||||
emit(state.copyWith(
|
|
||||||
isLoading: false,
|
|
||||||
currentLocation: position,
|
|
||||||
distanceFromVenue: distance,
|
|
||||||
isLocationVerified: isWithinRadius,
|
|
||||||
error: isWithinRadius ? null : 'You are ${distance.toStringAsFixed(0)}m away. You must be within ${allowedRadiusMeters}m.',
|
|
||||||
));
|
|
||||||
} catch (e) {
|
|
||||||
emit(state.copyWith(isLoading: false, error: 'Failed to get location: $e'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> clockIn() async {
|
|
||||||
if (state.currentLocation == null) {
|
|
||||||
await checkLocationPermission();
|
|
||||||
if (state.currentLocation == null) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(state.copyWith(isLoading: true));
|
|
||||||
|
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
|
||||||
|
|
||||||
emit(state.copyWith(
|
|
||||||
isLoading: false,
|
|
||||||
isClockedIn: true,
|
|
||||||
clockInTime: DateTime.now(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> clockOut() async {
|
|
||||||
if (state.currentLocation == null) {
|
|
||||||
await checkLocationPermission();
|
|
||||||
if (state.currentLocation == null) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(state.copyWith(isLoading: true));
|
|
||||||
|
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
|
||||||
|
|
||||||
emit(state.copyWith(
|
|
||||||
isLoading: false,
|
|
||||||
isClockedIn: false,
|
|
||||||
clockInTime: null,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,14 +6,15 @@ import 'package:flutter_modular/flutter_modular.dart';
|
|||||||
|
|
||||||
import '../bloc/clock_in_bloc.dart';
|
import '../bloc/clock_in_bloc.dart';
|
||||||
import '../bloc/clock_in_state.dart';
|
import '../bloc/clock_in_state.dart';
|
||||||
|
import '../bloc/geofence_bloc.dart';
|
||||||
import '../widgets/clock_in_body.dart';
|
import '../widgets/clock_in_body.dart';
|
||||||
import '../widgets/clock_in_page_skeleton/clock_in_page_skeleton.dart';
|
import '../widgets/clock_in_page_skeleton/clock_in_page_skeleton.dart';
|
||||||
|
|
||||||
/// Top-level page for the staff clock-in feature.
|
/// Top-level page for the staff clock-in feature.
|
||||||
///
|
///
|
||||||
/// Acts as a thin shell that provides the [ClockInBloc] and delegates
|
/// Provides [ClockInBloc] and [GeofenceBloc], then delegates rendering to
|
||||||
/// rendering to [ClockInBody] (loaded state) or [ClockInPageSkeleton]
|
/// [ClockInBody] (loaded) or [ClockInPageSkeleton] (loading). Error
|
||||||
/// (loading state). Error snackbars are handled via [BlocListener].
|
/// snackbars are handled via [BlocListener].
|
||||||
class ClockInPage extends StatelessWidget {
|
class ClockInPage extends StatelessWidget {
|
||||||
/// Creates the clock-in page.
|
/// Creates the clock-in page.
|
||||||
const ClockInPage({super.key});
|
const ClockInPage({super.key});
|
||||||
@@ -24,8 +25,15 @@ class ClockInPage extends StatelessWidget {
|
|||||||
context,
|
context,
|
||||||
).staff.clock_in;
|
).staff.clock_in;
|
||||||
|
|
||||||
return BlocProvider<ClockInBloc>.value(
|
return MultiBlocProvider(
|
||||||
value: Modular.get<ClockInBloc>(),
|
providers: <BlocProvider<dynamic>>[
|
||||||
|
BlocProvider<ClockInBloc>.value(
|
||||||
|
value: Modular.get<ClockInBloc>(),
|
||||||
|
),
|
||||||
|
BlocProvider<GeofenceBloc>.value(
|
||||||
|
value: Modular.get<GeofenceBloc>(),
|
||||||
|
),
|
||||||
|
],
|
||||||
child: BlocListener<ClockInBloc, ClockInState>(
|
child: BlocListener<ClockInBloc, ClockInState>(
|
||||||
listenWhen: (ClockInState previous, ClockInState current) =>
|
listenWhen: (ClockInState previous, ClockInState current) =>
|
||||||
current.status == ClockInStatus.failure &&
|
current.status == ClockInStatus.failure &&
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class CheckInModeTab extends StatelessWidget {
|
|||||||
return Expanded(
|
return Expanded(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
context.read<ClockInBloc>().add(CheckInModeChanged(value)),
|
ReadContext(context).read<ClockInBloc>().add(CheckInModeChanged(value)),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: UiConstants.space2),
|
padding: const EdgeInsets.symmetric(vertical: UiConstants.space2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|||||||
@@ -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/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
import '../bloc/clock_in_bloc.dart';
|
import '../bloc/clock_in_bloc.dart';
|
||||||
import '../bloc/clock_in_event.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 'clock_in_helpers.dart';
|
||||||
import 'early_check_in_banner.dart';
|
import 'early_check_in_banner.dart';
|
||||||
|
import 'geofence_status_banner.dart';
|
||||||
import 'lunch_break_modal.dart';
|
import 'lunch_break_modal.dart';
|
||||||
import 'nfc_scan_dialog.dart';
|
import 'nfc_scan_dialog.dart';
|
||||||
import 'no_shifts_banner.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.
|
/// Orchestrates which action widget is displayed based on the current state.
|
||||||
///
|
///
|
||||||
/// Decides between the swipe-to-check-in slider, the early-arrival banner,
|
/// 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 {
|
class ClockInActionSection extends StatelessWidget {
|
||||||
/// Creates the action section.
|
/// Creates the action section.
|
||||||
const ClockInActionSection({
|
const ClockInActionSection({
|
||||||
@@ -44,6 +54,37 @@ class ClockInActionSection extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
return MultiBlocListener(
|
||||||
|
listeners: <BlocListener<dynamic, dynamic>>[
|
||||||
|
// Start background tracking after successful check-in.
|
||||||
|
BlocListener<ClockInBloc, ClockInState>(
|
||||||
|
listenWhen: (ClockInState previous, ClockInState current) =>
|
||||||
|
previous.status == ClockInStatus.actionInProgress &&
|
||||||
|
current.status == ClockInStatus.success &&
|
||||||
|
current.attendance.isCheckedIn &&
|
||||||
|
!previous.attendance.isCheckedIn,
|
||||||
|
listener: (BuildContext context, ClockInState state) {
|
||||||
|
_startBackgroundTracking(context, state);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// Stop background tracking after clock-out.
|
||||||
|
BlocListener<ClockInBloc, ClockInState>(
|
||||||
|
listenWhen: (ClockInState previous, ClockInState current) =>
|
||||||
|
previous.attendance.isCheckedIn &&
|
||||||
|
!current.attendance.isCheckedIn,
|
||||||
|
listener: (BuildContext context, ClockInState _) {
|
||||||
|
ReadContext(context)
|
||||||
|
.read<GeofenceBloc>()
|
||||||
|
.add(const BackgroundTrackingStopped());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: _buildContent(context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the main content column with geofence banner and action widget.
|
||||||
|
Widget _buildContent(BuildContext context) {
|
||||||
if (selectedShift != null && checkOutTime == null) {
|
if (selectedShift != null && checkOutTime == null) {
|
||||||
return _buildActiveShiftAction(context);
|
return _buildActiveShiftAction(context);
|
||||||
}
|
}
|
||||||
@@ -58,36 +99,74 @@ class ClockInActionSection extends StatelessWidget {
|
|||||||
/// Builds the action widget for an active (not completed) shift.
|
/// Builds the action widget for an active (not completed) shift.
|
||||||
Widget _buildActiveShiftAction(BuildContext context) {
|
Widget _buildActiveShiftAction(BuildContext context) {
|
||||||
if (!isCheckedIn && !ClockInHelpers.isCheckInAllowed(selectedShift!)) {
|
if (!isCheckedIn && !ClockInHelpers.isCheckInAllowed(selectedShift!)) {
|
||||||
return EarlyCheckInBanner(
|
return Column(
|
||||||
availabilityTime: ClockInHelpers.getCheckInAvailabilityTime(
|
mainAxisSize: MainAxisSize.min,
|
||||||
selectedShift!,
|
children: <Widget>[
|
||||||
context,
|
const GeofenceStatusBanner(),
|
||||||
),
|
const SizedBox(height: UiConstants.space3),
|
||||||
|
EarlyCheckInBanner(
|
||||||
|
availabilityTime: ClockInHelpers.getCheckInAvailabilityTime(
|
||||||
|
selectedShift!,
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SwipeToCheckIn(
|
return BlocBuilder<GeofenceBloc, GeofenceState>(
|
||||||
isCheckedIn: isCheckedIn,
|
builder: (BuildContext context, GeofenceState geofenceState) {
|
||||||
mode: checkInMode,
|
final bool hasCoordinates = selectedShift?.latitude != null &&
|
||||||
isDisabled: isCheckedIn,
|
selectedShift?.longitude != null;
|
||||||
isLoading: isActionInProgress,
|
|
||||||
onCheckIn: () => _handleCheckIn(context),
|
// Disable swipe when the shift has coordinates and the user is
|
||||||
onCheckOut: () => _handleCheckOut(context),
|
// not verified and the timeout has not been reached.
|
||||||
|
final bool isGeofenceBlocking = hasCoordinates &&
|
||||||
|
!geofenceState.isLocationVerified &&
|
||||||
|
!geofenceState.isLocationTimedOut;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
spacing: UiConstants.space4,
|
||||||
|
children: <Widget>[
|
||||||
|
// Geofence status banner is shown even when not blocking to provide feedback
|
||||||
|
const GeofenceStatusBanner(),
|
||||||
|
SwipeToCheckIn(
|
||||||
|
isCheckedIn: isCheckedIn,
|
||||||
|
mode: checkInMode,
|
||||||
|
isDisabled: isCheckedIn || isGeofenceBlocking,
|
||||||
|
isLoading: isActionInProgress,
|
||||||
|
onCheckIn: () => _handleCheckIn(context),
|
||||||
|
onCheckOut: () => _handleCheckOut(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Triggers the check-in flow, showing an NFC dialog when needed.
|
/// Triggers the check-in flow, reading geofence state for location data.
|
||||||
Future<void> _handleCheckIn(BuildContext context) async {
|
Future<void> _handleCheckIn(BuildContext context) async {
|
||||||
|
final GeofenceState geofenceState = ReadContext(context).read<GeofenceBloc>().state;
|
||||||
|
|
||||||
if (checkInMode == 'nfc') {
|
if (checkInMode == 'nfc') {
|
||||||
final bool scanned = await showNfcScanDialog(context);
|
final bool scanned = await showNfcScanDialog(context);
|
||||||
if (scanned && context.mounted) {
|
if (scanned && context.mounted) {
|
||||||
context.read<ClockInBloc>().add(
|
ReadContext(context).read<ClockInBloc>().add(
|
||||||
CheckInRequested(shiftId: selectedShift!.id),
|
CheckInRequested(
|
||||||
|
shiftId: selectedShift!.id,
|
||||||
|
isLocationVerified: geofenceState.isLocationVerified,
|
||||||
|
isLocationTimedOut: geofenceState.isLocationTimedOut,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
context.read<ClockInBloc>().add(
|
ReadContext(context).read<ClockInBloc>().add(
|
||||||
CheckInRequested(shiftId: selectedShift!.id),
|
CheckInRequested(
|
||||||
|
shiftId: selectedShift!.id,
|
||||||
|
isLocationVerified: geofenceState.isLocationVerified,
|
||||||
|
isLocationTimedOut: geofenceState.isLocationTimedOut,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,10 +177,33 @@ class ClockInActionSection extends StatelessWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext dialogContext) => LunchBreakDialog(
|
builder: (BuildContext dialogContext) => LunchBreakDialog(
|
||||||
onComplete: () {
|
onComplete: () {
|
||||||
Navigator.of(dialogContext).pop();
|
Modular.to.popSafe();
|
||||||
context.read<ClockInBloc>().add(const CheckOutRequested());
|
ReadContext(context).read<ClockInBloc>().add(const CheckOutRequested());
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dispatches [BackgroundTrackingStarted] if the geofence has target
|
||||||
|
/// coordinates after a successful check-in.
|
||||||
|
void _startBackgroundTracking(BuildContext context, ClockInState state) {
|
||||||
|
final GeofenceState geofenceState = ReadContext(context).read<GeofenceBloc>().state;
|
||||||
|
|
||||||
|
if (geofenceState.targetLat != null &&
|
||||||
|
geofenceState.targetLng != null &&
|
||||||
|
state.attendance.activeShiftId != null) {
|
||||||
|
final TranslationsStaffClockInGeofenceEn geofenceI18n =
|
||||||
|
Translations.of(context).staff.clock_in.geofence;
|
||||||
|
|
||||||
|
ReadContext(context).read<GeofenceBloc>().add(
|
||||||
|
BackgroundTrackingStarted(
|
||||||
|
shiftId: state.attendance.activeShiftId!,
|
||||||
|
targetLat: geofenceState.targetLat!,
|
||||||
|
targetLng: geofenceState.targetLng!,
|
||||||
|
greetingTitle: geofenceI18n.clock_in_greeting_title,
|
||||||
|
greetingBody: geofenceI18n.clock_in_greeting_body,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import 'package:krow_domain/krow_domain.dart';
|
|||||||
import '../bloc/clock_in_bloc.dart';
|
import '../bloc/clock_in_bloc.dart';
|
||||||
import '../bloc/clock_in_event.dart';
|
import '../bloc/clock_in_event.dart';
|
||||||
import '../bloc/clock_in_state.dart';
|
import '../bloc/clock_in_state.dart';
|
||||||
|
import '../bloc/geofence_bloc.dart';
|
||||||
|
import '../bloc/geofence_event.dart';
|
||||||
import 'checked_in_banner.dart';
|
import 'checked_in_banner.dart';
|
||||||
import 'clock_in_action_section.dart';
|
import 'clock_in_action_section.dart';
|
||||||
import 'date_selector.dart';
|
import 'date_selector.dart';
|
||||||
@@ -17,89 +19,129 @@ import 'shift_card_list.dart';
|
|||||||
///
|
///
|
||||||
/// Composes the date selector, activity header, shift cards, action section,
|
/// Composes the date selector, activity header, shift cards, action section,
|
||||||
/// and the checked-in status banner into a single scrollable column.
|
/// 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.
|
/// Creates the clock-in body.
|
||||||
const ClockInBody({super.key});
|
const ClockInBody({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ClockInBody> createState() => _ClockInBodyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ClockInBodyState extends State<ClockInBody> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Sync geofence on initial mount if a shift is already selected.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final Shift? selectedShift =
|
||||||
|
ReadContext(context).read<ClockInBloc>().state.selectedShift;
|
||||||
|
_syncGeofence(context, selectedShift);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final TranslationsStaffClockInEn i18n = Translations.of(
|
final TranslationsStaffClockInEn i18n = Translations.of(
|
||||||
context,
|
context,
|
||||||
).staff.clock_in;
|
).staff.clock_in;
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return BlocListener<ClockInBloc, ClockInState>(
|
||||||
padding: const EdgeInsets.only(
|
listenWhen: (ClockInState previous, ClockInState current) =>
|
||||||
bottom: UiConstants.space24,
|
previous.selectedShift != current.selectedShift,
|
||||||
top: UiConstants.space6,
|
listener: (BuildContext context, ClockInState state) {
|
||||||
),
|
_syncGeofence(context, state.selectedShift);
|
||||||
child: Padding(
|
},
|
||||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
|
child: SingleChildScrollView(
|
||||||
child: BlocBuilder<ClockInBloc, ClockInState>(
|
padding: const EdgeInsets.only(
|
||||||
builder: (BuildContext context, ClockInState state) {
|
bottom: UiConstants.space24,
|
||||||
final List<Shift> todayShifts = state.todayShifts;
|
top: UiConstants.space6,
|
||||||
final Shift? selectedShift = state.selectedShift;
|
),
|
||||||
final String? activeShiftId = state.attendance.activeShiftId;
|
child: Padding(
|
||||||
final bool isActiveSelected =
|
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
|
||||||
selectedShift != null && selectedShift.id == activeShiftId;
|
child: BlocBuilder<ClockInBloc, ClockInState>(
|
||||||
final DateTime? checkInTime = isActiveSelected
|
builder: (BuildContext context, ClockInState state) {
|
||||||
? state.attendance.checkInTime
|
final List<Shift> todayShifts = state.todayShifts;
|
||||||
: null;
|
final Shift? selectedShift = state.selectedShift;
|
||||||
final DateTime? checkOutTime = isActiveSelected
|
final String? activeShiftId = state.attendance.activeShiftId;
|
||||||
? state.attendance.checkOutTime
|
final bool isActiveSelected =
|
||||||
: null;
|
selectedShift != null && selectedShift.id == activeShiftId;
|
||||||
final bool isCheckedIn =
|
final DateTime? checkInTime =
|
||||||
state.attendance.isCheckedIn && isActiveSelected;
|
isActiveSelected ? state.attendance.checkInTime : null;
|
||||||
|
final DateTime? checkOutTime =
|
||||||
|
isActiveSelected ? state.attendance.checkOutTime : null;
|
||||||
|
final bool isCheckedIn =
|
||||||
|
state.attendance.isCheckedIn && isActiveSelected;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
// date selector
|
// date selector
|
||||||
DateSelector(
|
DateSelector(
|
||||||
selectedDate: state.selectedDate,
|
selectedDate: state.selectedDate,
|
||||||
onSelect: (DateTime date) =>
|
onSelect: (DateTime date) =>
|
||||||
context.read<ClockInBloc>().add(DateSelected(date)),
|
ReadContext(context).read<ClockInBloc>().add(DateSelected(date)),
|
||||||
shiftDates: <String>[
|
shiftDates: <String>[
|
||||||
DateFormat('yyyy-MM-dd').format(DateTime.now()),
|
DateFormat('yyyy-MM-dd').format(DateTime.now()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space5),
|
const SizedBox(height: UiConstants.space5),
|
||||||
Text(
|
Text(
|
||||||
i18n.your_activity,
|
i18n.your_activity,
|
||||||
textAlign: TextAlign.start,
|
textAlign: TextAlign.start,
|
||||||
style: UiTypography.headline4m,
|
style: UiTypography.headline4m,
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
|
||||||
// today's shifts and actions
|
// today's shifts and actions
|
||||||
if (todayShifts.isNotEmpty)
|
if (todayShifts.isNotEmpty)
|
||||||
ShiftCardList(
|
ShiftCardList(
|
||||||
shifts: todayShifts,
|
shifts: todayShifts,
|
||||||
selectedShiftId: selectedShift?.id,
|
selectedShiftId: selectedShift?.id,
|
||||||
onShiftSelected: (Shift shift) =>
|
onShiftSelected: (Shift shift) => ReadContext(context)
|
||||||
context.read<ClockInBloc>().add(ShiftSelected(shift)),
|
.read<ClockInBloc>()
|
||||||
|
.add(ShiftSelected(shift)),
|
||||||
|
),
|
||||||
|
|
||||||
|
// action section (check-in/out buttons)
|
||||||
|
ClockInActionSection(
|
||||||
|
selectedShift: selectedShift,
|
||||||
|
isCheckedIn: isCheckedIn,
|
||||||
|
checkOutTime: checkOutTime,
|
||||||
|
checkInMode: state.checkInMode,
|
||||||
|
isActionInProgress:
|
||||||
|
state.status == ClockInStatus.actionInProgress,
|
||||||
),
|
),
|
||||||
|
|
||||||
// action section (check-in/out buttons)
|
// checked-in banner (only when checked in to the selected shift)
|
||||||
ClockInActionSection(
|
if (isCheckedIn && checkInTime != null) ...<Widget>[
|
||||||
selectedShift: selectedShift,
|
const SizedBox(height: UiConstants.space3),
|
||||||
isCheckedIn: isCheckedIn,
|
CheckedInBanner(checkInTime: checkInTime),
|
||||||
checkOutTime: checkOutTime,
|
],
|
||||||
checkInMode: state.checkInMode,
|
const SizedBox(height: UiConstants.space4),
|
||||||
isActionInProgress:
|
|
||||||
state.status == ClockInStatus.actionInProgress,
|
|
||||||
),
|
|
||||||
|
|
||||||
// checked-in banner (only if currently checked in to the selected shift)
|
|
||||||
if (isCheckedIn && checkInTime != null) ...<Widget>[
|
|
||||||
const SizedBox(height: UiConstants.space3),
|
|
||||||
CheckedInBanner(checkInTime: checkInTime),
|
|
||||||
],
|
],
|
||||||
const SizedBox(height: UiConstants.space4),
|
);
|
||||||
],
|
},
|
||||||
);
|
),
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dispatches [GeofenceStarted] or [GeofenceStopped] based on whether
|
||||||
|
/// the selected shift has coordinates.
|
||||||
|
void _syncGeofence(BuildContext context, Shift? shift) {
|
||||||
|
final GeofenceBloc geofenceBloc = ReadContext(context).read<GeofenceBloc>();
|
||||||
|
|
||||||
|
if (shift != null && shift.latitude != null && shift.longitude != null) {
|
||||||
|
geofenceBloc.add(
|
||||||
|
GeofenceStarted(
|
||||||
|
targetLat: shift.latitude!,
|
||||||
|
targetLng: shift.longitude!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
geofenceBloc.add(const GeofenceStopped());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
import '../../domain/services/geofence_service_interface.dart';
|
||||||
|
import '../bloc/geofence_bloc.dart';
|
||||||
|
import '../bloc/geofence_event.dart';
|
||||||
|
import '../bloc/geofence_state.dart';
|
||||||
|
|
||||||
|
/// Banner that displays the current geofence verification status.
|
||||||
|
///
|
||||||
|
/// Reads [GeofenceBloc] state directly and renders the appropriate
|
||||||
|
/// status message with action buttons based on permission, location,
|
||||||
|
/// and verification conditions.
|
||||||
|
class GeofenceStatusBanner extends StatelessWidget {
|
||||||
|
/// Creates a [GeofenceStatusBanner].
|
||||||
|
const GeofenceStatusBanner({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
|
||||||
|
context,
|
||||||
|
).staff.clock_in.geofence;
|
||||||
|
return BlocBuilder<GeofenceBloc, GeofenceState>(
|
||||||
|
builder: (BuildContext context, GeofenceState state) {
|
||||||
|
// Hide banner when no target coordinates are set.
|
||||||
|
if (state.targetLat == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return _buildBannerForState(context, state, i18n);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determines which banner variant to display based on the current state.
|
||||||
|
Widget _buildBannerForState(
|
||||||
|
BuildContext context,
|
||||||
|
GeofenceState state,
|
||||||
|
TranslationsStaffClockInGeofenceEn i18n,
|
||||||
|
) {
|
||||||
|
// 1. Location services disabled.
|
||||||
|
if (state.permissionStatus == LocationPermissionStatus.serviceDisabled ||
|
||||||
|
(state.isLocationTimedOut && !state.isLocationServiceEnabled)) {
|
||||||
|
return _BannerContainer(
|
||||||
|
backgroundColor: UiColors.tagError,
|
||||||
|
borderColor: UiColors.error,
|
||||||
|
icon: UiIcons.error,
|
||||||
|
iconColor: UiColors.textError,
|
||||||
|
title: i18n.service_disabled,
|
||||||
|
titleStyle: UiTypography.body3m.textError,
|
||||||
|
action: _BannerActionButton(
|
||||||
|
label: i18n.open_settings,
|
||||||
|
onPressed: () => _openLocationSettings(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Permission denied (can re-request).
|
||||||
|
if (state.permissionStatus == LocationPermissionStatus.denied) {
|
||||||
|
return _BannerContainer(
|
||||||
|
backgroundColor: UiColors.tagError,
|
||||||
|
borderColor: UiColors.error,
|
||||||
|
icon: UiIcons.error,
|
||||||
|
iconColor: UiColors.textError,
|
||||||
|
title: i18n.permission_required,
|
||||||
|
titleStyle: UiTypography.body3m.textError,
|
||||||
|
action: _BannerActionButton(
|
||||||
|
label: i18n.grant_permission,
|
||||||
|
onPressed: () {
|
||||||
|
if (state.targetLat != null && state.targetLng != null) {
|
||||||
|
ReadContext(context).read<GeofenceBloc>().add(
|
||||||
|
GeofenceStarted(
|
||||||
|
targetLat: state.targetLat!,
|
||||||
|
targetLng: state.targetLng!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Permission permanently denied.
|
||||||
|
if (state.permissionStatus == LocationPermissionStatus.deniedForever) {
|
||||||
|
return _BannerContainer(
|
||||||
|
backgroundColor: UiColors.tagError,
|
||||||
|
borderColor: UiColors.error,
|
||||||
|
icon: UiIcons.error,
|
||||||
|
iconColor: UiColors.textError,
|
||||||
|
title: i18n.permission_denied_forever,
|
||||||
|
titleStyle: UiTypography.body3m.textError,
|
||||||
|
action: _BannerActionButton(
|
||||||
|
label: i18n.open_settings,
|
||||||
|
onPressed: () => _openAppSettings(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Actively verifying location.
|
||||||
|
if (state.isVerifying) {
|
||||||
|
return _BannerContainer(
|
||||||
|
backgroundColor: UiColors.tagInProgress,
|
||||||
|
borderColor: UiColors.primary,
|
||||||
|
icon: null,
|
||||||
|
iconColor: UiColors.primary,
|
||||||
|
title: i18n.verifying,
|
||||||
|
titleStyle: UiTypography.body3m.primary,
|
||||||
|
leading: const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: UiColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Location verified successfully.
|
||||||
|
if (state.isLocationVerified) {
|
||||||
|
return _BannerContainer(
|
||||||
|
backgroundColor: UiColors.tagSuccess,
|
||||||
|
borderColor: UiColors.success,
|
||||||
|
icon: UiIcons.checkCircle,
|
||||||
|
iconColor: UiColors.textSuccess,
|
||||||
|
title: i18n.verified,
|
||||||
|
titleStyle: UiTypography.body3m.textSuccess,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Timed out but location services are enabled.
|
||||||
|
if (state.isLocationTimedOut && state.isLocationServiceEnabled) {
|
||||||
|
return _BannerContainer(
|
||||||
|
backgroundColor: UiColors.tagPending,
|
||||||
|
borderColor: UiColors.textWarning,
|
||||||
|
icon: UiIcons.warning,
|
||||||
|
iconColor: UiColors.textWarning,
|
||||||
|
title: i18n.timeout_title,
|
||||||
|
titleStyle: UiTypography.body3m.textWarning,
|
||||||
|
subtitle: i18n.timeout_desc,
|
||||||
|
subtitleStyle: UiTypography.body3r.textWarning,
|
||||||
|
action: _BannerActionButton(
|
||||||
|
label: i18n.retry,
|
||||||
|
color: UiColors.textWarning,
|
||||||
|
onPressed: () {
|
||||||
|
ReadContext(
|
||||||
|
context,
|
||||||
|
).read<GeofenceBloc>().add(const GeofenceRetryRequested());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Not verified and too far away (distance known).
|
||||||
|
if (!state.isLocationVerified &&
|
||||||
|
!state.isLocationTimedOut &&
|
||||||
|
state.distanceFromTarget != null) {
|
||||||
|
return _BannerContainer(
|
||||||
|
backgroundColor: UiColors.tagPending,
|
||||||
|
borderColor: UiColors.textWarning,
|
||||||
|
icon: UiIcons.warning,
|
||||||
|
iconColor: UiColors.textWarning,
|
||||||
|
title: i18n.too_far_title,
|
||||||
|
titleStyle: UiTypography.body3m.textWarning,
|
||||||
|
subtitle: i18n.too_far_desc(
|
||||||
|
distance: _formatDistance(state.distanceFromTarget!),
|
||||||
|
),
|
||||||
|
subtitleStyle: UiTypography.body3r.textWarning,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: hide banner for unmatched states.
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the device location settings via the geofence service.
|
||||||
|
void _openLocationSettings() {
|
||||||
|
Modular.get<GeofenceServiceInterface>().openLocationSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the app settings page via the geofence service.
|
||||||
|
void _openAppSettings() {
|
||||||
|
Modular.get<GeofenceServiceInterface>().openAppSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats a distance in meters to a human-readable string.
|
||||||
|
String _formatDistance(double meters) {
|
||||||
|
if (meters >= 1000) {
|
||||||
|
return '${(meters / 1000).toStringAsFixed(1)} km';
|
||||||
|
}
|
||||||
|
return '${meters.round()} m';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal container widget that provides consistent banner styling.
|
||||||
|
///
|
||||||
|
/// Renders a rounded container with an icon (or custom leading widget),
|
||||||
|
/// title/subtitle text, and an optional action button.
|
||||||
|
class _BannerContainer extends StatelessWidget {
|
||||||
|
/// Creates a [_BannerContainer].
|
||||||
|
const _BannerContainer({
|
||||||
|
required this.backgroundColor,
|
||||||
|
required this.borderColor,
|
||||||
|
required this.icon,
|
||||||
|
required this.iconColor,
|
||||||
|
required this.title,
|
||||||
|
required this.titleStyle,
|
||||||
|
this.subtitle,
|
||||||
|
this.subtitleStyle,
|
||||||
|
this.action,
|
||||||
|
this.leading,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Background color of the banner container.
|
||||||
|
final Color backgroundColor;
|
||||||
|
|
||||||
|
/// Border color of the banner container.
|
||||||
|
final Color borderColor;
|
||||||
|
|
||||||
|
/// Icon to display on the left side, or null if [leading] is used.
|
||||||
|
final IconData? icon;
|
||||||
|
|
||||||
|
/// Color for the icon.
|
||||||
|
final Color iconColor;
|
||||||
|
|
||||||
|
/// Primary message displayed in the banner.
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
/// Text style for the title.
|
||||||
|
final TextStyle titleStyle;
|
||||||
|
|
||||||
|
/// Optional secondary message below the title.
|
||||||
|
final String? subtitle;
|
||||||
|
|
||||||
|
/// Text style for the subtitle.
|
||||||
|
final TextStyle? subtitleStyle;
|
||||||
|
|
||||||
|
/// Optional action button on the right side.
|
||||||
|
final Widget? action;
|
||||||
|
|
||||||
|
/// Optional custom leading widget, used instead of the icon.
|
||||||
|
final Widget? leading;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
border: Border.all(color: borderColor.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: <Widget>[
|
||||||
|
// Icon or custom leading widget.
|
||||||
|
if (leading != null)
|
||||||
|
leading!
|
||||||
|
else if (icon != null)
|
||||||
|
Icon(icon, color: iconColor, size: 20),
|
||||||
|
|
||||||
|
const SizedBox(width: UiConstants.space2),
|
||||||
|
|
||||||
|
// Title and optional subtitle.
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(title, style: titleStyle),
|
||||||
|
if (subtitle != null) ...<Widget>[
|
||||||
|
const SizedBox(height: UiConstants.space1),
|
||||||
|
Text(subtitle!, style: subtitleStyle),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Optional action button.
|
||||||
|
if (action != null) ...<Widget>[
|
||||||
|
const SizedBox(width: UiConstants.space2),
|
||||||
|
action!,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tappable text button used as a banner action.
|
||||||
|
class _BannerActionButton extends StatelessWidget {
|
||||||
|
/// Creates a [_BannerActionButton].
|
||||||
|
const _BannerActionButton({
|
||||||
|
required this.label,
|
||||||
|
required this.onPressed,
|
||||||
|
this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Text label for the button.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// Callback when the button is pressed.
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
/// Optional override color for the button text.
|
||||||
|
final Color? color;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onPressed,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: UiTypography.body3m.copyWith(
|
||||||
|
color: color ?? UiColors.primary,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
decorationColor: color ?? UiColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:core_localization/core_localization.dart';
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
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.
|
/// Shows the NFC scanning dialog and returns `true` when a scan completes.
|
||||||
///
|
///
|
||||||
@@ -35,7 +37,7 @@ Future<bool> showNfcScanDialog(BuildContext context) async {
|
|||||||
const Duration(milliseconds: 1000),
|
const Duration(milliseconds: 1000),
|
||||||
);
|
);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
Navigator.of(dialogContext).pop();
|
Modular.to.popSafe();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -151,13 +151,6 @@ class _SwipeToCheckInState extends State<SwipeToCheckIn>
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: currentColor,
|
color: currentColor,
|
||||||
borderRadius: UiConstants.radiusLg,
|
borderRadius: UiConstants.radiusLg,
|
||||||
boxShadow: <BoxShadow>[
|
|
||||||
BoxShadow(
|
|
||||||
color: UiColors.black.withValues(alpha: 0.1),
|
|
||||||
blurRadius: 4,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
|
|||||||
@@ -3,28 +3,58 @@ import 'package:flutter_modular/flutter_modular.dart';
|
|||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
|
||||||
import 'data/repositories_impl/clock_in_repository_impl.dart';
|
import 'data/repositories_impl/clock_in_repository_impl.dart';
|
||||||
|
import 'data/services/background_geofence_service.dart';
|
||||||
|
import 'data/services/geofence_service_impl.dart';
|
||||||
import 'domain/repositories/clock_in_repository_interface.dart';
|
import 'domain/repositories/clock_in_repository_interface.dart';
|
||||||
|
import 'domain/services/geofence_service_interface.dart';
|
||||||
import 'domain/usecases/clock_in_usecase.dart';
|
import 'domain/usecases/clock_in_usecase.dart';
|
||||||
import 'domain/usecases/clock_out_usecase.dart';
|
import 'domain/usecases/clock_out_usecase.dart';
|
||||||
import 'domain/usecases/get_attendance_status_usecase.dart';
|
import 'domain/usecases/get_attendance_status_usecase.dart';
|
||||||
import 'domain/usecases/get_todays_shift_usecase.dart';
|
import 'domain/usecases/get_todays_shift_usecase.dart';
|
||||||
import 'presentation/bloc/clock_in_bloc.dart';
|
import 'presentation/bloc/clock_in_bloc.dart';
|
||||||
|
import 'presentation/bloc/geofence_bloc.dart';
|
||||||
import 'presentation/pages/clock_in_page.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 {
|
class StaffClockInModule extends Module {
|
||||||
|
@override
|
||||||
|
List<Module> get imports => <Module>[CoreModule()];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
// Repositories
|
// Repositories
|
||||||
i.add<ClockInRepositoryInterface>(ClockInRepositoryImpl.new);
|
i.add<ClockInRepositoryInterface>(ClockInRepositoryImpl.new);
|
||||||
|
|
||||||
|
// Geofence Services (resolve core singletons from DI)
|
||||||
|
i.add<GeofenceServiceInterface>(
|
||||||
|
() => GeofenceServiceImpl(
|
||||||
|
locationService: i.get<LocationService>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
i.add<BackgroundGeofenceService>(
|
||||||
|
() => BackgroundGeofenceService(
|
||||||
|
backgroundTaskService: i.get<BackgroundTaskService>(),
|
||||||
|
notificationService: i.get<NotificationService>(),
|
||||||
|
storageService: i.get<StorageService>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Use Cases
|
// Use Cases
|
||||||
i.add<GetTodaysShiftUseCase>(GetTodaysShiftUseCase.new);
|
i.add<GetTodaysShiftUseCase>(GetTodaysShiftUseCase.new);
|
||||||
i.add<GetAttendanceStatusUseCase>(GetAttendanceStatusUseCase.new);
|
i.add<GetAttendanceStatusUseCase>(GetAttendanceStatusUseCase.new);
|
||||||
i.add<ClockInUseCase>(ClockInUseCase.new);
|
i.add<ClockInUseCase>(ClockInUseCase.new);
|
||||||
i.add<ClockOutUseCase>(ClockOutUseCase.new);
|
i.add<ClockOutUseCase>(ClockOutUseCase.new);
|
||||||
|
|
||||||
// BLoC
|
// BLoCs (transient -- new instance per navigation)
|
||||||
i.add<ClockInBloc>(ClockInBloc.new);
|
i.add<ClockInBloc>(ClockInBloc.new);
|
||||||
|
i.add<GeofenceBloc>(
|
||||||
|
() => GeofenceBloc(
|
||||||
|
geofenceService: i.get<GeofenceServiceInterface>(),
|
||||||
|
backgroundGeofenceService: i.get<BackgroundGeofenceService>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -28,6 +28,4 @@ dependencies:
|
|||||||
krow_core:
|
krow_core:
|
||||||
path: ../../../core
|
path: ../../../core
|
||||||
firebase_data_connect: ^0.2.2+2
|
firebase_data_connect: ^0.2.2+2
|
||||||
geolocator: ^10.1.0
|
|
||||||
permission_handler: ^11.0.1
|
|
||||||
firebase_auth: ^6.1.4
|
firebase_auth: ^6.1.4
|
||||||
|
|||||||
@@ -289,6 +289,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
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:
|
diff_match_patch:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -510,6 +518,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
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:
|
flutter_localizations:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -557,22 +597,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.0"
|
version: "4.0.0"
|
||||||
|
geoclue:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geoclue
|
||||||
|
sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.1"
|
||||||
geolocator:
|
geolocator:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: geolocator
|
name: geolocator
|
||||||
sha256: f4efb8d3c4cdcad2e226af9661eb1a0dd38c71a9494b22526f9da80ab79520e5
|
sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.1.1"
|
version: "14.0.2"
|
||||||
geolocator_android:
|
geolocator_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: geolocator_android
|
name: geolocator_android
|
||||||
sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d
|
sha256: "179c3cb66dfa674fc9ccbf2be872a02658724d1c067634e2c427cf6df7df901a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.6.2"
|
version: "5.0.2"
|
||||||
geolocator_apple:
|
geolocator_apple:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -581,6 +629,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.13"
|
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:
|
geolocator_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -593,10 +649,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: geolocator_web
|
name: geolocator_web
|
||||||
sha256: "102e7da05b48ca6bf0a5bda0010f886b171d1a08059f01bfe02addd0175ebece"
|
sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.1"
|
version: "4.1.3"
|
||||||
geolocator_windows:
|
geolocator_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -717,6 +773,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.4"
|
version: "3.2.4"
|
||||||
|
gsettings:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: gsettings
|
||||||
|
sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.8"
|
||||||
hooks:
|
hooks:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1021,6 +1085,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
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:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1077,54 +1157,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
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:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1536,6 +1568,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.15"
|
version: "0.6.15"
|
||||||
|
timezone:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: timezone
|
||||||
|
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.11.0"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1680,6 +1720,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.15.0"
|
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:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
Reference in New Issue
Block a user