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
|
||||
3. Hardcoded spacing values — must use design system spacing constants
|
||||
4. Direct icon library imports — must use design system icon abstractions
|
||||
5. Direct `Navigator.push/pop/replace` usage — must use safe navigation extensions
|
||||
5. Direct `Navigator.push/pop/replace` usage — must use safe navigation extensions from the `apps/mobile/packages/core/lib/src/routing/navigation_extensions.dart`.
|
||||
6. Missing tests for use cases or repositories
|
||||
7. Complex BLoC without bloc_test coverage
|
||||
8. Test coverage below 70% for business logic
|
||||
|
||||
@@ -46,6 +46,7 @@ android {
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
@@ -122,6 +123,10 @@ afterEvaluate {
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
@@ -35,16 +35,31 @@ public final class GeneratedPluginRegistrant {
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin firebase_core, io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.baseflow.geolocator.GeolocatorPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin geolocator_android, com.baseflow.geolocator.GeolocatorPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin image_picker_android, io.flutter.plugins.imagepicker.ImagePickerPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
|
||||
} catch (Exception e) {
|
||||
@@ -65,5 +80,10 @@ public final class GeneratedPluginRegistrant {
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.workmanager.WorkmanagerPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin workmanager_android, dev.fluttercommunity.workmanager.WorkmanagerPlugin", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,12 +30,30 @@
|
||||
@import firebase_core;
|
||||
#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>)
|
||||
#import <image_picker_ios/FLTImagePickerPlugin.h>
|
||||
#else
|
||||
@import image_picker_ios;
|
||||
#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>)
|
||||
#import <record_ios/RecordIosPlugin.h>
|
||||
#else
|
||||
@@ -54,6 +72,12 @@
|
||||
@import url_launcher_ios;
|
||||
#endif
|
||||
|
||||
#if __has_include(<workmanager_apple/WorkmanagerPlugin.h>)
|
||||
#import <workmanager_apple/WorkmanagerPlugin.h>
|
||||
#else
|
||||
@import workmanager_apple;
|
||||
#endif
|
||||
|
||||
@implementation GeneratedPluginRegistrant
|
||||
|
||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
||||
@@ -61,10 +85,14 @@
|
||||
[FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]];
|
||||
[FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]];
|
||||
[FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]];
|
||||
[FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]];
|
||||
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
|
||||
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
||||
[FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]];
|
||||
[RecordIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"RecordIosPlugin"]];
|
||||
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
|
||||
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
|
||||
[WorkmanagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"WorkmanagerPlugin"]];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -10,6 +10,9 @@ import file_selector_macos
|
||||
import firebase_app_check
|
||||
import firebase_auth
|
||||
import firebase_core
|
||||
import flutter_local_notifications
|
||||
import geolocator_apple
|
||||
import package_info_plus
|
||||
import record_macos
|
||||
import shared_preferences_foundation
|
||||
import url_launcher_macos
|
||||
@@ -20,6 +23,9 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
|
||||
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <firebase_auth/firebase_auth_plugin_c_api.h>
|
||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||
#include <geolocator_windows/geolocator_windows.h>
|
||||
#include <record_windows/record_windows_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
@@ -19,6 +20,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
|
||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||
GeolocatorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
||||
RecordWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
|
||||
@@ -6,11 +6,13 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
firebase_auth
|
||||
firebase_core
|
||||
geolocator_windows
|
||||
record_windows
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
flutter_local_notifications_windows
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
@@ -46,6 +46,7 @@ android {
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
@@ -126,6 +127,10 @@ afterEvaluate {
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<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
|
||||
android:label="@string/app_name"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -35,6 +35,11 @@ public final class GeneratedPluginRegistrant {
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin firebase_core, io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
|
||||
} catch (Exception e) {
|
||||
@@ -55,16 +60,16 @@ public final class GeneratedPluginRegistrant {
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin image_picker_android, io.flutter.plugins.imagepicker.ImagePickerPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
|
||||
} catch (Exception e) {
|
||||
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 {
|
||||
flutterEngine.getPlugins().add(new com.llfbandit.record.RecordPlugin());
|
||||
} catch (Exception e) {
|
||||
@@ -80,5 +85,10 @@ public final class GeneratedPluginRegistrant {
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.workmanager.WorkmanagerPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin workmanager_android, dev.fluttercommunity.workmanager.WorkmanagerPlugin", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
@import firebase_core;
|
||||
#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
|
||||
@@ -48,10 +54,10 @@
|
||||
@import image_picker_ios;
|
||||
#endif
|
||||
|
||||
#if __has_include(<permission_handler_apple/PermissionHandlerPlugin.h>)
|
||||
#import <permission_handler_apple/PermissionHandlerPlugin.h>
|
||||
#if __has_include(<package_info_plus/FPPPackageInfoPlusPlugin.h>)
|
||||
#import <package_info_plus/FPPPackageInfoPlusPlugin.h>
|
||||
#else
|
||||
@import permission_handler_apple;
|
||||
@import package_info_plus;
|
||||
#endif
|
||||
|
||||
#if __has_include(<record_ios/RecordIosPlugin.h>)
|
||||
@@ -72,6 +78,12 @@
|
||||
@import url_launcher_ios;
|
||||
#endif
|
||||
|
||||
#if __has_include(<workmanager_apple/WorkmanagerPlugin.h>)
|
||||
#import <workmanager_apple/WorkmanagerPlugin.h>
|
||||
#else
|
||||
@import workmanager_apple;
|
||||
#endif
|
||||
|
||||
@implementation GeneratedPluginRegistrant
|
||||
|
||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
||||
@@ -79,13 +91,15 @@
|
||||
[FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]];
|
||||
[FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]];
|
||||
[FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]];
|
||||
[FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]];
|
||||
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
|
||||
[FLTGoogleMapsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTGoogleMapsPlugin"]];
|
||||
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
||||
[PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]];
|
||||
[FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]];
|
||||
[RecordIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"RecordIosPlugin"]];
|
||||
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
|
||||
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
|
||||
[WorkmanagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"WorkmanagerPlugin"]];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -45,6 +45,14 @@
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<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>
|
||||
<string>$(DART_DEFINES)</string>
|
||||
</dict>
|
||||
|
||||
@@ -11,13 +11,31 @@ import 'package:krowwithus_staff/firebase_options.dart';
|
||||
import 'package:staff_authentication/staff_authentication.dart'
|
||||
as staff_authentication;
|
||||
import 'package:staff_main/staff_main.dart' as staff_main;
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
|
||||
import 'src/widgets/session_listener.dart';
|
||||
|
||||
/// Top-level callback dispatcher for background tasks.
|
||||
///
|
||||
/// Must be a top-level function because workmanager executes it in a separate
|
||||
/// isolate where the DI container is not available.
|
||||
@pragma('vm:entry-point')
|
||||
void callbackDispatcher() {
|
||||
Workmanager().executeTask((String task, Map<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 {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
|
||||
// Initialize background task processing for geofence checks
|
||||
await const BackgroundTaskService().initialize(callbackDispatcher);
|
||||
|
||||
// Register global BLoC observer for centralized error logging
|
||||
Bloc.observer = CoreBlocObserver(
|
||||
logEvents: true,
|
||||
|
||||
@@ -10,7 +10,9 @@ import file_selector_macos
|
||||
import firebase_app_check
|
||||
import firebase_auth
|
||||
import firebase_core
|
||||
import flutter_local_notifications
|
||||
import geolocator_apple
|
||||
import package_info_plus
|
||||
import record_macos
|
||||
import shared_preferences_foundation
|
||||
import url_launcher_macos
|
||||
@@ -21,7 +23,9 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
|
||||
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
|
||||
@@ -34,6 +34,7 @@ dependencies:
|
||||
flutter_modular: ^6.3.0
|
||||
firebase_core: ^4.4.0
|
||||
flutter_bloc: ^8.1.6
|
||||
workmanager: ^0.9.0+3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
#include <firebase_auth/firebase_auth_plugin_c_api.h>
|
||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||
#include <geolocator_windows/geolocator_windows.h>
|
||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||
#include <record_windows/record_windows_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
@@ -23,8 +22,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||
GeolocatorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||
RecordWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
|
||||
@@ -7,12 +7,12 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
firebase_auth
|
||||
firebase_core
|
||||
geolocator_windows
|
||||
permission_handler_windows
|
||||
record_windows
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
flutter_local_notifications_windows
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
@@ -33,3 +33,7 @@ export 'src/services/device/gallery/gallery_service.dart';
|
||||
export 'src/services/device/file/file_picker_service.dart';
|
||||
export 'src/services/device/file_upload/device_file_upload_service.dart';
|
||||
export 'src/services/device/audio/audio_recorder_service.dart';
|
||||
export 'src/services/device/location/location_service.dart';
|
||||
export 'src/services/device/notification/notification_service.dart';
|
||||
export 'src/services/device/storage/storage_service.dart';
|
||||
export 'src/services/device/background_task/background_task_service.dart';
|
||||
|
||||
@@ -48,5 +48,13 @@ class CoreModule extends Module {
|
||||
apiUploadService: i.get<FileUploadService>(),
|
||||
),
|
||||
);
|
||||
|
||||
// 6. Register Geofence Device Services
|
||||
i.addLazySingleton<LocationService>(() => const LocationService());
|
||||
i.addLazySingleton<NotificationService>(() => NotificationService());
|
||||
i.addLazySingleton<StorageService>(() => StorageService());
|
||||
i.addLazySingleton<BackgroundTaskService>(
|
||||
() => const BackgroundTaskService(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
record: ^6.2.0
|
||||
firebase_auth: ^6.1.4
|
||||
geolocator: ^14.0.2
|
||||
flutter_local_notifications: ^21.0.0
|
||||
shared_preferences: ^2.5.4
|
||||
workmanager: ^0.9.0+3
|
||||
|
||||
@@ -926,6 +926,28 @@
|
||||
"submit": "Submit",
|
||||
"success_title": "Break Logged!",
|
||||
"close": "Close"
|
||||
},
|
||||
"geofence": {
|
||||
"service_disabled": "Location services are turned off. Enable them to clock in.",
|
||||
"permission_required": "Location permission is required to clock in.",
|
||||
"permission_denied_forever": "Location was permanently denied. Enable it in Settings.",
|
||||
"open_settings": "Open Settings",
|
||||
"grant_permission": "Grant Permission",
|
||||
"verifying": "Verifying your location...",
|
||||
"too_far_title": "You're Too Far Away",
|
||||
"too_far_desc": "You are $distance away. Move within 500m to clock in.",
|
||||
"verified": "Location Verified",
|
||||
"not_in_range": "You must be at the workplace to clock in.",
|
||||
"timeout_title": "Can't Verify Location",
|
||||
"timeout_desc": "Unable to determine your location. You can still clock in with a note.",
|
||||
"timeout_note_hint": "Why can't your location be verified?",
|
||||
"clock_in_greeting_title": "You're Clocked In!",
|
||||
"clock_in_greeting_body": "Have a great shift. We'll keep track of your location.",
|
||||
"background_left_title": "You've Left the Workplace",
|
||||
"background_left_body": "You appear to be more than 500m from your shift location.",
|
||||
"always_permission_title": "Background Location Needed",
|
||||
"always_permission_desc": "To verify your location during shifts, please allow location access 'Always'.",
|
||||
"retry": "Retry"
|
||||
}
|
||||
},
|
||||
"availability": {
|
||||
@@ -1416,6 +1438,10 @@
|
||||
"application_not_found": "Your application couldn't be found.",
|
||||
"no_active_shift": "You don't have an active shift to clock out from."
|
||||
},
|
||||
"clock_in": {
|
||||
"location_verification_required": "Please wait for location verification before clocking in.",
|
||||
"notes_required_for_timeout": "Please add a note explaining why your location can't be verified."
|
||||
},
|
||||
"generic": {
|
||||
"unknown": "Something went wrong. Please try again.",
|
||||
"no_connection": "No internet connection. Please check your network and try again.",
|
||||
|
||||
@@ -921,6 +921,28 @@
|
||||
"submit": "Enviar",
|
||||
"success_title": "\u00a1Descanso registrado!",
|
||||
"close": "Cerrar"
|
||||
},
|
||||
"geofence": {
|
||||
"service_disabled": "Los servicios de ubicación están desactivados. Actívelos para registrar entrada.",
|
||||
"permission_required": "Se requiere permiso de ubicación para registrar entrada.",
|
||||
"permission_denied_forever": "La ubicación fue denegada permanentemente. Actívela en Configuración.",
|
||||
"open_settings": "Abrir Configuración",
|
||||
"grant_permission": "Otorgar Permiso",
|
||||
"verifying": "Verificando su ubicación...",
|
||||
"too_far_title": "Está Demasiado Lejos",
|
||||
"too_far_desc": "Está a $distance de distancia. Acérquese a 500m para registrar entrada.",
|
||||
"verified": "Ubicación Verificada",
|
||||
"not_in_range": "Debe estar en el lugar de trabajo para registrar entrada.",
|
||||
"timeout_title": "No se Puede Verificar la Ubicación",
|
||||
"timeout_desc": "No se pudo determinar su ubicación. Puede registrar entrada con una nota.",
|
||||
"timeout_note_hint": "¿Por qué no se puede verificar su ubicación?",
|
||||
"clock_in_greeting_title": "¡Entrada Registrada!",
|
||||
"clock_in_greeting_body": "Buen turno. Seguiremos el registro de su ubicación.",
|
||||
"background_left_title": "Ha Salido del Lugar de Trabajo",
|
||||
"background_left_body": "Parece que está a más de 500m de la ubicación de su turno.",
|
||||
"always_permission_title": "Se Necesita Ubicación en Segundo Plano",
|
||||
"always_permission_desc": "Para verificar su ubicación durante los turnos, permita el acceso a la ubicación 'Siempre'.",
|
||||
"retry": "Reintentar"
|
||||
}
|
||||
},
|
||||
"availability": {
|
||||
@@ -1411,6 +1433,10 @@
|
||||
"application_not_found": "No se pudo encontrar tu solicitud.",
|
||||
"no_active_shift": "No tienes un turno activo para registrar salida."
|
||||
},
|
||||
"clock_in": {
|
||||
"location_verification_required": "Por favor, espera la verificaci\u00f3n de ubicaci\u00f3n antes de registrar entrada.",
|
||||
"notes_required_for_timeout": "Por favor, agrega una nota explicando por qu\u00e9 no se puede verificar tu ubicaci\u00f3n."
|
||||
},
|
||||
"generic": {
|
||||
"unknown": "Algo sali\u00f3 mal. Por favor, intenta de nuevo.",
|
||||
"no_connection": "Sin conexi\u00f3n a internet. Por favor, verifica tu red e intenta de nuevo.",
|
||||
|
||||
@@ -35,6 +35,8 @@ String translateErrorKey(String key) {
|
||||
return _translateProfileError(errorType);
|
||||
case 'shift':
|
||||
return _translateShiftError(errorType);
|
||||
case 'clock_in':
|
||||
return _translateClockInError(errorType);
|
||||
case 'generic':
|
||||
return _translateGenericError(errorType);
|
||||
default:
|
||||
@@ -127,6 +129,18 @@ String _translateShiftError(String errorType) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Translates clock-in error keys to localized strings.
|
||||
String _translateClockInError(String errorType) {
|
||||
switch (errorType) {
|
||||
case 'location_verification_required':
|
||||
return t.errors.clock_in.location_verification_required;
|
||||
case 'notes_required_for_timeout':
|
||||
return t.errors.clock_in.notes_required_for_timeout;
|
||||
default:
|
||||
return t.errors.generic.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
String _translateGenericError(String errorType) {
|
||||
switch (errorType) {
|
||||
case 'unknown':
|
||||
|
||||
@@ -14,6 +14,10 @@ export 'src/core/services/api_services/file_visibility.dart';
|
||||
|
||||
// Device
|
||||
export 'src/core/services/device/base_device_service.dart';
|
||||
export 'src/core/services/device/location_permission_status.dart';
|
||||
|
||||
// Models
|
||||
export 'src/core/models/device_location.dart';
|
||||
|
||||
// Users & Membership
|
||||
export 'src/entities/users/user.dart';
|
||||
|
||||
@@ -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_auth
|
||||
import firebase_core
|
||||
import flutter_local_notifications
|
||||
import geolocator_apple
|
||||
import package_info_plus
|
||||
import record_macos
|
||||
import shared_preferences_foundation
|
||||
|
||||
@@ -19,6 +22,9 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
|
||||
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <firebase_auth/firebase_auth_plugin_c_api.h>
|
||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||
#include <geolocator_windows/geolocator_windows.h>
|
||||
#include <record_windows/record_windows_plugin_c_api.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
@@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
|
||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||
GeolocatorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
||||
RecordWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
firebase_auth
|
||||
firebase_core
|
||||
geolocator_windows
|
||||
record_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
flutter_local_notifications_windows
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
@@ -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:geolocator/geolocator.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
@@ -12,8 +11,13 @@ import '../../domain/usecases/get_todays_shift_usecase.dart';
|
||||
import 'clock_in_event.dart';
|
||||
import 'clock_in_state.dart';
|
||||
|
||||
/// BLoC responsible for clock-in/clock-out operations and shift management.
|
||||
///
|
||||
/// Location and geofence concerns are delegated to [GeofenceBloc].
|
||||
/// The UI bridges geofence state into [CheckInRequested] event parameters.
|
||||
class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
with BlocErrorHandler<ClockInState> {
|
||||
/// Creates a [ClockInBloc] with the required use cases.
|
||||
ClockInBloc({
|
||||
required GetTodaysShiftUseCase getTodaysShift,
|
||||
required GetAttendanceStatusUseCase getAttendanceStatus,
|
||||
@@ -30,20 +34,16 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
on<CheckInRequested>(_onCheckIn);
|
||||
on<CheckOutRequested>(_onCheckOut);
|
||||
on<CheckInModeChanged>(_onModeChanged);
|
||||
on<RequestLocationPermission>(_onRequestLocationPermission);
|
||||
on<CommuteModeToggled>(_onCommuteModeToggled);
|
||||
on<LocationUpdated>(_onLocationUpdated);
|
||||
|
||||
add(ClockInPageLoaded());
|
||||
}
|
||||
|
||||
final GetTodaysShiftUseCase _getTodaysShift;
|
||||
final GetAttendanceStatusUseCase _getAttendanceStatus;
|
||||
final ClockInUseCase _clockIn;
|
||||
final ClockOutUseCase _clockOut;
|
||||
|
||||
// Mock Venue Location (e.g., Grand Hotel, NYC)
|
||||
static const double allowedRadiusMeters = 500;
|
||||
|
||||
/// Loads today's shifts and the current attendance status.
|
||||
Future<void> _onLoaded(
|
||||
ClockInPageLoaded event,
|
||||
Emitter<ClockInState> emit,
|
||||
@@ -72,10 +72,6 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
selectedShift: selectedShift,
|
||||
attendance: status,
|
||||
));
|
||||
|
||||
if (selectedShift != null && !status.isCheckedIn) {
|
||||
add(RequestLocationPermission());
|
||||
}
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: ClockInStatus.failure,
|
||||
@@ -84,106 +80,15 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onRequestLocationPermission(
|
||||
RequestLocationPermission event,
|
||||
Emitter<ClockInState> emit,
|
||||
) async {
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
}
|
||||
|
||||
final bool hasConsent =
|
||||
permission == LocationPermission.always ||
|
||||
permission == LocationPermission.whileInUse;
|
||||
|
||||
emit(state.copyWith(hasLocationConsent: hasConsent));
|
||||
|
||||
if (hasConsent) {
|
||||
await _startLocationUpdates();
|
||||
}
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _startLocationUpdates() async {
|
||||
// Note: handleErrorWithResult could be used here too if we want centralized logging/conversion
|
||||
try {
|
||||
final Position position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high,
|
||||
);
|
||||
|
||||
double distance = 0;
|
||||
bool isVerified =
|
||||
false; // Require location match by default if shift has location
|
||||
|
||||
if (state.selectedShift != null &&
|
||||
state.selectedShift!.latitude != null &&
|
||||
state.selectedShift!.longitude != null) {
|
||||
distance = Geolocator.distanceBetween(
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
state.selectedShift!.latitude!,
|
||||
state.selectedShift!.longitude!,
|
||||
);
|
||||
isVerified = distance <= allowedRadiusMeters;
|
||||
} else {
|
||||
isVerified = true;
|
||||
}
|
||||
|
||||
if (!isClosed) {
|
||||
add(
|
||||
LocationUpdated(
|
||||
position: position,
|
||||
distance: distance,
|
||||
isVerified: isVerified,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// Geolocator errors usually handled via onRequestLocationPermission
|
||||
}
|
||||
}
|
||||
|
||||
void _onLocationUpdated(
|
||||
LocationUpdated event,
|
||||
Emitter<ClockInState> emit,
|
||||
) {
|
||||
emit(state.copyWith(
|
||||
currentLocation: event.position,
|
||||
distanceFromVenue: event.distance,
|
||||
isLocationVerified: event.isVerified,
|
||||
etaMinutes:
|
||||
(event.distance / 80).round(), // Rough estimate: 80m/min walking speed
|
||||
));
|
||||
}
|
||||
|
||||
void _onCommuteModeToggled(
|
||||
CommuteModeToggled event,
|
||||
Emitter<ClockInState> emit,
|
||||
) {
|
||||
emit(state.copyWith(isCommuteModeOn: event.isEnabled));
|
||||
if (event.isEnabled) {
|
||||
add(RequestLocationPermission());
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the currently selected shift.
|
||||
void _onShiftSelected(
|
||||
ShiftSelected event,
|
||||
Emitter<ClockInState> emit,
|
||||
) {
|
||||
emit(state.copyWith(selectedShift: event.shift));
|
||||
if (!state.attendance.isCheckedIn) {
|
||||
_startLocationUpdates();
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the selected date for shift viewing.
|
||||
void _onDateSelected(
|
||||
DateSelected event,
|
||||
Emitter<ClockInState> emit,
|
||||
@@ -191,6 +96,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
emit(state.copyWith(selectedDate: event.date));
|
||||
}
|
||||
|
||||
/// Updates the check-in interaction mode.
|
||||
void _onModeChanged(
|
||||
CheckInModeChanged event,
|
||||
Emitter<ClockInState> emit,
|
||||
@@ -198,10 +104,44 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
emit(state.copyWith(checkInMode: event.mode));
|
||||
}
|
||||
|
||||
/// Handles a clock-in request.
|
||||
///
|
||||
/// Geofence state is passed via event parameters from the UI layer:
|
||||
/// - If the shift has a venue (lat/lng) and location is neither verified
|
||||
/// nor timed out, the clock-in is rejected.
|
||||
/// - If the location timed out, notes are required to proceed.
|
||||
/// - Otherwise the clock-in proceeds normally.
|
||||
Future<void> _onCheckIn(
|
||||
CheckInRequested event,
|
||||
Emitter<ClockInState> emit,
|
||||
) async {
|
||||
final Shift? shift = state.selectedShift;
|
||||
final bool shiftHasLocation =
|
||||
shift != null && shift.latitude != null && shift.longitude != null;
|
||||
|
||||
// If the shift requires location verification but geofence has not
|
||||
// confirmed proximity and has not timed out, reject the attempt.
|
||||
if (shiftHasLocation &&
|
||||
!event.isLocationVerified &&
|
||||
!event.isLocationTimedOut) {
|
||||
emit(state.copyWith(
|
||||
status: ClockInStatus.failure,
|
||||
errorMessage: 'errors.clock_in.location_verification_required',
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
// When location timed out, require the user to provide notes explaining
|
||||
// why they are clocking in without verified proximity.
|
||||
if (event.isLocationTimedOut &&
|
||||
(event.notes == null || event.notes!.trim().isEmpty)) {
|
||||
emit(state.copyWith(
|
||||
status: ClockInStatus.failure,
|
||||
errorMessage: 'errors.clock_in.notes_required_for_timeout',
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
emit(state.copyWith(status: ClockInStatus.actionInProgress));
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
@@ -221,6 +161,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
);
|
||||
}
|
||||
|
||||
/// Handles a clock-out request.
|
||||
Future<void> _onCheckOut(
|
||||
CheckOutRequested event,
|
||||
Emitter<ClockInState> emit,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Base class for all clock-in related events.
|
||||
abstract class ClockInEvent extends Equatable {
|
||||
const ClockInEvent();
|
||||
|
||||
@@ -9,72 +9,81 @@ abstract class ClockInEvent extends Equatable {
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
/// Emitted when the clock-in page is first loaded.
|
||||
class ClockInPageLoaded extends ClockInEvent {}
|
||||
|
||||
/// Emitted when the user selects a shift from the list.
|
||||
class ShiftSelected extends ClockInEvent {
|
||||
const ShiftSelected(this.shift);
|
||||
|
||||
/// The shift the user selected.
|
||||
final Shift shift;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[shift];
|
||||
}
|
||||
|
||||
/// Emitted when the user picks a different date.
|
||||
class DateSelected extends ClockInEvent {
|
||||
|
||||
const DateSelected(this.date);
|
||||
|
||||
/// The newly selected date.
|
||||
final DateTime date;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[date];
|
||||
}
|
||||
|
||||
/// Emitted when the user requests to clock in.
|
||||
///
|
||||
/// [isLocationVerified] and [isLocationTimedOut] are provided by the UI layer
|
||||
/// from the GeofenceBloc state, bridging the two BLoCs.
|
||||
class CheckInRequested extends ClockInEvent {
|
||||
const CheckInRequested({
|
||||
required this.shiftId,
|
||||
this.notes,
|
||||
this.isLocationVerified = false,
|
||||
this.isLocationTimedOut = false,
|
||||
});
|
||||
|
||||
const CheckInRequested({required this.shiftId, this.notes});
|
||||
/// The ID of the shift to clock into.
|
||||
final String shiftId;
|
||||
|
||||
/// Optional notes provided by the user.
|
||||
final String? notes;
|
||||
|
||||
/// Whether the geofence verification passed (user is within radius).
|
||||
final bool isLocationVerified;
|
||||
|
||||
/// Whether the geofence verification timed out (GPS unavailable).
|
||||
final bool isLocationTimedOut;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[shiftId, notes];
|
||||
List<Object?> get props =>
|
||||
<Object?>[shiftId, notes, isLocationVerified, isLocationTimedOut];
|
||||
}
|
||||
|
||||
/// Emitted when the user requests to clock out.
|
||||
class CheckOutRequested extends ClockInEvent {
|
||||
|
||||
const CheckOutRequested({this.notes, this.breakTimeMinutes});
|
||||
|
||||
/// Optional notes provided by the user.
|
||||
final String? notes;
|
||||
|
||||
/// Break time taken during the shift, in minutes.
|
||||
final int? breakTimeMinutes;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[notes, breakTimeMinutes];
|
||||
}
|
||||
|
||||
/// Emitted when the user changes the check-in mode (e.g. swipe vs tap).
|
||||
class CheckInModeChanged extends ClockInEvent {
|
||||
|
||||
const CheckInModeChanged(this.mode);
|
||||
|
||||
/// The new check-in mode identifier.
|
||||
final String mode;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[mode];
|
||||
}
|
||||
|
||||
class CommuteModeToggled extends ClockInEvent {
|
||||
|
||||
const CommuteModeToggled(this.isEnabled);
|
||||
final bool isEnabled;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[isEnabled];
|
||||
}
|
||||
|
||||
class RequestLocationPermission extends ClockInEvent {}
|
||||
|
||||
class LocationUpdated extends ClockInEvent {
|
||||
|
||||
const LocationUpdated({required this.position, required this.distance, required this.isVerified});
|
||||
final Position position;
|
||||
final double distance;
|
||||
final bool isVerified;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[position, distance, isVerified];
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
|
||||
/// Represents the possible statuses of the clock-in page.
|
||||
enum ClockInStatus { initial, loading, success, failure, actionInProgress }
|
||||
|
||||
/// State for the [ClockInBloc].
|
||||
///
|
||||
/// Contains today's shifts, the selected shift, attendance status,
|
||||
/// and clock-in UI configuration. Location/geofence concerns are
|
||||
/// managed separately by [GeofenceBloc].
|
||||
class ClockInState extends Equatable {
|
||||
|
||||
const ClockInState({
|
||||
this.status = ClockInStatus.initial,
|
||||
this.todayShifts = const <Shift>[],
|
||||
@@ -15,28 +18,30 @@ class ClockInState extends Equatable {
|
||||
required this.selectedDate,
|
||||
this.checkInMode = 'swipe',
|
||||
this.errorMessage,
|
||||
this.currentLocation,
|
||||
this.distanceFromVenue,
|
||||
this.isLocationVerified = false,
|
||||
this.isCommuteModeOn = false,
|
||||
this.hasLocationConsent = false,
|
||||
this.etaMinutes,
|
||||
});
|
||||
|
||||
/// Current page status.
|
||||
final ClockInStatus status;
|
||||
|
||||
/// List of shifts scheduled for the selected date.
|
||||
final List<Shift> todayShifts;
|
||||
|
||||
/// The shift currently selected by the user.
|
||||
final Shift? selectedShift;
|
||||
|
||||
/// Current attendance/check-in status from the backend.
|
||||
final AttendanceStatus attendance;
|
||||
|
||||
/// The date the user is viewing shifts for.
|
||||
final DateTime selectedDate;
|
||||
|
||||
/// The current check-in interaction mode (e.g. 'swipe').
|
||||
final String checkInMode;
|
||||
|
||||
/// Error message key for displaying failures.
|
||||
final String? errorMessage;
|
||||
|
||||
final Position? currentLocation;
|
||||
final double? distanceFromVenue;
|
||||
final bool isLocationVerified;
|
||||
final bool isCommuteModeOn;
|
||||
final bool hasLocationConsent;
|
||||
final int? etaMinutes;
|
||||
|
||||
/// Creates a copy of this state with the given fields replaced.
|
||||
ClockInState copyWith({
|
||||
ClockInStatus? status,
|
||||
List<Shift>? todayShifts,
|
||||
@@ -45,12 +50,6 @@ class ClockInState extends Equatable {
|
||||
DateTime? selectedDate,
|
||||
String? checkInMode,
|
||||
String? errorMessage,
|
||||
Position? currentLocation,
|
||||
double? distanceFromVenue,
|
||||
bool? isLocationVerified,
|
||||
bool? isCommuteModeOn,
|
||||
bool? hasLocationConsent,
|
||||
int? etaMinutes,
|
||||
}) {
|
||||
return ClockInState(
|
||||
status: status ?? this.status,
|
||||
@@ -60,12 +59,6 @@ class ClockInState extends Equatable {
|
||||
selectedDate: selectedDate ?? this.selectedDate,
|
||||
checkInMode: checkInMode ?? this.checkInMode,
|
||||
errorMessage: errorMessage,
|
||||
currentLocation: currentLocation ?? this.currentLocation,
|
||||
distanceFromVenue: distanceFromVenue ?? this.distanceFromVenue,
|
||||
isLocationVerified: isLocationVerified ?? this.isLocationVerified,
|
||||
isCommuteModeOn: isCommuteModeOn ?? this.isCommuteModeOn,
|
||||
hasLocationConsent: hasLocationConsent ?? this.hasLocationConsent,
|
||||
etaMinutes: etaMinutes ?? this.etaMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,11 +71,5 @@ class ClockInState extends Equatable {
|
||||
selectedDate,
|
||||
checkInMode,
|
||||
errorMessage,
|
||||
currentLocation,
|
||||
distanceFromVenue,
|
||||
isLocationVerified,
|
||||
isCommuteModeOn,
|
||||
hasLocationConsent,
|
||||
etaMinutes,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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_state.dart';
|
||||
import '../bloc/geofence_bloc.dart';
|
||||
import '../widgets/clock_in_body.dart';
|
||||
import '../widgets/clock_in_page_skeleton/clock_in_page_skeleton.dart';
|
||||
|
||||
/// Top-level page for the staff clock-in feature.
|
||||
///
|
||||
/// Acts as a thin shell that provides the [ClockInBloc] and delegates
|
||||
/// rendering to [ClockInBody] (loaded state) or [ClockInPageSkeleton]
|
||||
/// (loading state). Error snackbars are handled via [BlocListener].
|
||||
/// Provides [ClockInBloc] and [GeofenceBloc], then delegates rendering to
|
||||
/// [ClockInBody] (loaded) or [ClockInPageSkeleton] (loading). Error
|
||||
/// snackbars are handled via [BlocListener].
|
||||
class ClockInPage extends StatelessWidget {
|
||||
/// Creates the clock-in page.
|
||||
const ClockInPage({super.key});
|
||||
@@ -24,8 +25,15 @@ class ClockInPage extends StatelessWidget {
|
||||
context,
|
||||
).staff.clock_in;
|
||||
|
||||
return BlocProvider<ClockInBloc>.value(
|
||||
value: Modular.get<ClockInBloc>(),
|
||||
return MultiBlocProvider(
|
||||
providers: <BlocProvider<dynamic>>[
|
||||
BlocProvider<ClockInBloc>.value(
|
||||
value: Modular.get<ClockInBloc>(),
|
||||
),
|
||||
BlocProvider<GeofenceBloc>.value(
|
||||
value: Modular.get<GeofenceBloc>(),
|
||||
),
|
||||
],
|
||||
child: BlocListener<ClockInBloc, ClockInState>(
|
||||
listenWhen: (ClockInState previous, ClockInState current) =>
|
||||
current.status == ClockInStatus.failure &&
|
||||
|
||||
@@ -37,7 +37,7 @@ class CheckInModeTab extends StatelessWidget {
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () =>
|
||||
context.read<ClockInBloc>().add(CheckInModeChanged(value)),
|
||||
ReadContext(context).read<ClockInBloc>().add(CheckInModeChanged(value)),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: UiConstants.space2),
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../bloc/clock_in_bloc.dart';
|
||||
import '../bloc/clock_in_event.dart';
|
||||
import '../bloc/clock_in_state.dart';
|
||||
import '../bloc/geofence_bloc.dart';
|
||||
import '../bloc/geofence_event.dart';
|
||||
import '../bloc/geofence_state.dart';
|
||||
import 'clock_in_helpers.dart';
|
||||
import 'early_check_in_banner.dart';
|
||||
import 'geofence_status_banner.dart';
|
||||
import 'lunch_break_modal.dart';
|
||||
import 'nfc_scan_dialog.dart';
|
||||
import 'no_shifts_banner.dart';
|
||||
@@ -15,7 +24,8 @@ import 'swipe_to_check_in.dart';
|
||||
/// Orchestrates which action widget is displayed based on the current state.
|
||||
///
|
||||
/// Decides between the swipe-to-check-in slider, the early-arrival banner,
|
||||
/// the shift-completed banner, or the no-shifts placeholder.
|
||||
/// the shift-completed banner, or the no-shifts placeholder. Also shows the
|
||||
/// [GeofenceStatusBanner] and manages background tracking lifecycle.
|
||||
class ClockInActionSection extends StatelessWidget {
|
||||
/// Creates the action section.
|
||||
const ClockInActionSection({
|
||||
@@ -44,6 +54,37 @@ class ClockInActionSection extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocListener(
|
||||
listeners: <BlocListener<dynamic, dynamic>>[
|
||||
// Start background tracking after successful check-in.
|
||||
BlocListener<ClockInBloc, ClockInState>(
|
||||
listenWhen: (ClockInState previous, ClockInState current) =>
|
||||
previous.status == ClockInStatus.actionInProgress &&
|
||||
current.status == ClockInStatus.success &&
|
||||
current.attendance.isCheckedIn &&
|
||||
!previous.attendance.isCheckedIn,
|
||||
listener: (BuildContext context, ClockInState state) {
|
||||
_startBackgroundTracking(context, state);
|
||||
},
|
||||
),
|
||||
// Stop background tracking after clock-out.
|
||||
BlocListener<ClockInBloc, ClockInState>(
|
||||
listenWhen: (ClockInState previous, ClockInState current) =>
|
||||
previous.attendance.isCheckedIn &&
|
||||
!current.attendance.isCheckedIn,
|
||||
listener: (BuildContext context, ClockInState _) {
|
||||
ReadContext(context)
|
||||
.read<GeofenceBloc>()
|
||||
.add(const BackgroundTrackingStopped());
|
||||
},
|
||||
),
|
||||
],
|
||||
child: _buildContent(context),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the main content column with geofence banner and action widget.
|
||||
Widget _buildContent(BuildContext context) {
|
||||
if (selectedShift != null && checkOutTime == null) {
|
||||
return _buildActiveShiftAction(context);
|
||||
}
|
||||
@@ -58,36 +99,74 @@ class ClockInActionSection extends StatelessWidget {
|
||||
/// Builds the action widget for an active (not completed) shift.
|
||||
Widget _buildActiveShiftAction(BuildContext context) {
|
||||
if (!isCheckedIn && !ClockInHelpers.isCheckInAllowed(selectedShift!)) {
|
||||
return EarlyCheckInBanner(
|
||||
availabilityTime: ClockInHelpers.getCheckInAvailabilityTime(
|
||||
selectedShift!,
|
||||
context,
|
||||
),
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
const GeofenceStatusBanner(),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
EarlyCheckInBanner(
|
||||
availabilityTime: ClockInHelpers.getCheckInAvailabilityTime(
|
||||
selectedShift!,
|
||||
context,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return SwipeToCheckIn(
|
||||
isCheckedIn: isCheckedIn,
|
||||
mode: checkInMode,
|
||||
isDisabled: isCheckedIn,
|
||||
isLoading: isActionInProgress,
|
||||
onCheckIn: () => _handleCheckIn(context),
|
||||
onCheckOut: () => _handleCheckOut(context),
|
||||
return BlocBuilder<GeofenceBloc, GeofenceState>(
|
||||
builder: (BuildContext context, GeofenceState geofenceState) {
|
||||
final bool hasCoordinates = selectedShift?.latitude != null &&
|
||||
selectedShift?.longitude != null;
|
||||
|
||||
// Disable swipe when the shift has coordinates and the user is
|
||||
// not verified and the timeout has not been reached.
|
||||
final bool isGeofenceBlocking = hasCoordinates &&
|
||||
!geofenceState.isLocationVerified &&
|
||||
!geofenceState.isLocationTimedOut;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: UiConstants.space4,
|
||||
children: <Widget>[
|
||||
// Geofence status banner is shown even when not blocking to provide feedback
|
||||
const GeofenceStatusBanner(),
|
||||
SwipeToCheckIn(
|
||||
isCheckedIn: isCheckedIn,
|
||||
mode: checkInMode,
|
||||
isDisabled: isCheckedIn || isGeofenceBlocking,
|
||||
isLoading: isActionInProgress,
|
||||
onCheckIn: () => _handleCheckIn(context),
|
||||
onCheckOut: () => _handleCheckOut(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Triggers the check-in flow, showing an NFC dialog when needed.
|
||||
/// Triggers the check-in flow, reading geofence state for location data.
|
||||
Future<void> _handleCheckIn(BuildContext context) async {
|
||||
final GeofenceState geofenceState = ReadContext(context).read<GeofenceBloc>().state;
|
||||
|
||||
if (checkInMode == 'nfc') {
|
||||
final bool scanned = await showNfcScanDialog(context);
|
||||
if (scanned && context.mounted) {
|
||||
context.read<ClockInBloc>().add(
|
||||
CheckInRequested(shiftId: selectedShift!.id),
|
||||
ReadContext(context).read<ClockInBloc>().add(
|
||||
CheckInRequested(
|
||||
shiftId: selectedShift!.id,
|
||||
isLocationVerified: geofenceState.isLocationVerified,
|
||||
isLocationTimedOut: geofenceState.isLocationTimedOut,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
context.read<ClockInBloc>().add(
|
||||
CheckInRequested(shiftId: selectedShift!.id),
|
||||
ReadContext(context).read<ClockInBloc>().add(
|
||||
CheckInRequested(
|
||||
shiftId: selectedShift!.id,
|
||||
isLocationVerified: geofenceState.isLocationVerified,
|
||||
isLocationTimedOut: geofenceState.isLocationTimedOut,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -98,10 +177,33 @@ class ClockInActionSection extends StatelessWidget {
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) => LunchBreakDialog(
|
||||
onComplete: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
context.read<ClockInBloc>().add(const CheckOutRequested());
|
||||
Modular.to.popSafe();
|
||||
ReadContext(context).read<ClockInBloc>().add(const CheckOutRequested());
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Dispatches [BackgroundTrackingStarted] if the geofence has target
|
||||
/// coordinates after a successful check-in.
|
||||
void _startBackgroundTracking(BuildContext context, ClockInState state) {
|
||||
final GeofenceState geofenceState = ReadContext(context).read<GeofenceBloc>().state;
|
||||
|
||||
if (geofenceState.targetLat != null &&
|
||||
geofenceState.targetLng != null &&
|
||||
state.attendance.activeShiftId != null) {
|
||||
final TranslationsStaffClockInGeofenceEn geofenceI18n =
|
||||
Translations.of(context).staff.clock_in.geofence;
|
||||
|
||||
ReadContext(context).read<GeofenceBloc>().add(
|
||||
BackgroundTrackingStarted(
|
||||
shiftId: state.attendance.activeShiftId!,
|
||||
targetLat: geofenceState.targetLat!,
|
||||
targetLng: geofenceState.targetLng!,
|
||||
greetingTitle: geofenceI18n.clock_in_greeting_title,
|
||||
greetingBody: geofenceI18n.clock_in_greeting_body,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
import '../bloc/clock_in_bloc.dart';
|
||||
import '../bloc/clock_in_event.dart';
|
||||
import '../bloc/clock_in_state.dart';
|
||||
import '../bloc/geofence_bloc.dart';
|
||||
import '../bloc/geofence_event.dart';
|
||||
import 'checked_in_banner.dart';
|
||||
import 'clock_in_action_section.dart';
|
||||
import 'date_selector.dart';
|
||||
@@ -17,89 +19,129 @@ import 'shift_card_list.dart';
|
||||
///
|
||||
/// Composes the date selector, activity header, shift cards, action section,
|
||||
/// and the checked-in status banner into a single scrollable column.
|
||||
class ClockInBody extends StatelessWidget {
|
||||
/// Triggers geofence verification on mount and on shift selection changes.
|
||||
class ClockInBody extends StatefulWidget {
|
||||
/// Creates the clock-in body.
|
||||
const ClockInBody({super.key});
|
||||
|
||||
@override
|
||||
State<ClockInBody> createState() => _ClockInBodyState();
|
||||
}
|
||||
|
||||
class _ClockInBodyState extends State<ClockInBody> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Sync geofence on initial mount if a shift is already selected.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final Shift? selectedShift =
|
||||
ReadContext(context).read<ClockInBloc>().state.selectedShift;
|
||||
_syncGeofence(context, selectedShift);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffClockInEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: UiConstants.space24,
|
||||
top: UiConstants.space6,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
|
||||
child: BlocBuilder<ClockInBloc, ClockInState>(
|
||||
builder: (BuildContext context, ClockInState state) {
|
||||
final List<Shift> todayShifts = state.todayShifts;
|
||||
final Shift? selectedShift = state.selectedShift;
|
||||
final String? activeShiftId = state.attendance.activeShiftId;
|
||||
final bool isActiveSelected =
|
||||
selectedShift != null && selectedShift.id == activeShiftId;
|
||||
final DateTime? checkInTime = isActiveSelected
|
||||
? state.attendance.checkInTime
|
||||
: null;
|
||||
final DateTime? checkOutTime = isActiveSelected
|
||||
? state.attendance.checkOutTime
|
||||
: null;
|
||||
final bool isCheckedIn =
|
||||
state.attendance.isCheckedIn && isActiveSelected;
|
||||
return BlocListener<ClockInBloc, ClockInState>(
|
||||
listenWhen: (ClockInState previous, ClockInState current) =>
|
||||
previous.selectedShift != current.selectedShift,
|
||||
listener: (BuildContext context, ClockInState state) {
|
||||
_syncGeofence(context, state.selectedShift);
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: UiConstants.space24,
|
||||
top: UiConstants.space6,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
|
||||
child: BlocBuilder<ClockInBloc, ClockInState>(
|
||||
builder: (BuildContext context, ClockInState state) {
|
||||
final List<Shift> todayShifts = state.todayShifts;
|
||||
final Shift? selectedShift = state.selectedShift;
|
||||
final String? activeShiftId = state.attendance.activeShiftId;
|
||||
final bool isActiveSelected =
|
||||
selectedShift != null && selectedShift.id == activeShiftId;
|
||||
final DateTime? checkInTime =
|
||||
isActiveSelected ? state.attendance.checkInTime : null;
|
||||
final DateTime? checkOutTime =
|
||||
isActiveSelected ? state.attendance.checkOutTime : null;
|
||||
final bool isCheckedIn =
|
||||
state.attendance.isCheckedIn && isActiveSelected;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// date selector
|
||||
DateSelector(
|
||||
selectedDate: state.selectedDate,
|
||||
onSelect: (DateTime date) =>
|
||||
context.read<ClockInBloc>().add(DateSelected(date)),
|
||||
shiftDates: <String>[
|
||||
DateFormat('yyyy-MM-dd').format(DateTime.now()),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
Text(
|
||||
i18n.your_activity,
|
||||
textAlign: TextAlign.start,
|
||||
style: UiTypography.headline4m,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// date selector
|
||||
DateSelector(
|
||||
selectedDate: state.selectedDate,
|
||||
onSelect: (DateTime date) =>
|
||||
ReadContext(context).read<ClockInBloc>().add(DateSelected(date)),
|
||||
shiftDates: <String>[
|
||||
DateFormat('yyyy-MM-dd').format(DateTime.now()),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
Text(
|
||||
i18n.your_activity,
|
||||
textAlign: TextAlign.start,
|
||||
style: UiTypography.headline4m,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// today's shifts and actions
|
||||
if (todayShifts.isNotEmpty)
|
||||
ShiftCardList(
|
||||
shifts: todayShifts,
|
||||
selectedShiftId: selectedShift?.id,
|
||||
onShiftSelected: (Shift shift) =>
|
||||
context.read<ClockInBloc>().add(ShiftSelected(shift)),
|
||||
// today's shifts and actions
|
||||
if (todayShifts.isNotEmpty)
|
||||
ShiftCardList(
|
||||
shifts: todayShifts,
|
||||
selectedShiftId: selectedShift?.id,
|
||||
onShiftSelected: (Shift shift) => ReadContext(context)
|
||||
.read<ClockInBloc>()
|
||||
.add(ShiftSelected(shift)),
|
||||
),
|
||||
|
||||
// action section (check-in/out buttons)
|
||||
ClockInActionSection(
|
||||
selectedShift: selectedShift,
|
||||
isCheckedIn: isCheckedIn,
|
||||
checkOutTime: checkOutTime,
|
||||
checkInMode: state.checkInMode,
|
||||
isActionInProgress:
|
||||
state.status == ClockInStatus.actionInProgress,
|
||||
),
|
||||
|
||||
// action section (check-in/out buttons)
|
||||
ClockInActionSection(
|
||||
selectedShift: selectedShift,
|
||||
isCheckedIn: isCheckedIn,
|
||||
checkOutTime: checkOutTime,
|
||||
checkInMode: state.checkInMode,
|
||||
isActionInProgress:
|
||||
state.status == ClockInStatus.actionInProgress,
|
||||
),
|
||||
|
||||
// checked-in banner (only if currently checked in to the selected shift)
|
||||
if (isCheckedIn && checkInTime != null) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
CheckedInBanner(checkInTime: checkInTime),
|
||||
// checked-in banner (only when checked in to the selected shift)
|
||||
if (isCheckedIn && checkInTime != null) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
CheckedInBanner(checkInTime: checkInTime),
|
||||
],
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
],
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Dispatches [GeofenceStarted] or [GeofenceStopped] based on whether
|
||||
/// the selected shift has coordinates.
|
||||
void _syncGeofence(BuildContext context, Shift? shift) {
|
||||
final GeofenceBloc geofenceBloc = ReadContext(context).read<GeofenceBloc>();
|
||||
|
||||
if (shift != null && shift.latitude != null && shift.longitude != null) {
|
||||
geofenceBloc.add(
|
||||
GeofenceStarted(
|
||||
targetLat: shift.latitude!,
|
||||
targetLng: shift.longitude!,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
geofenceBloc.add(const GeofenceStopped());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Shows the NFC scanning dialog and returns `true` when a scan completes.
|
||||
///
|
||||
@@ -35,7 +37,7 @@ Future<bool> showNfcScanDialog(BuildContext context) async {
|
||||
const Duration(milliseconds: 1000),
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(dialogContext).pop();
|
||||
Modular.to.popSafe();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -151,13 +151,6 @@ class _SwipeToCheckInState extends State<SwipeToCheckIn>
|
||||
decoration: BoxDecoration(
|
||||
color: currentColor,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
boxShadow: <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: UiColors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
|
||||
@@ -3,28 +3,58 @@ import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'data/repositories_impl/clock_in_repository_impl.dart';
|
||||
import 'data/services/background_geofence_service.dart';
|
||||
import 'data/services/geofence_service_impl.dart';
|
||||
import 'domain/repositories/clock_in_repository_interface.dart';
|
||||
import 'domain/services/geofence_service_interface.dart';
|
||||
import 'domain/usecases/clock_in_usecase.dart';
|
||||
import 'domain/usecases/clock_out_usecase.dart';
|
||||
import 'domain/usecases/get_attendance_status_usecase.dart';
|
||||
import 'domain/usecases/get_todays_shift_usecase.dart';
|
||||
import 'presentation/bloc/clock_in_bloc.dart';
|
||||
import 'presentation/bloc/geofence_bloc.dart';
|
||||
import 'presentation/pages/clock_in_page.dart';
|
||||
|
||||
/// Module for the staff clock-in feature.
|
||||
///
|
||||
/// Registers repositories, use cases, geofence services, and BLoCs.
|
||||
class StaffClockInModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.add<ClockInRepositoryInterface>(ClockInRepositoryImpl.new);
|
||||
|
||||
// Geofence Services (resolve core singletons from DI)
|
||||
i.add<GeofenceServiceInterface>(
|
||||
() => GeofenceServiceImpl(
|
||||
locationService: i.get<LocationService>(),
|
||||
),
|
||||
);
|
||||
i.add<BackgroundGeofenceService>(
|
||||
() => BackgroundGeofenceService(
|
||||
backgroundTaskService: i.get<BackgroundTaskService>(),
|
||||
notificationService: i.get<NotificationService>(),
|
||||
storageService: i.get<StorageService>(),
|
||||
),
|
||||
);
|
||||
|
||||
// Use Cases
|
||||
i.add<GetTodaysShiftUseCase>(GetTodaysShiftUseCase.new);
|
||||
i.add<GetAttendanceStatusUseCase>(GetAttendanceStatusUseCase.new);
|
||||
i.add<ClockInUseCase>(ClockInUseCase.new);
|
||||
i.add<ClockOutUseCase>(ClockOutUseCase.new);
|
||||
|
||||
// BLoC
|
||||
// BLoCs (transient -- new instance per navigation)
|
||||
i.add<ClockInBloc>(ClockInBloc.new);
|
||||
i.add<GeofenceBloc>(
|
||||
() => GeofenceBloc(
|
||||
geofenceService: i.get<GeofenceServiceInterface>(),
|
||||
backgroundGeofenceService: i.get<BackgroundGeofenceService>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -28,6 +28,4 @@ dependencies:
|
||||
krow_core:
|
||||
path: ../../../core
|
||||
firebase_data_connect: ^0.2.2+2
|
||||
geolocator: ^10.1.0
|
||||
permission_handler: ^11.0.1
|
||||
firebase_auth: ^6.1.4
|
||||
|
||||
@@ -289,6 +289,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
diff_match_patch:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -510,6 +518,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_local_notifications:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "21.0.0"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.0"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.0"
|
||||
flutter_local_notifications_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_windows
|
||||
sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
flutter_localizations:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -557,22 +597,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
geoclue:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geoclue
|
||||
sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.1"
|
||||
geolocator:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator
|
||||
sha256: f4efb8d3c4cdcad2e226af9661eb1a0dd38c71a9494b22526f9da80ab79520e5
|
||||
sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.1.1"
|
||||
version: "14.0.2"
|
||||
geolocator_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_android
|
||||
sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d
|
||||
sha256: "179c3cb66dfa674fc9ccbf2be872a02658724d1c067634e2c427cf6df7df901a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.6.2"
|
||||
version: "5.0.2"
|
||||
geolocator_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -581,6 +629,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.13"
|
||||
geolocator_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_linux
|
||||
sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.4"
|
||||
geolocator_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -593,10 +649,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_web
|
||||
sha256: "102e7da05b48ca6bf0a5bda0010f886b171d1a08059f01bfe02addd0175ebece"
|
||||
sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
version: "4.1.3"
|
||||
geolocator_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -717,6 +773,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.4"
|
||||
gsettings:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: gsettings
|
||||
sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.8"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1021,6 +1085,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
package_info_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.0.0"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_platform_interface
|
||||
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1077,54 +1157,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
permission_handler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler
|
||||
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.4.0"
|
||||
permission_handler_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_android
|
||||
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.1.0"
|
||||
permission_handler_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_apple
|
||||
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.7"
|
||||
permission_handler_html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_html
|
||||
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3+5"
|
||||
permission_handler_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_platform_interface
|
||||
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
permission_handler_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_windows
|
||||
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1536,6 +1568,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.15"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timezone
|
||||
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.0"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1680,6 +1720,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
workmanager:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: workmanager
|
||||
sha256: "065673b2a465865183093806925419d311a9a5e0995aa74ccf8920fd695e2d10"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.0+3"
|
||||
workmanager_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: workmanager_android
|
||||
sha256: "9ae744db4ef891f5fcd2fb8671fccc712f4f96489a487a1411e0c8675e5e8cb7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.0+2"
|
||||
workmanager_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: workmanager_apple
|
||||
sha256: "1cc12ae3cbf5535e72f7ba4fde0c12dd11b757caf493a28e22d684052701f2ca"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.1+2"
|
||||
workmanager_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: workmanager_platform_interface
|
||||
sha256: f40422f10b970c67abb84230b44da22b075147637532ac501729256fcea10a47
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.1+1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
Reference in New Issue
Block a user