Merge remote-tracking branch 'origin/dev' into feature/session-persistence-new
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -127,6 +128,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) {
|
||||
@@ -85,5 +90,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>)
|
||||
@@ -78,6 +84,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 {
|
||||
@@ -85,14 +97,16 @@
|
||||
[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"]];
|
||||
[SmartAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"SmartAuthPlugin"]];
|
||||
[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>
|
||||
|
||||
@@ -10,6 +10,8 @@ import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krowwithus_staff/firebase_options.dart';
|
||||
import 'package:staff_authentication/staff_authentication.dart'
|
||||
as staff_authentication;
|
||||
import 'package:staff_clock_in/staff_clock_in.dart'
|
||||
show backgroundGeofenceDispatcher;
|
||||
import 'package:staff_main/staff_main.dart' as staff_main;
|
||||
|
||||
import 'src/widgets/session_listener.dart';
|
||||
@@ -18,6 +20,9 @@ void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
|
||||
// Initialize background task processing for geofence checks
|
||||
await const BackgroundTaskService().initialize(backgroundGeofenceDispatcher);
|
||||
|
||||
// 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 smart_auth
|
||||
@@ -22,7 +24,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"))
|
||||
SmartAuthPlugin.register(with: registry.registrar(forPlugin: "SmartAuthPlugin"))
|
||||
|
||||
@@ -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 <smart_auth/smart_auth_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
@@ -24,8 +23,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||
GeolocatorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||
RecordWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
|
||||
SmartAuthPluginRegisterWithRegistrar(
|
||||
|
||||
@@ -7,13 +7,13 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
firebase_auth
|
||||
firebase_core
|
||||
geolocator_windows
|
||||
permission_handler_windows
|
||||
record_windows
|
||||
smart_auth
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
flutter_local_notifications_windows
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
@@ -5,6 +5,8 @@ export 'src/core_module.dart';
|
||||
export 'src/domain/arguments/usecase_argument.dart';
|
||||
export 'src/domain/usecases/usecase.dart';
|
||||
export 'src/utils/date_time_utils.dart';
|
||||
export 'src/utils/geo_utils.dart';
|
||||
export 'src/utils/time_utils.dart';
|
||||
export 'src/presentation/widgets/web_mobile_frame.dart';
|
||||
export 'src/presentation/mixins/bloc_error_handler.dart';
|
||||
export 'src/presentation/observers/core_bloc_observer.dart';
|
||||
@@ -33,3 +35,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,70 @@
|
||||
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());
|
||||
}
|
||||
|
||||
/// Registers the task execution callback for the background isolate.
|
||||
///
|
||||
/// Must be called inside the top-level callback dispatcher function.
|
||||
/// The [callback] receives the task name and optional input data, and
|
||||
/// must return `true` on success or `false` on failure.
|
||||
void executeTask(
|
||||
Future<bool> Function(String task, Map<String, dynamic>? inputData)
|
||||
callback,
|
||||
) {
|
||||
Workmanager().executeTask(callback);
|
||||
}
|
||||
}
|
||||
@@ -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,79 @@
|
||||
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;
|
||||
|
||||
/// Whether [initialize] has already been called.
|
||||
bool _initialized = false;
|
||||
|
||||
/// Initializes notification channels and requests permissions.
|
||||
///
|
||||
/// Safe to call multiple times — subsequent calls are no-ops.
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
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);
|
||||
_initialized = true;
|
||||
});
|
||||
}
|
||||
|
||||
/// Ensures the plugin is initialized before use.
|
||||
Future<void> _ensureInitialized() async {
|
||||
if (!_initialized) await initialize();
|
||||
}
|
||||
|
||||
/// Displays a local notification with the given [title] and [body].
|
||||
Future<void> showNotification({
|
||||
required String title,
|
||||
required String body,
|
||||
int id = 0,
|
||||
}) async {
|
||||
await _ensureInitialized();
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
32
apps/mobile/packages/core/lib/src/utils/geo_utils.dart
Normal file
32
apps/mobile/packages/core/lib/src/utils/geo_utils.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'dart:math';
|
||||
|
||||
/// Calculates the distance in meters between two geographic coordinates
|
||||
/// using the Haversine formula.
|
||||
double calculateDistance(
|
||||
double lat1,
|
||||
double lng1,
|
||||
double lat2,
|
||||
double lng2,
|
||||
) {
|
||||
const double earthRadius = 6371000.0;
|
||||
final double dLat = _toRadians(lat2 - lat1);
|
||||
final double dLng = _toRadians(lng2 - lng1);
|
||||
final double a = sin(dLat / 2) * sin(dLat / 2) +
|
||||
cos(_toRadians(lat1)) *
|
||||
cos(_toRadians(lat2)) *
|
||||
sin(dLng / 2) *
|
||||
sin(dLng / 2);
|
||||
final double c = 2 * atan2(sqrt(a), sqrt(1 - a));
|
||||
return earthRadius * c;
|
||||
}
|
||||
|
||||
/// 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';
|
||||
}
|
||||
|
||||
/// Converts degrees to radians.
|
||||
double _toRadians(double degrees) => degrees * pi / 180;
|
||||
30
apps/mobile/packages/core/lib/src/utils/time_utils.dart
Normal file
30
apps/mobile/packages/core/lib/src/utils/time_utils.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Formats a time string (ISO 8601 or HH:mm) into 12-hour format
|
||||
/// (e.g. "9:00 AM").
|
||||
///
|
||||
/// Returns the original string unchanged if parsing fails.
|
||||
String formatTime(String timeStr) {
|
||||
if (timeStr.isEmpty) return '';
|
||||
try {
|
||||
final DateTime dt = DateTime.parse(timeStr);
|
||||
return DateFormat('h:mm a').format(dt);
|
||||
} catch (_) {
|
||||
try {
|
||||
final List<String> parts = timeStr.split(':');
|
||||
if (parts.length >= 2) {
|
||||
final DateTime dt = DateTime(
|
||||
2022,
|
||||
1,
|
||||
1,
|
||||
int.parse(parts[0]),
|
||||
int.parse(parts[1]),
|
||||
);
|
||||
return DateFormat('h:mm a').format(dt);
|
||||
}
|
||||
return timeStr;
|
||||
} catch (_) {
|
||||
return timeStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ dependencies:
|
||||
design_system:
|
||||
path: ../design_system
|
||||
|
||||
intl: ^0.20.0
|
||||
flutter_bloc: ^8.1.0
|
||||
equatable: ^2.0.8
|
||||
flutter_modular: ^6.4.1
|
||||
@@ -27,3 +28,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
|
||||
|
||||
@@ -856,10 +856,13 @@
|
||||
"today_shift_badge": "TODAY'S SHIFT",
|
||||
"early_title": "You're early!",
|
||||
"check_in_at": "Check-in available at $time",
|
||||
"early_checkout_title": "Too early to check out",
|
||||
"check_out_at": "Check-out available at $time",
|
||||
"shift_completed": "Shift Completed!",
|
||||
"great_work": "Great work today",
|
||||
"no_shifts_today": "No confirmed shifts for today",
|
||||
"accept_shift_cta": "Accept a shift to clock in",
|
||||
"per_hr": "\\$$amount/hr",
|
||||
"soon": "soon",
|
||||
"checked_in_at_label": "Checked in at",
|
||||
"not_in_range": "You must be within $distance m to clock in.",
|
||||
@@ -926,6 +929,39 @@
|
||||
"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_required_desc": "Grant location permission to verify you're at the workplace when clocking in.",
|
||||
"permission_denied_forever": "Location was permanently denied.",
|
||||
"permission_denied_forever_desc": "Grant location permission in your device settings to verify you're at the workplace when clocking in.",
|
||||
"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.",
|
||||
"clock_out_title": "You're Clocked Out!",
|
||||
"clock_out_body": "Great work today. See you next shift.",
|
||||
"always_permission_title": "Background Location Needed",
|
||||
"always_permission_desc": "To verify your location during shifts, please allow location access 'Always'.",
|
||||
"retry": "Retry",
|
||||
"clock_in_anyway": "Clock In Anyway",
|
||||
"override_title": "Justification Required",
|
||||
"override_desc": "Your location could not be verified. Please explain why you are clocking in without location verification.",
|
||||
"override_hint": "Enter your justification...",
|
||||
"override_submit": "Clock In",
|
||||
"overridden_title": "Location Not Verified",
|
||||
"overridden_desc": "You are clocking in without location verification. Your justification has been recorded."
|
||||
}
|
||||
},
|
||||
"availability": {
|
||||
@@ -1416,6 +1452,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.",
|
||||
|
||||
@@ -851,10 +851,13 @@
|
||||
"today_shift_badge": "TURNO DE HOY",
|
||||
"early_title": "\u00a1Ha llegado temprano!",
|
||||
"check_in_at": "Entrada disponible a las $time",
|
||||
"early_checkout_title": "Muy temprano para salir",
|
||||
"check_out_at": "Salida disponible a las $time",
|
||||
"shift_completed": "\u00a1Turno completado!",
|
||||
"great_work": "Buen trabajo hoy",
|
||||
"no_shifts_today": "No hay turnos confirmados para hoy",
|
||||
"accept_shift_cta": "Acepte un turno para registrar su entrada",
|
||||
"per_hr": "\\$$amount/hr",
|
||||
"soon": "pronto",
|
||||
"checked_in_at_label": "Entrada registrada a las",
|
||||
"nfc_dialog": {
|
||||
@@ -921,6 +924,39 @@
|
||||
"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_required_desc": "Otorgue permiso de ubicación para verificar que está en el lugar de trabajo al registrar entrada.",
|
||||
"permission_denied_forever": "La ubicación fue denegada permanentemente.",
|
||||
"permission_denied_forever_desc": "Otorgue permiso de ubicación en la configuración de su dispositivo para verificar que está en el lugar de trabajo al registrar entrada.",
|
||||
"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.",
|
||||
"clock_out_title": "¡Salida Registrada!",
|
||||
"clock_out_body": "Buen trabajo hoy. Nos vemos en el próximo 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",
|
||||
"clock_in_anyway": "Registrar Entrada",
|
||||
"override_title": "Justificación Requerida",
|
||||
"override_desc": "No se pudo verificar su ubicación. Explique por qué registra entrada sin verificación de ubicación.",
|
||||
"override_hint": "Ingrese su justificación...",
|
||||
"override_submit": "Registrar Entrada",
|
||||
"overridden_title": "Ubicación No Verificada",
|
||||
"overridden_desc": "Está registrando entrada sin verificación de ubicación. Su justificación ha sido registrada."
|
||||
}
|
||||
},
|
||||
"availability": {
|
||||
@@ -1411,6 +1447,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':
|
||||
|
||||
@@ -432,9 +432,9 @@ class ShiftsConnectorRepositoryImpl implements ShiftsConnectorRepository {
|
||||
.dayEnd(_service.toTimestamp(dayEndUtc))
|
||||
.execute();
|
||||
|
||||
if (validationResponse.data.applications.isNotEmpty) {
|
||||
throw Exception('The user already has a shift that day.');
|
||||
}
|
||||
// if (validationResponse.data.applications.isNotEmpty) {
|
||||
// throw Exception('The user already has a shift that day.');
|
||||
// }
|
||||
}
|
||||
|
||||
// Check for existing application
|
||||
|
||||
@@ -15,18 +15,32 @@ class UiNoticeBanner extends StatelessWidget {
|
||||
this.backgroundColor,
|
||||
this.borderRadius,
|
||||
this.padding,
|
||||
this.iconColor,
|
||||
this.titleColor,
|
||||
this.descriptionColor,
|
||||
this.action,
|
||||
this.leading,
|
||||
});
|
||||
|
||||
/// The icon to display on the left side.
|
||||
/// Defaults to null. The icon will be rendered with primary color and 24pt size.
|
||||
/// Ignored when [leading] is provided.
|
||||
final IconData? icon;
|
||||
|
||||
/// Custom color for the icon. Defaults to [UiColors.primary].
|
||||
final Color? iconColor;
|
||||
|
||||
/// The title text to display.
|
||||
final String title;
|
||||
|
||||
/// Custom color for the title text. Defaults to primary text color.
|
||||
final Color? titleColor;
|
||||
|
||||
/// Optional description text to display below the title.
|
||||
final String? description;
|
||||
|
||||
/// Custom color for the description text. Defaults to secondary text color.
|
||||
final Color? descriptionColor;
|
||||
|
||||
/// The background color of the banner.
|
||||
/// Defaults to [UiColors.primary] with 8% opacity.
|
||||
final Color? backgroundColor;
|
||||
@@ -39,6 +53,12 @@ class UiNoticeBanner extends StatelessWidget {
|
||||
/// Defaults to [UiConstants.space4] on all sides.
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
/// Optional action widget displayed on the right side of the banner.
|
||||
final Widget? action;
|
||||
|
||||
/// Optional custom leading widget that replaces the icon when provided.
|
||||
final Widget? leading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
@@ -50,8 +70,11 @@ class UiNoticeBanner extends StatelessWidget {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
if (icon != null) ...<Widget>[
|
||||
Icon(icon, color: UiColors.primary, size: 24),
|
||||
if (leading != null) ...<Widget>[
|
||||
leading!,
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
] else if (icon != null) ...<Widget>[
|
||||
Icon(icon, color: iconColor ?? UiColors.primary, size: 24),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
],
|
||||
Expanded(
|
||||
@@ -60,15 +83,21 @@ class UiNoticeBanner extends StatelessWidget {
|
||||
children: <Widget>[
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
style: UiTypography.body2b.copyWith(color: titleColor),
|
||||
),
|
||||
if (description != null) ...<Widget>[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
description!,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: descriptionColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (action != null) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
action!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -26,11 +26,12 @@ class HubAddressAutocomplete extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return GooglePlaceAutoCompleteTextField(
|
||||
textEditingController: controller,
|
||||
boxDecoration: null,
|
||||
focusNode: focusNode,
|
||||
inputDecoration: decoration ?? const InputDecoration(),
|
||||
googleAPIKey: AppConfig.googleMapsApiKey,
|
||||
debounceTime: 500,
|
||||
countries: HubsConstants.supportedCountries,
|
||||
//countries: HubsConstants.supportedCountries,
|
||||
isLatLngRequired: true,
|
||||
getPlaceDetailWithLatLng: (Prediction prediction) {
|
||||
onSelected?.call(prediction);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -183,12 +183,12 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface {
|
||||
|
||||
final fdc.Timestamp checkInTs = _service.toTimestamp(DateTime.now());
|
||||
|
||||
await _service.run(() => _service.connector
|
||||
await _service.connector
|
||||
.updateApplicationStatus(
|
||||
id: app!.id,
|
||||
id: app.id,
|
||||
)
|
||||
.checkInTime(checkInTs)
|
||||
.execute());
|
||||
.execute();
|
||||
_activeApplicationId = app.id;
|
||||
|
||||
return getAttendanceStatus();
|
||||
@@ -210,9 +210,9 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface {
|
||||
}
|
||||
final fdc.QueryResult<dc.GetApplicationByIdData,
|
||||
dc.GetApplicationByIdVariables> appResult =
|
||||
await _service.run(() => _service.connector
|
||||
await _service.connector
|
||||
.getApplicationById(id: targetAppId)
|
||||
.execute());
|
||||
.execute();
|
||||
final dc.GetApplicationByIdApplication? app = appResult.data.application;
|
||||
|
||||
if (app == null) {
|
||||
@@ -222,12 +222,12 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface {
|
||||
throw Exception('No active shift found to clock out');
|
||||
}
|
||||
|
||||
await _service.run(() => _service.connector
|
||||
await _service.connector
|
||||
.updateApplicationStatus(
|
||||
id: targetAppId,
|
||||
)
|
||||
.checkOutTime(_service.toTimestamp(DateTime.now()))
|
||||
.execute());
|
||||
.execute();
|
||||
|
||||
return getAttendanceStatus();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
// ignore_for_file: avoid_print
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Top-level callback dispatcher for background geofence tasks.
|
||||
///
|
||||
/// Must be a top-level function because workmanager executes it in a separate
|
||||
/// isolate where the DI container is not available. Core services are
|
||||
/// instantiated directly since they are simple wrappers.
|
||||
///
|
||||
/// Note: [Workmanager.executeTask] is kept because [BackgroundTaskService] does
|
||||
/// not expose an equivalent callback-registration API. The `workmanager` import
|
||||
/// is retained solely for this entry-point pattern.
|
||||
@pragma('vm:entry-point')
|
||||
void backgroundGeofenceDispatcher() {
|
||||
const BackgroundTaskService().executeTask(
|
||||
(String task, Map<String, dynamic>? inputData) async {
|
||||
print('[BackgroundGeofence] Task triggered: $task');
|
||||
print('[BackgroundGeofence] Input data: $inputData');
|
||||
print(
|
||||
'[BackgroundGeofence] Timestamp: ${DateTime.now().toIso8601String()}',
|
||||
);
|
||||
|
||||
final double? targetLat = inputData?['targetLat'] as double?;
|
||||
final double? targetLng = inputData?['targetLng'] as double?;
|
||||
final String? shiftId = inputData?['shiftId'] as String?;
|
||||
|
||||
print(
|
||||
'[BackgroundGeofence] Target: lat=$targetLat, lng=$targetLng, '
|
||||
'shiftId=$shiftId',
|
||||
);
|
||||
|
||||
if (targetLat == null || targetLng == null) {
|
||||
print(
|
||||
'[BackgroundGeofence] Missing target coordinates, skipping check',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const LocationService locationService = LocationService();
|
||||
final DeviceLocation location = await locationService.getCurrentLocation();
|
||||
print(
|
||||
'[BackgroundGeofence] Current position: '
|
||||
'lat=${location.latitude}, lng=${location.longitude}',
|
||||
);
|
||||
|
||||
final double distance = calculateDistance(
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
targetLat,
|
||||
targetLng,
|
||||
);
|
||||
print(
|
||||
'[BackgroundGeofence] Distance from target: ${distance.round()}m',
|
||||
);
|
||||
|
||||
if (distance > BackgroundGeofenceService.geofenceRadiusMeters) {
|
||||
print(
|
||||
'[BackgroundGeofence] Worker is outside geofence '
|
||||
'(${distance.round()}m > '
|
||||
'${BackgroundGeofenceService.geofenceRadiusMeters.round()}m), '
|
||||
'showing notification',
|
||||
);
|
||||
|
||||
final String title = inputData?['leftGeofenceTitle'] as String? ??
|
||||
"You've Left the Workplace";
|
||||
final String body = inputData?['leftGeofenceBody'] as String? ??
|
||||
'You appear to be more than 500m from your shift location.';
|
||||
|
||||
final NotificationService notificationService =
|
||||
NotificationService();
|
||||
await notificationService.showNotification(
|
||||
id: BackgroundGeofenceService.leftGeofenceNotificationId,
|
||||
title: title,
|
||||
body: body,
|
||||
);
|
||||
} else {
|
||||
print(
|
||||
'[BackgroundGeofence] Worker is within geofence '
|
||||
'(${distance.round()}m <= '
|
||||
'${BackgroundGeofenceService.geofenceRadiusMeters.round()}m)',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('[BackgroundGeofence] Error during background check: $e');
|
||||
}
|
||||
|
||||
print('[BackgroundGeofence] Background check completed');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Service that manages periodic background geofence checks while clocked in.
|
||||
///
|
||||
/// Handles scheduling and cancelling background tasks only. Notification
|
||||
/// delivery is handled by [ClockInNotificationService]. The background isolate
|
||||
/// logic lives in the top-level [backgroundGeofenceDispatcher] function above.
|
||||
class BackgroundGeofenceService {
|
||||
|
||||
/// Creates a [BackgroundGeofenceService] instance.
|
||||
BackgroundGeofenceService({
|
||||
required BackgroundTaskService backgroundTaskService,
|
||||
required StorageService storageService,
|
||||
}) : _backgroundTaskService = backgroundTaskService,
|
||||
_storageService = storageService;
|
||||
|
||||
/// The core background task service for scheduling periodic work.
|
||||
final BackgroundTaskService _backgroundTaskService;
|
||||
|
||||
/// The core storage service for persisting geofence target data.
|
||||
final StorageService _storageService;
|
||||
|
||||
/// Storage key for the target latitude.
|
||||
static const String _keyTargetLat = 'geofence_target_lat';
|
||||
|
||||
/// Storage key for the target longitude.
|
||||
static const String _keyTargetLng = 'geofence_target_lng';
|
||||
|
||||
/// Storage key for the shift identifier.
|
||||
static const String _keyShiftId = 'geofence_shift_id';
|
||||
|
||||
/// Storage key for the active tracking flag.
|
||||
static const String _keyTrackingActive = 'geofence_tracking_active';
|
||||
|
||||
/// Unique task name for the periodic background check.
|
||||
static const String taskUniqueName = 'geofence_background_check';
|
||||
|
||||
/// Task name identifier for the workmanager callback.
|
||||
static const String taskName = 'geofenceCheck';
|
||||
|
||||
/// Notification ID for left-geofence warnings.
|
||||
///
|
||||
/// Kept here because the top-level [backgroundGeofenceDispatcher] references
|
||||
/// it directly (background isolate has no DI access).
|
||||
static const int leftGeofenceNotificationId = 2;
|
||||
|
||||
/// Geofence radius in meters.
|
||||
static const double geofenceRadiusMeters = 500;
|
||||
|
||||
/// Starts periodic 15-minute background geofence checks.
|
||||
///
|
||||
/// Called after a successful clock-in. Persists the target coordinates
|
||||
/// and passes localized notification strings via [inputData] so the
|
||||
/// background isolate can display them without DI.
|
||||
Future<void> startBackgroundTracking({
|
||||
required double targetLat,
|
||||
required double targetLng,
|
||||
required String shiftId,
|
||||
required String leftGeofenceTitle,
|
||||
required String leftGeofenceBody,
|
||||
}) async {
|
||||
await Future.wait(<Future<bool>>[
|
||||
_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: <String, dynamic>{
|
||||
'targetLat': targetLat,
|
||||
'targetLng': targetLng,
|
||||
'shiftId': shiftId,
|
||||
'leftGeofenceTitle': leftGeofenceTitle,
|
||||
'leftGeofenceBody': leftGeofenceBody,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 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(<Future<bool>>[
|
||||
_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 bool? active = await _storageService.getBool(_keyTrackingActive);
|
||||
return active ?? false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Service responsible for displaying clock-in related local notifications.
|
||||
///
|
||||
/// Encapsulates notification logic extracted from [BackgroundGeofenceService]
|
||||
/// so that geofence tracking and user-facing notifications have separate
|
||||
/// responsibilities.
|
||||
class ClockInNotificationService {
|
||||
/// Creates a [ClockInNotificationService] instance.
|
||||
const ClockInNotificationService({
|
||||
required NotificationService notificationService,
|
||||
}) : _notificationService = notificationService;
|
||||
|
||||
/// The underlying core notification service.
|
||||
final NotificationService _notificationService;
|
||||
|
||||
/// Notification ID for clock-in greeting notifications.
|
||||
static const int _clockInNotificationId = 1;
|
||||
|
||||
/// Notification ID for left-geofence warnings.
|
||||
static const int leftGeofenceNotificationId = 2;
|
||||
|
||||
/// Notification ID for clock-out notifications.
|
||||
static const int _clockOutNotificationId = 3;
|
||||
|
||||
/// Shows a greeting notification after successful clock-in.
|
||||
Future<void> showClockInGreeting({
|
||||
required String title,
|
||||
required String body,
|
||||
}) async {
|
||||
await _notificationService.showNotification(
|
||||
title: title,
|
||||
body: body,
|
||||
id: _clockInNotificationId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows a notification when the worker clocks out.
|
||||
Future<void> showClockOutNotification({
|
||||
required String title,
|
||||
required String body,
|
||||
}) async {
|
||||
await _notificationService.showNotification(
|
||||
title: title,
|
||||
body: body,
|
||||
id: _clockOutNotificationId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows a notification when the worker leaves the geofence.
|
||||
Future<void> showLeftGeofenceNotification({
|
||||
required String title,
|
||||
required String body,
|
||||
}) async {
|
||||
await _notificationService.showNotification(
|
||||
title: title,
|
||||
body: body,
|
||||
id: leftGeofenceNotificationId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'dart:async';
|
||||
|
||||
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 {
|
||||
|
||||
/// Creates a [GeofenceServiceImpl] instance.
|
||||
GeofenceServiceImpl({
|
||||
required LocationService locationService,
|
||||
this.debugAlwaysInRange = false,
|
||||
}) : _locationService = locationService;
|
||||
/// 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;
|
||||
|
||||
@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(
|
||||
(DeviceLocation 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 DeviceLocation 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 double distance = calculateDistance(
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
targetLat,
|
||||
targetLng,
|
||||
);
|
||||
|
||||
final bool isWithin = debugAlwaysInRange || distance <= radiusMeters;
|
||||
final int eta =
|
||||
isWithin ? 0 : (distance / _walkingSpeedMetersPerMinute).round();
|
||||
|
||||
return GeofenceResult(
|
||||
distanceMeters: distance,
|
||||
isWithinRadius: isWithin,
|
||||
estimatedEtaMinutes: eta,
|
||||
location: location,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 {
|
||||
/// Creates a [GeofenceResult] instance.
|
||||
const GeofenceResult({
|
||||
required this.distanceMeters,
|
||||
required this.isWithinRadius,
|
||||
required this.estimatedEtaMinutes,
|
||||
required this.location,
|
||||
});
|
||||
|
||||
/// Distance from the target location in meters.
|
||||
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;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Immutable input context carrying all data needed for clock-in validation.
|
||||
///
|
||||
/// Constructed by the presentation layer and passed through the validation
|
||||
/// pipeline so that each validator can inspect the fields it cares about.
|
||||
class ClockInValidationContext extends Equatable {
|
||||
/// Creates a [ClockInValidationContext].
|
||||
const ClockInValidationContext({
|
||||
required this.isCheckingIn,
|
||||
this.shiftStartTime,
|
||||
this.shiftEndTime,
|
||||
this.hasCoordinates = false,
|
||||
this.isLocationVerified = false,
|
||||
this.isLocationTimedOut = false,
|
||||
this.isGeofenceOverridden = false,
|
||||
this.overrideNotes,
|
||||
});
|
||||
|
||||
/// Whether this is a clock-in attempt (`true`) or clock-out (`false`).
|
||||
final bool isCheckingIn;
|
||||
|
||||
/// The scheduled start time of the shift, if known.
|
||||
final DateTime? shiftStartTime;
|
||||
|
||||
/// The scheduled end time of the shift, if known.
|
||||
final DateTime? shiftEndTime;
|
||||
|
||||
/// Whether the shift's venue has latitude/longitude coordinates.
|
||||
final bool hasCoordinates;
|
||||
|
||||
/// Whether the device location has been verified against the geofence.
|
||||
final bool isLocationVerified;
|
||||
|
||||
/// Whether the location check timed out before verification completed.
|
||||
final bool isLocationTimedOut;
|
||||
|
||||
/// Whether the worker explicitly overrode the geofence via justification.
|
||||
final bool isGeofenceOverridden;
|
||||
|
||||
/// Optional notes provided when overriding or timing out.
|
||||
final String? overrideNotes;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
isCheckingIn,
|
||||
shiftStartTime,
|
||||
shiftEndTime,
|
||||
hasCoordinates,
|
||||
isLocationVerified,
|
||||
isLocationTimedOut,
|
||||
isGeofenceOverridden,
|
||||
overrideNotes,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// The outcome of a single validation step in the clock-in pipeline.
|
||||
///
|
||||
/// Use the named constructors [ClockInValidationResult.valid] and
|
||||
/// [ClockInValidationResult.invalid] to create instances.
|
||||
class ClockInValidationResult extends Equatable {
|
||||
/// Creates a passing validation result.
|
||||
const ClockInValidationResult.valid()
|
||||
: isValid = true,
|
||||
errorKey = null;
|
||||
|
||||
/// Creates a failing validation result with the given [errorKey].
|
||||
const ClockInValidationResult.invalid(this.errorKey) : isValid = false;
|
||||
|
||||
/// Whether the validation passed.
|
||||
final bool isValid;
|
||||
|
||||
/// A localization key describing the validation failure, or `null` if valid.
|
||||
final String? errorKey;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[isValid, errorKey];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import '../clock_in_validation_context.dart';
|
||||
import '../clock_in_validation_result.dart';
|
||||
|
||||
/// Abstract interface for a single step in the clock-in validation pipeline.
|
||||
///
|
||||
/// Implementations inspect the [ClockInValidationContext] and return a
|
||||
/// [ClockInValidationResult] indicating whether the check passed or failed.
|
||||
abstract class ClockInValidator {
|
||||
/// Validates the given [context] and returns the result.
|
||||
ClockInValidationResult validate(ClockInValidationContext context);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import '../clock_in_validation_context.dart';
|
||||
import '../clock_in_validation_result.dart';
|
||||
import 'clock_in_validator.dart';
|
||||
|
||||
/// Runs a list of [ClockInValidator]s in order, short-circuiting on first failure.
|
||||
///
|
||||
/// This implements the composite pattern to chain multiple validation rules
|
||||
/// into a single pipeline. Validators are executed sequentially and the first
|
||||
/// failing result is returned immediately.
|
||||
class CompositeClockInValidator implements ClockInValidator {
|
||||
|
||||
/// Creates a [CompositeClockInValidator] with the given [validators].
|
||||
const CompositeClockInValidator(this.validators);
|
||||
/// The ordered list of validators to execute.
|
||||
final List<ClockInValidator> validators;
|
||||
|
||||
/// Runs each validator in order. Returns the first failing result,
|
||||
/// or [ClockInValidationResult.valid] if all pass.
|
||||
@override
|
||||
ClockInValidationResult validate(ClockInValidationContext context) {
|
||||
for (final ClockInValidator validator in validators) {
|
||||
final ClockInValidationResult result = validator.validate(context);
|
||||
if (!result.isValid) return result;
|
||||
}
|
||||
return const ClockInValidationResult.valid();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import '../clock_in_validation_context.dart';
|
||||
import '../clock_in_validation_result.dart';
|
||||
import 'clock_in_validator.dart';
|
||||
|
||||
/// Validates that geofence requirements are satisfied before clock-in.
|
||||
///
|
||||
/// Only applies when checking in to a shift that has venue coordinates.
|
||||
/// If the shift has no coordinates or this is a clock-out, validation passes.
|
||||
///
|
||||
/// Logic extracted from [ClockInBloc._onCheckIn]:
|
||||
/// - If the shift requires location verification but the geofence has not
|
||||
/// confirmed proximity, has not timed out, and the worker has not
|
||||
/// explicitly overridden via the justification modal, the attempt is rejected.
|
||||
class GeofenceValidator implements ClockInValidator {
|
||||
/// Creates a [GeofenceValidator].
|
||||
const GeofenceValidator();
|
||||
|
||||
/// Returns invalid when clocking in to a location-based shift without
|
||||
/// verified location, timeout, or explicit override.
|
||||
@override
|
||||
ClockInValidationResult validate(ClockInValidationContext context) {
|
||||
// Only applies to clock-in for shifts with coordinates.
|
||||
if (!context.isCheckingIn || !context.hasCoordinates) {
|
||||
return const ClockInValidationResult.valid();
|
||||
}
|
||||
|
||||
if (!context.isLocationVerified &&
|
||||
!context.isLocationTimedOut &&
|
||||
!context.isGeofenceOverridden) {
|
||||
return const ClockInValidationResult.invalid('geofence_not_verified');
|
||||
}
|
||||
|
||||
return const ClockInValidationResult.valid();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import '../clock_in_validation_context.dart';
|
||||
import '../clock_in_validation_result.dart';
|
||||
import 'clock_in_validator.dart';
|
||||
|
||||
/// Validates that override notes are provided when required.
|
||||
///
|
||||
/// When the location check timed out or the geofence was explicitly overridden,
|
||||
/// the worker must supply non-empty notes explaining why they are clocking in
|
||||
/// without verified proximity.
|
||||
///
|
||||
/// Logic extracted from [ClockInBloc._onCheckIn] notes check.
|
||||
class OverrideNotesValidator implements ClockInValidator {
|
||||
/// Creates an [OverrideNotesValidator].
|
||||
const OverrideNotesValidator();
|
||||
|
||||
/// Returns invalid if notes are required but missing or empty.
|
||||
@override
|
||||
ClockInValidationResult validate(ClockInValidationContext context) {
|
||||
// Only applies to clock-in attempts.
|
||||
if (!context.isCheckingIn) {
|
||||
return const ClockInValidationResult.valid();
|
||||
}
|
||||
|
||||
final bool notesRequired =
|
||||
context.isLocationTimedOut || context.isGeofenceOverridden;
|
||||
|
||||
if (notesRequired &&
|
||||
(context.overrideNotes == null ||
|
||||
context.overrideNotes!.trim().isEmpty)) {
|
||||
return const ClockInValidationResult.invalid('notes_required');
|
||||
}
|
||||
|
||||
return const ClockInValidationResult.valid();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:staff_clock_in/src/domain/validators/clock_in_validation_context.dart';
|
||||
import 'package:staff_clock_in/src/domain/validators/clock_in_validation_result.dart';
|
||||
|
||||
import 'clock_in_validator.dart';
|
||||
|
||||
/// Validates that the current time falls within the allowed window.
|
||||
///
|
||||
/// - For clock-in: the current time must be at most 15 minutes before the
|
||||
/// shift start time.
|
||||
/// - For clock-out: the current time must be at most 15 minutes before the
|
||||
/// shift end time.
|
||||
/// - If the relevant shift time is `null`, validation passes (don't block
|
||||
/// when the time is unknown).
|
||||
class TimeWindowValidator implements ClockInValidator {
|
||||
/// Creates a [TimeWindowValidator].
|
||||
const TimeWindowValidator();
|
||||
|
||||
/// The number of minutes before the shift time that the action is allowed.
|
||||
static const int _earlyWindowMinutes = 15;
|
||||
|
||||
/// Returns invalid if the current time is too early for the action.
|
||||
@override
|
||||
ClockInValidationResult validate(ClockInValidationContext context) {
|
||||
if (context.isCheckingIn) {
|
||||
return _validateClockIn(context);
|
||||
}
|
||||
return _validateClockOut(context);
|
||||
}
|
||||
|
||||
/// Validates the clock-in time window against [shiftStartTime].
|
||||
ClockInValidationResult _validateClockIn(ClockInValidationContext context) {
|
||||
final DateTime? shiftStart = context.shiftStartTime;
|
||||
if (shiftStart == null) {
|
||||
return const ClockInValidationResult.valid();
|
||||
}
|
||||
|
||||
final DateTime windowStart = shiftStart.subtract(
|
||||
const Duration(minutes: _earlyWindowMinutes),
|
||||
);
|
||||
|
||||
if (DateTime.now().isBefore(windowStart)) {
|
||||
return const ClockInValidationResult.invalid('too_early_clock_in');
|
||||
}
|
||||
|
||||
return const ClockInValidationResult.valid();
|
||||
}
|
||||
|
||||
/// Validates the clock-out time window against [shiftEndTime].
|
||||
ClockInValidationResult _validateClockOut(ClockInValidationContext context) {
|
||||
final DateTime? shiftEnd = context.shiftEndTime;
|
||||
if (shiftEnd == null) {
|
||||
return const ClockInValidationResult.valid();
|
||||
}
|
||||
|
||||
final DateTime windowStart = shiftEnd.subtract(
|
||||
const Duration(minutes: _earlyWindowMinutes),
|
||||
);
|
||||
|
||||
if (DateTime.now().isBefore(windowStart)) {
|
||||
return const ClockInValidationResult.invalid('too_early_clock_out');
|
||||
}
|
||||
|
||||
return const ClockInValidationResult.valid();
|
||||
}
|
||||
|
||||
/// Returns the formatted earliest allowed time for the given [shiftTime].
|
||||
///
|
||||
/// The result is a 12-hour string such as "8:45 AM". Presentation code
|
||||
/// can call this directly without depending on Flutter's [BuildContext].
|
||||
static String getAvailabilityTime(DateTime shiftTime) {
|
||||
final DateTime windowStart = shiftTime.subtract(
|
||||
const Duration(minutes: _earlyWindowMinutes),
|
||||
);
|
||||
return DateFormat('h:mm a').format(windowStart);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../../domain/arguments/clock_in_arguments.dart';
|
||||
import '../../../domain/arguments/clock_out_arguments.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 '../../../domain/validators/clock_in_validation_context.dart';
|
||||
import '../../../domain/validators/clock_in_validation_result.dart';
|
||||
import '../../../domain/validators/validators/composite_clock_in_validator.dart';
|
||||
import '../../../domain/validators/validators/time_window_validator.dart';
|
||||
import '../geofence/geofence_bloc.dart';
|
||||
import '../geofence/geofence_event.dart';
|
||||
import '../geofence/geofence_state.dart';
|
||||
import 'clock_in_event.dart';
|
||||
import 'clock_in_state.dart';
|
||||
|
||||
/// BLoC responsible for clock-in/clock-out operations and shift management.
|
||||
///
|
||||
/// Reads [GeofenceBloc] state directly to evaluate geofence conditions,
|
||||
/// removing the need for the UI to bridge geofence fields into events.
|
||||
/// Validation is delegated to [CompositeClockInValidator].
|
||||
/// Background tracking lifecycle is managed here after successful
|
||||
/// clock-in/clock-out, rather than in the UI layer.
|
||||
class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
with BlocErrorHandler<ClockInState> {
|
||||
/// Creates a [ClockInBloc] with the required use cases, geofence BLoC,
|
||||
/// and validator.
|
||||
ClockInBloc({
|
||||
required GetTodaysShiftUseCase getTodaysShift,
|
||||
required GetAttendanceStatusUseCase getAttendanceStatus,
|
||||
required ClockInUseCase clockIn,
|
||||
required ClockOutUseCase clockOut,
|
||||
required GeofenceBloc geofenceBloc,
|
||||
required CompositeClockInValidator validator,
|
||||
}) : _getTodaysShift = getTodaysShift,
|
||||
_getAttendanceStatus = getAttendanceStatus,
|
||||
_clockIn = clockIn,
|
||||
_clockOut = clockOut,
|
||||
_geofenceBloc = geofenceBloc,
|
||||
_validator = validator,
|
||||
super(ClockInState(selectedDate: DateTime.now())) {
|
||||
on<ClockInPageLoaded>(_onLoaded);
|
||||
on<ShiftSelected>(_onShiftSelected);
|
||||
on<DateSelected>(_onDateSelected);
|
||||
on<CheckInRequested>(_onCheckIn);
|
||||
on<CheckOutRequested>(_onCheckOut);
|
||||
on<CheckInModeChanged>(_onModeChanged);
|
||||
on<TimeWindowRefreshRequested>(_onTimeWindowRefresh);
|
||||
}
|
||||
|
||||
final GetTodaysShiftUseCase _getTodaysShift;
|
||||
final GetAttendanceStatusUseCase _getAttendanceStatus;
|
||||
final ClockInUseCase _clockIn;
|
||||
final ClockOutUseCase _clockOut;
|
||||
|
||||
/// Reference to [GeofenceBloc] for reading geofence state directly.
|
||||
final GeofenceBloc _geofenceBloc;
|
||||
|
||||
/// Composite validator for clock-in preconditions.
|
||||
final CompositeClockInValidator _validator;
|
||||
|
||||
/// Periodic timer that re-evaluates time window flags every 30 seconds
|
||||
/// so the "too early" banner updates without user interaction.
|
||||
Timer? _timeWindowTimer;
|
||||
|
||||
/// Loads today's shifts and the current attendance status.
|
||||
Future<void> _onLoaded(
|
||||
ClockInPageLoaded event,
|
||||
Emitter<ClockInState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: ClockInStatus.loading));
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final List<Shift> shifts = await _getTodaysShift();
|
||||
final AttendanceStatus status = await _getAttendanceStatus();
|
||||
|
||||
Shift? selectedShift;
|
||||
if (shifts.isNotEmpty) {
|
||||
if (status.activeShiftId != null) {
|
||||
try {
|
||||
selectedShift =
|
||||
shifts.firstWhere((Shift s) => s.id == status.activeShiftId);
|
||||
} catch (_) {}
|
||||
}
|
||||
selectedShift ??= shifts.last;
|
||||
}
|
||||
|
||||
final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(
|
||||
selectedShift,
|
||||
);
|
||||
|
||||
emit(state.copyWith(
|
||||
status: ClockInStatus.success,
|
||||
todayShifts: shifts,
|
||||
selectedShift: selectedShift,
|
||||
attendance: status,
|
||||
isCheckInAllowed: timeFlags.isCheckInAllowed,
|
||||
isCheckOutAllowed: timeFlags.isCheckOutAllowed,
|
||||
checkInAvailabilityTime: timeFlags.checkInAvailabilityTime,
|
||||
checkOutAvailabilityTime: timeFlags.checkOutAvailabilityTime,
|
||||
));
|
||||
|
||||
// Start periodic timer so time-window banners auto-update.
|
||||
_startTimeWindowTimer();
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: ClockInStatus.failure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates the currently selected shift and recomputes time window flags.
|
||||
void _onShiftSelected(
|
||||
ShiftSelected event,
|
||||
Emitter<ClockInState> emit,
|
||||
) {
|
||||
final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(event.shift);
|
||||
emit(state.copyWith(
|
||||
selectedShift: event.shift,
|
||||
isCheckInAllowed: timeFlags.isCheckInAllowed,
|
||||
isCheckOutAllowed: timeFlags.isCheckOutAllowed,
|
||||
checkInAvailabilityTime: timeFlags.checkInAvailabilityTime,
|
||||
checkOutAvailabilityTime: timeFlags.checkOutAvailabilityTime,
|
||||
));
|
||||
}
|
||||
|
||||
/// Updates the selected date and re-fetches shifts.
|
||||
///
|
||||
/// Currently the repository always fetches today's shifts regardless of
|
||||
/// the selected date. Re-loading ensures the UI stays in sync after a
|
||||
/// date change.
|
||||
// TODO(clock_in): Pass selected date to repository for date-based filtering.
|
||||
Future<void> _onDateSelected(
|
||||
DateSelected event,
|
||||
Emitter<ClockInState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(selectedDate: event.date));
|
||||
await _onLoaded(ClockInPageLoaded(), emit);
|
||||
}
|
||||
|
||||
/// Updates the check-in interaction mode.
|
||||
void _onModeChanged(
|
||||
CheckInModeChanged event,
|
||||
Emitter<ClockInState> emit,
|
||||
) {
|
||||
emit(state.copyWith(checkInMode: event.mode));
|
||||
}
|
||||
|
||||
/// Handles a clock-in request.
|
||||
///
|
||||
/// Reads geofence state directly from [_geofenceBloc] and builds a
|
||||
/// [ClockInValidationContext] to run through the [_validator] pipeline.
|
||||
/// On success, dispatches [BackgroundTrackingStarted] to [_geofenceBloc].
|
||||
Future<void> _onCheckIn(
|
||||
CheckInRequested event,
|
||||
Emitter<ClockInState> emit,
|
||||
) async {
|
||||
// Clear previous error so repeated failures are always emitted as new states.
|
||||
if (state.errorMessage != null) {
|
||||
emit(state.copyWith(errorMessage: null));
|
||||
}
|
||||
|
||||
final Shift? shift = state.selectedShift;
|
||||
final GeofenceState geofenceState = _geofenceBloc.state;
|
||||
|
||||
final bool hasCoordinates =
|
||||
shift != null && shift.latitude != null && shift.longitude != null;
|
||||
|
||||
// Build validation context from combined BLoC states.
|
||||
final ClockInValidationContext validationContext = ClockInValidationContext(
|
||||
isCheckingIn: true,
|
||||
shiftStartTime: _tryParseDateTime(shift?.startTime),
|
||||
shiftEndTime: _tryParseDateTime(shift?.endTime),
|
||||
hasCoordinates: hasCoordinates,
|
||||
isLocationVerified: geofenceState.isLocationVerified,
|
||||
isLocationTimedOut: geofenceState.isLocationTimedOut,
|
||||
isGeofenceOverridden: geofenceState.isGeofenceOverridden,
|
||||
overrideNotes: event.notes,
|
||||
);
|
||||
|
||||
final ClockInValidationResult validationResult =
|
||||
_validator.validate(validationContext);
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
emit(state.copyWith(
|
||||
status: ClockInStatus.failure,
|
||||
errorMessage: validationResult.errorKey,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
emit(state.copyWith(status: ClockInStatus.actionInProgress));
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final AttendanceStatus newStatus = await _clockIn(
|
||||
ClockInArguments(shiftId: event.shiftId, notes: event.notes),
|
||||
);
|
||||
emit(state.copyWith(
|
||||
status: ClockInStatus.success,
|
||||
attendance: newStatus,
|
||||
));
|
||||
|
||||
// Start background tracking after successful clock-in.
|
||||
_dispatchBackgroundTrackingStarted(
|
||||
event: event,
|
||||
activeShiftId: newStatus.activeShiftId,
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: ClockInStatus.failure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Handles a clock-out request.
|
||||
///
|
||||
/// On success, dispatches [BackgroundTrackingStopped] to [_geofenceBloc].
|
||||
Future<void> _onCheckOut(
|
||||
CheckOutRequested event,
|
||||
Emitter<ClockInState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: ClockInStatus.actionInProgress));
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final AttendanceStatus newStatus = await _clockOut(
|
||||
ClockOutArguments(
|
||||
notes: event.notes,
|
||||
breakTimeMinutes: event.breakTimeMinutes ?? 0,
|
||||
applicationId: state.attendance.activeApplicationId,
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(
|
||||
status: ClockInStatus.success,
|
||||
attendance: newStatus,
|
||||
));
|
||||
|
||||
// Stop background tracking after successful clock-out.
|
||||
_geofenceBloc.add(
|
||||
BackgroundTrackingStopped(
|
||||
clockOutTitle: event.clockOutTitle,
|
||||
clockOutBody: event.clockOutBody,
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: ClockInStatus.failure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Re-evaluates time window flags for the currently selected shift.
|
||||
///
|
||||
/// Fired periodically by [_timeWindowTimer] so banners like "too early"
|
||||
/// automatically disappear once the check-in window opens.
|
||||
void _onTimeWindowRefresh(
|
||||
TimeWindowRefreshRequested event,
|
||||
Emitter<ClockInState> emit,
|
||||
) {
|
||||
if (state.status != ClockInStatus.success) return;
|
||||
final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(
|
||||
state.selectedShift,
|
||||
);
|
||||
emit(state.copyWith(
|
||||
isCheckInAllowed: timeFlags.isCheckInAllowed,
|
||||
isCheckOutAllowed: timeFlags.isCheckOutAllowed,
|
||||
checkInAvailabilityTime: timeFlags.checkInAvailabilityTime,
|
||||
clearCheckInAvailabilityTime: timeFlags.checkInAvailabilityTime == null,
|
||||
checkOutAvailabilityTime: timeFlags.checkOutAvailabilityTime,
|
||||
clearCheckOutAvailabilityTime:
|
||||
timeFlags.checkOutAvailabilityTime == null,
|
||||
));
|
||||
}
|
||||
|
||||
/// Starts the periodic time-window refresh timer.
|
||||
// TODO: Change this logic to more comprehensive logic based on the actual shift times instead of a fixed 30-second timer.
|
||||
void _startTimeWindowTimer() {
|
||||
_timeWindowTimer?.cancel();
|
||||
_timeWindowTimer = Timer.periodic(
|
||||
const Duration(seconds: 30),
|
||||
(_) => add(const TimeWindowRefreshRequested()),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_timeWindowTimer?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
/// Safely parses a time string into a [DateTime], returning `null` on failure.
|
||||
static DateTime? _tryParseDateTime(String? value) {
|
||||
if (value == null || value.isEmpty) return null;
|
||||
return DateTime.tryParse(value);
|
||||
}
|
||||
|
||||
/// Computes time-window check-in/check-out flags for the given [shift].
|
||||
///
|
||||
/// Uses [TimeWindowValidator] so this business logic stays out of widgets.
|
||||
static _TimeWindowFlags _computeTimeWindowFlags(Shift? shift) {
|
||||
if (shift == null) {
|
||||
return const _TimeWindowFlags();
|
||||
}
|
||||
|
||||
const TimeWindowValidator validator = TimeWindowValidator();
|
||||
final DateTime? shiftStart = _tryParseDateTime(shift.startTime);
|
||||
final DateTime? shiftEnd = _tryParseDateTime(shift.endTime);
|
||||
|
||||
// Check-in window.
|
||||
bool isCheckInAllowed = true;
|
||||
String? checkInAvailabilityTime;
|
||||
if (shiftStart != null) {
|
||||
final ClockInValidationContext checkInCtx = ClockInValidationContext(
|
||||
isCheckingIn: true,
|
||||
shiftStartTime: shiftStart,
|
||||
);
|
||||
isCheckInAllowed = validator.validate(checkInCtx).isValid;
|
||||
if (!isCheckInAllowed) {
|
||||
checkInAvailabilityTime =
|
||||
TimeWindowValidator.getAvailabilityTime(shiftStart);
|
||||
}
|
||||
}
|
||||
|
||||
// Check-out window.
|
||||
bool isCheckOutAllowed = true;
|
||||
String? checkOutAvailabilityTime;
|
||||
if (shiftEnd != null) {
|
||||
final ClockInValidationContext checkOutCtx = ClockInValidationContext(
|
||||
isCheckingIn: false,
|
||||
shiftEndTime: shiftEnd,
|
||||
);
|
||||
isCheckOutAllowed = validator.validate(checkOutCtx).isValid;
|
||||
if (!isCheckOutAllowed) {
|
||||
checkOutAvailabilityTime =
|
||||
TimeWindowValidator.getAvailabilityTime(shiftEnd);
|
||||
}
|
||||
}
|
||||
|
||||
return _TimeWindowFlags(
|
||||
isCheckInAllowed: isCheckInAllowed,
|
||||
isCheckOutAllowed: isCheckOutAllowed,
|
||||
checkInAvailabilityTime: checkInAvailabilityTime,
|
||||
checkOutAvailabilityTime: checkOutAvailabilityTime,
|
||||
);
|
||||
}
|
||||
|
||||
/// Dispatches [BackgroundTrackingStarted] to [_geofenceBloc] if the
|
||||
/// geofence has target coordinates.
|
||||
void _dispatchBackgroundTrackingStarted({
|
||||
required CheckInRequested event,
|
||||
required String? activeShiftId,
|
||||
}) {
|
||||
final GeofenceState geofenceState = _geofenceBloc.state;
|
||||
|
||||
if (geofenceState.targetLat != null &&
|
||||
geofenceState.targetLng != null &&
|
||||
activeShiftId != null) {
|
||||
_geofenceBloc.add(
|
||||
BackgroundTrackingStarted(
|
||||
shiftId: activeShiftId,
|
||||
targetLat: geofenceState.targetLat!,
|
||||
targetLng: geofenceState.targetLng!,
|
||||
greetingTitle: event.clockInGreetingTitle,
|
||||
greetingBody: event.clockInGreetingBody,
|
||||
leftGeofenceTitle: event.leftGeofenceTitle,
|
||||
leftGeofenceBody: event.leftGeofenceBody,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal value holder for time-window computation results.
|
||||
class _TimeWindowFlags {
|
||||
/// Creates a [_TimeWindowFlags] with default allowed values.
|
||||
const _TimeWindowFlags({
|
||||
this.isCheckInAllowed = true,
|
||||
this.isCheckOutAllowed = true,
|
||||
this.checkInAvailabilityTime,
|
||||
this.checkOutAvailabilityTime,
|
||||
});
|
||||
|
||||
/// Whether the time window currently allows check-in.
|
||||
final bool isCheckInAllowed;
|
||||
|
||||
/// Whether the time window currently allows check-out.
|
||||
final bool isCheckOutAllowed;
|
||||
|
||||
/// Formatted time when check-in becomes available, or `null`.
|
||||
final String? checkInAvailabilityTime;
|
||||
|
||||
/// Formatted time when check-out becomes available, or `null`.
|
||||
final String? checkOutAvailabilityTime;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Base class for all clock-in related events.
|
||||
abstract class ClockInEvent extends Equatable {
|
||||
const ClockInEvent();
|
||||
|
||||
@override
|
||||
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.
|
||||
///
|
||||
/// Geofence state is read directly by the BLoC from [GeofenceBloc],
|
||||
/// so this event only carries the shift ID, optional notes, and
|
||||
/// notification strings for background tracking.
|
||||
class CheckInRequested extends ClockInEvent {
|
||||
const CheckInRequested({
|
||||
required this.shiftId,
|
||||
this.notes,
|
||||
this.clockInGreetingTitle = '',
|
||||
this.clockInGreetingBody = '',
|
||||
this.leftGeofenceTitle = '',
|
||||
this.leftGeofenceBody = '',
|
||||
});
|
||||
|
||||
/// The ID of the shift to clock into.
|
||||
final String shiftId;
|
||||
|
||||
/// Optional notes provided by the user (e.g. geofence override notes).
|
||||
final String? notes;
|
||||
|
||||
/// Localized title for the clock-in greeting notification.
|
||||
final String clockInGreetingTitle;
|
||||
|
||||
/// Localized body for the clock-in greeting notification.
|
||||
final String clockInGreetingBody;
|
||||
|
||||
/// Localized title for the left-geofence background notification.
|
||||
final String leftGeofenceTitle;
|
||||
|
||||
/// Localized body for the left-geofence background notification.
|
||||
final String leftGeofenceBody;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
shiftId,
|
||||
notes,
|
||||
clockInGreetingTitle,
|
||||
clockInGreetingBody,
|
||||
leftGeofenceTitle,
|
||||
leftGeofenceBody,
|
||||
];
|
||||
}
|
||||
|
||||
/// Emitted when the user requests to clock out.
|
||||
class CheckOutRequested extends ClockInEvent {
|
||||
const CheckOutRequested({
|
||||
this.notes,
|
||||
this.breakTimeMinutes,
|
||||
this.clockOutTitle = '',
|
||||
this.clockOutBody = '',
|
||||
});
|
||||
|
||||
/// Optional notes provided by the user.
|
||||
final String? notes;
|
||||
|
||||
/// Break time taken during the shift, in minutes.
|
||||
final int? breakTimeMinutes;
|
||||
|
||||
/// Localized title for the clock-out notification.
|
||||
final String clockOutTitle;
|
||||
|
||||
/// Localized body for the clock-out notification.
|
||||
final String clockOutBody;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
notes,
|
||||
breakTimeMinutes,
|
||||
clockOutTitle,
|
||||
clockOutBody,
|
||||
];
|
||||
}
|
||||
|
||||
/// 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];
|
||||
}
|
||||
|
||||
/// Periodically emitted by a timer to re-evaluate time window flags.
|
||||
///
|
||||
/// Ensures banners like "too early to check in" disappear once the
|
||||
/// time window opens, without requiring user interaction.
|
||||
class TimeWindowRefreshRequested extends ClockInEvent {
|
||||
/// Creates a [TimeWindowRefreshRequested] event.
|
||||
const TimeWindowRefreshRequested();
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.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>[],
|
||||
this.selectedShift,
|
||||
this.attendance = const AttendanceStatus(),
|
||||
required this.selectedDate,
|
||||
this.checkInMode = 'swipe',
|
||||
this.errorMessage,
|
||||
this.isCheckInAllowed = true,
|
||||
this.isCheckOutAllowed = true,
|
||||
this.checkInAvailabilityTime,
|
||||
this.checkOutAvailabilityTime,
|
||||
});
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Whether the time window allows the user to check in.
|
||||
final bool isCheckInAllowed;
|
||||
|
||||
/// Whether the time window allows the user to check out.
|
||||
final bool isCheckOutAllowed;
|
||||
|
||||
/// Formatted earliest time when check-in becomes available, or `null`.
|
||||
final String? checkInAvailabilityTime;
|
||||
|
||||
/// Formatted earliest time when check-out becomes available, or `null`.
|
||||
final String? checkOutAvailabilityTime;
|
||||
|
||||
/// Creates a copy of this state with the given fields replaced.
|
||||
///
|
||||
/// Use the `clearX` flags to explicitly set nullable fields to `null`,
|
||||
/// since the `??` fallback otherwise prevents clearing.
|
||||
ClockInState copyWith({
|
||||
ClockInStatus? status,
|
||||
List<Shift>? todayShifts,
|
||||
Shift? selectedShift,
|
||||
bool clearSelectedShift = false,
|
||||
AttendanceStatus? attendance,
|
||||
DateTime? selectedDate,
|
||||
String? checkInMode,
|
||||
String? errorMessage,
|
||||
bool? isCheckInAllowed,
|
||||
bool? isCheckOutAllowed,
|
||||
String? checkInAvailabilityTime,
|
||||
bool clearCheckInAvailabilityTime = false,
|
||||
String? checkOutAvailabilityTime,
|
||||
bool clearCheckOutAvailabilityTime = false,
|
||||
}) {
|
||||
return ClockInState(
|
||||
status: status ?? this.status,
|
||||
todayShifts: todayShifts ?? this.todayShifts,
|
||||
selectedShift:
|
||||
clearSelectedShift ? null : (selectedShift ?? this.selectedShift),
|
||||
attendance: attendance ?? this.attendance,
|
||||
selectedDate: selectedDate ?? this.selectedDate,
|
||||
checkInMode: checkInMode ?? this.checkInMode,
|
||||
errorMessage: errorMessage,
|
||||
isCheckInAllowed: isCheckInAllowed ?? this.isCheckInAllowed,
|
||||
isCheckOutAllowed: isCheckOutAllowed ?? this.isCheckOutAllowed,
|
||||
checkInAvailabilityTime: clearCheckInAvailabilityTime
|
||||
? null
|
||||
: (checkInAvailabilityTime ?? this.checkInAvailabilityTime),
|
||||
checkOutAvailabilityTime: clearCheckOutAvailabilityTime
|
||||
? null
|
||||
: (checkOutAvailabilityTime ?? this.checkOutAvailabilityTime),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
status,
|
||||
todayShifts,
|
||||
selectedShift,
|
||||
attendance,
|
||||
selectedDate,
|
||||
checkInMode,
|
||||
errorMessage,
|
||||
isCheckInAllowed,
|
||||
isCheckOutAllowed,
|
||||
checkInAvailabilityTime,
|
||||
checkOutAvailabilityTime,
|
||||
];
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
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';
|
||||
import '../../domain/usecases/get_todays_shift_usecase.dart';
|
||||
import '../../domain/usecases/get_attendance_status_usecase.dart';
|
||||
import '../../domain/usecases/clock_in_usecase.dart';
|
||||
import '../../domain/usecases/clock_out_usecase.dart';
|
||||
import '../../domain/arguments/clock_in_arguments.dart';
|
||||
import '../../domain/arguments/clock_out_arguments.dart';
|
||||
import 'clock_in_event.dart';
|
||||
import 'clock_in_state.dart';
|
||||
|
||||
class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
with BlocErrorHandler<ClockInState> {
|
||||
ClockInBloc({
|
||||
required GetTodaysShiftUseCase getTodaysShift,
|
||||
required GetAttendanceStatusUseCase getAttendanceStatus,
|
||||
required ClockInUseCase clockIn,
|
||||
required ClockOutUseCase clockOut,
|
||||
}) : _getTodaysShift = getTodaysShift,
|
||||
_getAttendanceStatus = getAttendanceStatus,
|
||||
_clockIn = clockIn,
|
||||
_clockOut = clockOut,
|
||||
super(ClockInState(selectedDate: DateTime.now())) {
|
||||
on<ClockInPageLoaded>(_onLoaded);
|
||||
on<ShiftSelected>(_onShiftSelected);
|
||||
on<DateSelected>(_onDateSelected);
|
||||
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;
|
||||
|
||||
Future<void> _onLoaded(
|
||||
ClockInPageLoaded event,
|
||||
Emitter<ClockInState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: ClockInStatus.loading));
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final List<Shift> shifts = await _getTodaysShift();
|
||||
final AttendanceStatus status = await _getAttendanceStatus();
|
||||
|
||||
Shift? selectedShift;
|
||||
if (shifts.isNotEmpty) {
|
||||
if (status.activeShiftId != null) {
|
||||
try {
|
||||
selectedShift =
|
||||
shifts.firstWhere((Shift s) => s.id == status.activeShiftId);
|
||||
} catch (_) {}
|
||||
}
|
||||
selectedShift ??= shifts.last;
|
||||
}
|
||||
|
||||
emit(state.copyWith(
|
||||
status: ClockInStatus.success,
|
||||
todayShifts: shifts,
|
||||
selectedShift: selectedShift,
|
||||
attendance: status,
|
||||
));
|
||||
|
||||
if (selectedShift != null && !status.isCheckedIn) {
|
||||
add(RequestLocationPermission());
|
||||
}
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: ClockInStatus.failure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
void _onShiftSelected(
|
||||
ShiftSelected event,
|
||||
Emitter<ClockInState> emit,
|
||||
) {
|
||||
emit(state.copyWith(selectedShift: event.shift));
|
||||
if (!state.attendance.isCheckedIn) {
|
||||
_startLocationUpdates();
|
||||
}
|
||||
}
|
||||
|
||||
void _onDateSelected(
|
||||
DateSelected event,
|
||||
Emitter<ClockInState> emit,
|
||||
) {
|
||||
emit(state.copyWith(selectedDate: event.date));
|
||||
}
|
||||
|
||||
void _onModeChanged(
|
||||
CheckInModeChanged event,
|
||||
Emitter<ClockInState> emit,
|
||||
) {
|
||||
emit(state.copyWith(checkInMode: event.mode));
|
||||
}
|
||||
|
||||
Future<void> _onCheckIn(
|
||||
CheckInRequested event,
|
||||
Emitter<ClockInState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: ClockInStatus.actionInProgress));
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final AttendanceStatus newStatus = await _clockIn(
|
||||
ClockInArguments(shiftId: event.shiftId, notes: event.notes),
|
||||
);
|
||||
emit(state.copyWith(
|
||||
status: ClockInStatus.success,
|
||||
attendance: newStatus,
|
||||
));
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: ClockInStatus.failure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onCheckOut(
|
||||
CheckOutRequested event,
|
||||
Emitter<ClockInState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: ClockInStatus.actionInProgress));
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final AttendanceStatus newStatus = await _clockOut(
|
||||
ClockOutArguments(
|
||||
notes: event.notes,
|
||||
breakTimeMinutes: 0,
|
||||
applicationId: state.attendance.activeApplicationId,
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(
|
||||
status: ClockInStatus.success,
|
||||
attendance: newStatus,
|
||||
));
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: ClockInStatus.failure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
abstract class ClockInEvent extends Equatable {
|
||||
const ClockInEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
class ClockInPageLoaded extends ClockInEvent {}
|
||||
|
||||
class ShiftSelected extends ClockInEvent {
|
||||
const ShiftSelected(this.shift);
|
||||
final Shift shift;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[shift];
|
||||
}
|
||||
|
||||
class DateSelected extends ClockInEvent {
|
||||
|
||||
const DateSelected(this.date);
|
||||
final DateTime date;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[date];
|
||||
}
|
||||
|
||||
class CheckInRequested extends ClockInEvent {
|
||||
|
||||
const CheckInRequested({required this.shiftId, this.notes});
|
||||
final String shiftId;
|
||||
final String? notes;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[shiftId, notes];
|
||||
}
|
||||
|
||||
class CheckOutRequested extends ClockInEvent {
|
||||
|
||||
const CheckOutRequested({this.notes, this.breakTimeMinutes});
|
||||
final String? notes;
|
||||
final int? breakTimeMinutes;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[notes, breakTimeMinutes];
|
||||
}
|
||||
|
||||
class CheckInModeChanged extends ClockInEvent {
|
||||
|
||||
const CheckInModeChanged(this.mode);
|
||||
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,88 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
|
||||
enum ClockInStatus { initial, loading, success, failure, actionInProgress }
|
||||
|
||||
class ClockInState extends Equatable {
|
||||
|
||||
const ClockInState({
|
||||
this.status = ClockInStatus.initial,
|
||||
this.todayShifts = const <Shift>[],
|
||||
this.selectedShift,
|
||||
this.attendance = const AttendanceStatus(),
|
||||
required this.selectedDate,
|
||||
this.checkInMode = 'swipe',
|
||||
this.errorMessage,
|
||||
this.currentLocation,
|
||||
this.distanceFromVenue,
|
||||
this.isLocationVerified = false,
|
||||
this.isCommuteModeOn = false,
|
||||
this.hasLocationConsent = false,
|
||||
this.etaMinutes,
|
||||
});
|
||||
final ClockInStatus status;
|
||||
final List<Shift> todayShifts;
|
||||
final Shift? selectedShift;
|
||||
final AttendanceStatus attendance;
|
||||
final DateTime selectedDate;
|
||||
final String checkInMode;
|
||||
final String? errorMessage;
|
||||
|
||||
final Position? currentLocation;
|
||||
final double? distanceFromVenue;
|
||||
final bool isLocationVerified;
|
||||
final bool isCommuteModeOn;
|
||||
final bool hasLocationConsent;
|
||||
final int? etaMinutes;
|
||||
|
||||
ClockInState copyWith({
|
||||
ClockInStatus? status,
|
||||
List<Shift>? todayShifts,
|
||||
Shift? selectedShift,
|
||||
AttendanceStatus? attendance,
|
||||
DateTime? selectedDate,
|
||||
String? checkInMode,
|
||||
String? errorMessage,
|
||||
Position? currentLocation,
|
||||
double? distanceFromVenue,
|
||||
bool? isLocationVerified,
|
||||
bool? isCommuteModeOn,
|
||||
bool? hasLocationConsent,
|
||||
int? etaMinutes,
|
||||
}) {
|
||||
return ClockInState(
|
||||
status: status ?? this.status,
|
||||
todayShifts: todayShifts ?? this.todayShifts,
|
||||
selectedShift: selectedShift ?? this.selectedShift,
|
||||
attendance: attendance ?? this.attendance,
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
status,
|
||||
todayShifts,
|
||||
selectedShift,
|
||||
attendance,
|
||||
selectedDate,
|
||||
checkInMode,
|
||||
errorMessage,
|
||||
currentLocation,
|
||||
distanceFromVenue,
|
||||
isLocationVerified,
|
||||
isCommuteModeOn,
|
||||
hasLocationConsent,
|
||||
etaMinutes,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
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 '../../../data/services/clock_in_notification_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,
|
||||
required ClockInNotificationService notificationService,
|
||||
}) : _geofenceService = geofenceService,
|
||||
_backgroundGeofenceService = backgroundGeofenceService,
|
||||
_notificationService = notificationService,
|
||||
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<GeofenceOverrideApproved>(_onOverrideApproved);
|
||||
on<GeofenceStopped>(_onStopped);
|
||||
}
|
||||
/// Generation counter to discard stale geofence results when a new
|
||||
/// [GeofenceStarted] event arrives before the previous check completes.
|
||||
int _generation = 0;
|
||||
|
||||
/// The geofence service for foreground proximity checks.
|
||||
final GeofenceServiceInterface _geofenceService;
|
||||
|
||||
/// The background service for periodic tracking while clocked in.
|
||||
final BackgroundGeofenceService _backgroundGeofenceService;
|
||||
|
||||
/// The notification service for clock-in related notifications.
|
||||
final ClockInNotificationService _notificationService;
|
||||
|
||||
/// 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 {
|
||||
// Increment generation so in-flight results from previous shifts are
|
||||
// discarded when they complete after a new GeofenceStarted fires.
|
||||
_generation++;
|
||||
final int currentGeneration = _generation;
|
||||
|
||||
// Reset override state from any previous shift and clear stale location
|
||||
// data so the new shift starts with a clean geofence verification.
|
||||
emit(state.copyWith(
|
||||
isVerifying: true,
|
||||
targetLat: event.targetLat,
|
||||
targetLng: event.targetLng,
|
||||
isGeofenceOverridden: false,
|
||||
clearOverrideNotes: true,
|
||||
isLocationVerified: false,
|
||||
isLocationTimedOut: false,
|
||||
clearCurrentLocation: true,
|
||||
clearDistanceFromTarget: true,
|
||||
));
|
||||
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
// Check permission first.
|
||||
final LocationPermissionStatus permission = await _geofenceService.ensurePermission();
|
||||
|
||||
// Discard if a newer GeofenceStarted has fired while awaiting.
|
||||
if (_generation != currentGeneration) return;
|
||||
|
||||
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((bool isEnabled) {
|
||||
add(GeofenceServiceStatusChanged(isEnabled));
|
||||
});
|
||||
|
||||
// Get initial position with a 30s timeout.
|
||||
final GeofenceResult? result = await _geofenceService.checkGeofenceWithTimeout(
|
||||
targetLat: event.targetLat,
|
||||
targetLng: event.targetLng,
|
||||
);
|
||||
|
||||
// Discard if a newer GeofenceStarted has fired while awaiting.
|
||||
if (_generation != currentGeneration) return;
|
||||
|
||||
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(
|
||||
(GeofenceResult 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 GeofenceResult? 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 LocationPermissionStatus 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,
|
||||
leftGeofenceTitle: event.leftGeofenceTitle,
|
||||
leftGeofenceBody: event.leftGeofenceBody,
|
||||
);
|
||||
|
||||
// Show greeting notification using localized strings from the UI.
|
||||
await _notificationService.showClockInGreeting(
|
||||
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();
|
||||
|
||||
// Show clock-out notification using localized strings from the UI.
|
||||
await _notificationService.showClockOutNotification(
|
||||
title: event.clockOutTitle,
|
||||
body: event.clockOutBody,
|
||||
);
|
||||
|
||||
emit(state.copyWith(isBackgroundTrackingActive: false));
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
isBackgroundTrackingActive: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Handles the [GeofenceOverrideApproved] event by storing the override
|
||||
/// flag and justification notes, enabling the swipe slider.
|
||||
void _onOverrideApproved(
|
||||
GeofenceOverrideApproved event,
|
||||
Emitter<GeofenceState> emit,
|
||||
) {
|
||||
emit(state.copyWith(
|
||||
isGeofenceOverridden: true,
|
||||
overrideNotes: event.notes,
|
||||
));
|
||||
}
|
||||
|
||||
/// 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,147 @@
|
||||
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 {
|
||||
/// Creates a [GeofenceStarted] event.
|
||||
const GeofenceStarted({required this.targetLat, required this.targetLng});
|
||||
|
||||
/// Target latitude of the shift location.
|
||||
final double targetLat;
|
||||
|
||||
/// Target longitude of the shift location.
|
||||
final double 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 {
|
||||
/// Creates a [GeofenceResultUpdated] event.
|
||||
const GeofenceResultUpdated(this.result);
|
||||
|
||||
/// The latest geofence check result.
|
||||
final GeofenceResult 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 {
|
||||
/// Creates a [GeofenceServiceStatusChanged] event.
|
||||
const GeofenceServiceStatusChanged(this.isEnabled);
|
||||
|
||||
/// Whether location services are now enabled.
|
||||
final bool 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 {
|
||||
/// Creates a [BackgroundTrackingStarted] event.
|
||||
const BackgroundTrackingStarted({
|
||||
required this.shiftId,
|
||||
required this.targetLat,
|
||||
required this.targetLng,
|
||||
required this.greetingTitle,
|
||||
required this.greetingBody,
|
||||
required this.leftGeofenceTitle,
|
||||
required this.leftGeofenceBody,
|
||||
});
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Localized title for the left-geofence notification, persisted to storage
|
||||
/// for the background isolate.
|
||||
final String leftGeofenceTitle;
|
||||
|
||||
/// Localized body for the left-geofence notification, persisted to storage
|
||||
/// for the background isolate.
|
||||
final String leftGeofenceBody;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
shiftId,
|
||||
targetLat,
|
||||
targetLng,
|
||||
greetingTitle,
|
||||
greetingBody,
|
||||
leftGeofenceTitle,
|
||||
leftGeofenceBody,
|
||||
];
|
||||
}
|
||||
|
||||
/// Stops background tracking after clock-out.
|
||||
class BackgroundTrackingStopped extends GeofenceEvent {
|
||||
/// Creates a [BackgroundTrackingStopped] event.
|
||||
const BackgroundTrackingStopped({
|
||||
required this.clockOutTitle,
|
||||
required this.clockOutBody,
|
||||
});
|
||||
|
||||
/// Localized clock-out notification title passed from the UI layer.
|
||||
final String clockOutTitle;
|
||||
|
||||
/// Localized clock-out notification body passed from the UI layer.
|
||||
final String clockOutBody;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[clockOutTitle, clockOutBody];
|
||||
}
|
||||
|
||||
/// Worker approved geofence override by providing justification notes.
|
||||
class GeofenceOverrideApproved extends GeofenceEvent {
|
||||
/// Creates a [GeofenceOverrideApproved] event.
|
||||
const GeofenceOverrideApproved({required this.notes});
|
||||
|
||||
/// The justification notes provided by the worker.
|
||||
final String notes;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[notes];
|
||||
}
|
||||
|
||||
/// Stops all geofence monitoring (foreground and background).
|
||||
class GeofenceStopped extends GeofenceEvent {
|
||||
/// Creates a [GeofenceStopped] event.
|
||||
const GeofenceStopped();
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
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.isGeofenceOverridden = false,
|
||||
this.overrideNotes,
|
||||
this.targetLat,
|
||||
this.targetLng,
|
||||
});
|
||||
|
||||
/// Initial state before any geofence operations.
|
||||
const GeofenceState.initial() : this();
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Whether the worker has overridden the geofence check via justification.
|
||||
final bool isGeofenceOverridden;
|
||||
|
||||
/// Justification notes provided when overriding the geofence.
|
||||
final String? overrideNotes;
|
||||
|
||||
/// Target latitude being monitored.
|
||||
final double? targetLat;
|
||||
|
||||
/// Target longitude being monitored.
|
||||
final double? targetLng;
|
||||
|
||||
/// Creates a copy with the given fields replaced.
|
||||
///
|
||||
/// Use the `clearX` flags to explicitly set nullable fields to `null`,
|
||||
/// since the `??` fallback otherwise prevents clearing.
|
||||
GeofenceState copyWith({
|
||||
LocationPermissionStatus? permissionStatus,
|
||||
bool clearPermissionStatus = false,
|
||||
bool? isLocationServiceEnabled,
|
||||
DeviceLocation? currentLocation,
|
||||
bool clearCurrentLocation = false,
|
||||
double? distanceFromTarget,
|
||||
bool clearDistanceFromTarget = false,
|
||||
bool? isLocationVerified,
|
||||
bool? isLocationTimedOut,
|
||||
bool? isVerifying,
|
||||
bool? isBackgroundTrackingActive,
|
||||
bool? isGeofenceOverridden,
|
||||
String? overrideNotes,
|
||||
bool clearOverrideNotes = false,
|
||||
double? targetLat,
|
||||
bool clearTargetLat = false,
|
||||
double? targetLng,
|
||||
bool clearTargetLng = false,
|
||||
}) {
|
||||
return GeofenceState(
|
||||
permissionStatus: clearPermissionStatus
|
||||
? null
|
||||
: (permissionStatus ?? this.permissionStatus),
|
||||
isLocationServiceEnabled:
|
||||
isLocationServiceEnabled ?? this.isLocationServiceEnabled,
|
||||
currentLocation: clearCurrentLocation
|
||||
? null
|
||||
: (currentLocation ?? this.currentLocation),
|
||||
distanceFromTarget: clearDistanceFromTarget
|
||||
? null
|
||||
: (distanceFromTarget ?? this.distanceFromTarget),
|
||||
isLocationVerified: isLocationVerified ?? this.isLocationVerified,
|
||||
isLocationTimedOut: isLocationTimedOut ?? this.isLocationTimedOut,
|
||||
isVerifying: isVerifying ?? this.isVerifying,
|
||||
isBackgroundTrackingActive:
|
||||
isBackgroundTrackingActive ?? this.isBackgroundTrackingActive,
|
||||
isGeofenceOverridden: isGeofenceOverridden ?? this.isGeofenceOverridden,
|
||||
overrideNotes:
|
||||
clearOverrideNotes ? null : (overrideNotes ?? this.overrideNotes),
|
||||
targetLat: clearTargetLat ? null : (targetLat ?? this.targetLat),
|
||||
targetLng: clearTargetLng ? null : (targetLng ?? this.targetLng),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
permissionStatus,
|
||||
isLocationServiceEnabled,
|
||||
currentLocation,
|
||||
distanceFromTarget,
|
||||
isLocationVerified,
|
||||
isLocationTimedOut,
|
||||
isVerifying,
|
||||
isBackgroundTrackingActive,
|
||||
isGeofenceOverridden,
|
||||
overrideNotes,
|
||||
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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,745 +1,75 @@
|
||||
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports, unused_element, unused_field, duplicate_ignore
|
||||
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:intl/intl.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/clock_in/clock_in_bloc.dart';
|
||||
import '../bloc/clock_in/clock_in_event.dart';
|
||||
import '../bloc/clock_in/clock_in_state.dart';
|
||||
import '../bloc/geofence/geofence_bloc.dart';
|
||||
import '../widgets/clock_in_body.dart';
|
||||
import '../widgets/clock_in_page_skeleton/clock_in_page_skeleton.dart';
|
||||
import '../widgets/commute_tracker.dart';
|
||||
import '../widgets/date_selector.dart';
|
||||
import '../widgets/lunch_break_modal.dart';
|
||||
import '../widgets/swipe_to_check_in.dart';
|
||||
|
||||
class ClockInPage extends StatefulWidget {
|
||||
/// Top-level page for the staff clock-in feature.
|
||||
///
|
||||
/// 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});
|
||||
|
||||
@override
|
||||
State<ClockInPage> createState() => _ClockInPageState();
|
||||
}
|
||||
|
||||
class _ClockInPageState extends State<ClockInPage> {
|
||||
late final ClockInBloc _bloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bloc = Modular.get<ClockInBloc>();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffClockInEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in;
|
||||
return BlocProvider<ClockInBloc>.value(
|
||||
value: _bloc,
|
||||
child: BlocConsumer<ClockInBloc, ClockInState>(
|
||||
listener: (BuildContext context, ClockInState state) {
|
||||
if (state.status == ClockInStatus.failure &&
|
||||
state.errorMessage != null) {
|
||||
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(title: i18n.title, showBackButton: false),
|
||||
body: MultiBlocProvider(
|
||||
providers: <BlocProvider<dynamic>>[
|
||||
BlocProvider<GeofenceBloc>.value(
|
||||
value: Modular.get<GeofenceBloc>(),
|
||||
),
|
||||
BlocProvider<ClockInBloc>(
|
||||
create: (BuildContext _) {
|
||||
final ClockInBloc bloc = Modular.get<ClockInBloc>();
|
||||
bloc.add(ClockInPageLoaded());
|
||||
return bloc;
|
||||
},
|
||||
),
|
||||
],
|
||||
child: BlocListener<ClockInBloc, ClockInState>(
|
||||
listenWhen: (ClockInState previous, ClockInState current) =>
|
||||
current.status == ClockInStatus.failure &&
|
||||
current.errorMessage != null &&
|
||||
(previous.status != current.status ||
|
||||
previous.errorMessage != current.errorMessage),
|
||||
listener: (BuildContext context, ClockInState state) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: translateErrorKey(state.errorMessage!),
|
||||
type: UiSnackbarType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (BuildContext context, ClockInState state) {
|
||||
if (state.status == ClockInStatus.loading &&
|
||||
state.todayShifts.isEmpty) {
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(title: i18n.title, showBackButton: false),
|
||||
body: const SafeArea(child: ClockInPageSkeleton()),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<ClockInBloc, ClockInState>(
|
||||
buildWhen: (ClockInState previous, ClockInState current) =>
|
||||
previous.status != current.status ||
|
||||
previous.todayShifts != current.todayShifts,
|
||||
builder: (BuildContext context, ClockInState state) {
|
||||
final bool isInitialLoading =
|
||||
state.status == ClockInStatus.loading &&
|
||||
state.todayShifts.isEmpty;
|
||||
|
||||
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 Scaffold(
|
||||
appBar: UiAppBar(title: i18n.title, showBackButton: false),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: UiConstants.space24,
|
||||
top: UiConstants.space6,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space5,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// // Commute Tracker (shows before date selector when applicable)
|
||||
// if (selectedShift != null)
|
||||
// CommuteTracker(
|
||||
// shift: selectedShift,
|
||||
// hasLocationConsent: state.hasLocationConsent,
|
||||
// isCommuteModeOn: state.isCommuteModeOn,
|
||||
// distanceMeters: state.distanceFromVenue,
|
||||
// etaMinutes: state.etaMinutes,
|
||||
// onCommuteToggled: (bool value) {
|
||||
// _bloc.add(CommuteModeToggled(value));
|
||||
// },
|
||||
// ),
|
||||
// Date Selector
|
||||
DateSelector(
|
||||
selectedDate: state.selectedDate,
|
||||
onSelect: (DateTime date) =>
|
||||
_bloc.add(DateSelected(date)),
|
||||
shiftDates: <String>[
|
||||
DateFormat('yyyy-MM-dd').format(DateTime.now()),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
|
||||
// Your Activity Header
|
||||
Text(
|
||||
i18n.your_activity,
|
||||
textAlign: TextAlign.start,
|
||||
style: UiTypography.headline4m,
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Selected Shift Info Card
|
||||
if (todayShifts.isNotEmpty)
|
||||
Column(
|
||||
children: todayShifts
|
||||
.map(
|
||||
(Shift shift) => GestureDetector(
|
||||
onTap: () =>
|
||||
_bloc.add(ShiftSelected(shift)),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(
|
||||
UiConstants.space3,
|
||||
),
|
||||
margin: const EdgeInsets.only(
|
||||
bottom: UiConstants.space3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(
|
||||
color: shift.id == selectedShift?.id
|
||||
? UiColors.primary
|
||||
: UiColors.border,
|
||||
width: shift.id == selectedShift?.id
|
||||
? 2
|
||||
: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
shift.id ==
|
||||
selectedShift?.id
|
||||
? i18n.selected_shift_badge
|
||||
: i18n.today_shift_badge,
|
||||
style: UiTypography
|
||||
.titleUppercase4b
|
||||
.copyWith(
|
||||
color:
|
||||
shift.id ==
|
||||
selectedShift
|
||||
?.id
|
||||
? UiColors.primary
|
||||
: UiColors
|
||||
.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
shift.title,
|
||||
style: UiTypography.body2b,
|
||||
),
|
||||
Text(
|
||||
"${shift.clientName} ${shift.location}",
|
||||
style: UiTypography
|
||||
.body3r
|
||||
.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
"${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}",
|
||||
style: UiTypography
|
||||
.body3m
|
||||
.textSecondary,
|
||||
),
|
||||
Text(
|
||||
"\$${shift.hourlyRate}/hr",
|
||||
style: UiTypography.body3m
|
||||
.copyWith(
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
|
||||
// Swipe To Check In / Checked Out State / No Shift State
|
||||
if (selectedShift != null &&
|
||||
checkOutTime == null) ...<Widget>[
|
||||
if (!isCheckedIn &&
|
||||
!_isCheckInAllowed(selectedShift))
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(
|
||||
UiConstants.space6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.bgSecondary,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.clock,
|
||||
size: 48,
|
||||
color: UiColors.iconThird,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Text(
|
||||
i18n.early_title,
|
||||
style: UiTypography.body1m.textSecondary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
i18n.check_in_at(
|
||||
time: _getCheckInAvailabilityTime(
|
||||
selectedShift,
|
||||
),
|
||||
),
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else ...<Widget>[
|
||||
// Attire Photo Section
|
||||
// if (!isCheckedIn) ...<Widget>[
|
||||
// Container(
|
||||
// padding: const EdgeInsets.all(
|
||||
// UiConstants.space4,
|
||||
// ),
|
||||
// margin: const EdgeInsets.only(
|
||||
// bottom: UiConstants.space4,
|
||||
// ),
|
||||
// decoration: BoxDecoration(
|
||||
// color: UiColors.white,
|
||||
// borderRadius: UiConstants.radiusLg,
|
||||
// border: Border.all(color: UiColors.border),
|
||||
// ),
|
||||
// child: Row(
|
||||
// children: <Widget>[
|
||||
// Container(
|
||||
// width: 48,
|
||||
// height: 48,
|
||||
// decoration: BoxDecoration(
|
||||
// color: UiColors.bgSecondary,
|
||||
// borderRadius: UiConstants.radiusMd,
|
||||
// ),
|
||||
// child: const Icon(
|
||||
// UiIcons.camera,
|
||||
// color: UiColors.primary,
|
||||
// ),
|
||||
// ),
|
||||
// const SizedBox(width: UiConstants.space3),
|
||||
// Expanded(
|
||||
// child: Column(
|
||||
// crossAxisAlignment:
|
||||
// CrossAxisAlignment.start,
|
||||
// children: <Widget>[
|
||||
// Text(
|
||||
// i18n.attire_photo_label,
|
||||
// style: UiTypography.body2b,
|
||||
// ),
|
||||
// Text(
|
||||
// i18n.attire_photo_desc,
|
||||
// style: UiTypography
|
||||
// .body3r
|
||||
// .textSecondary,
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// UiButton.secondary(
|
||||
// text: i18n.take_attire_photo,
|
||||
// onPressed: () {
|
||||
// UiSnackbar.show(
|
||||
// context,
|
||||
// message: i18n.attire_captured,
|
||||
// type: UiSnackbarType.success,
|
||||
// );
|
||||
// },
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// ],
|
||||
|
||||
// if (!isCheckedIn &&
|
||||
// (!state.isLocationVerified ||
|
||||
// state.currentLocation ==
|
||||
// null)) ...<Widget>[
|
||||
// Container(
|
||||
// width: double.infinity,
|
||||
// padding: const EdgeInsets.all(
|
||||
// UiConstants.space4,
|
||||
// ),
|
||||
// margin: const EdgeInsets.only(
|
||||
// bottom: UiConstants.space4,
|
||||
// ),
|
||||
// decoration: BoxDecoration(
|
||||
// color: UiColors.tagError,
|
||||
// borderRadius: UiConstants.radiusLg,
|
||||
// ),
|
||||
// child: Row(
|
||||
// children: [
|
||||
// const Icon(
|
||||
// UiIcons.error,
|
||||
// color: UiColors.textError,
|
||||
// size: 20,
|
||||
// ),
|
||||
// const SizedBox(width: UiConstants.space3),
|
||||
// Expanded(
|
||||
// child: Text(
|
||||
// state.currentLocation == null
|
||||
// ? i18n.location_verifying
|
||||
// : i18n.not_in_range(
|
||||
// distance: '500',
|
||||
// ),
|
||||
// style: UiTypography.body3m.textError,
|
||||
// ),
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// ],
|
||||
SwipeToCheckIn(
|
||||
isCheckedIn: isCheckedIn,
|
||||
mode: state.checkInMode,
|
||||
isDisabled: isCheckedIn,
|
||||
isLoading:
|
||||
state.status ==
|
||||
ClockInStatus.actionInProgress,
|
||||
onCheckIn: () async {
|
||||
// Show NFC dialog if mode is 'nfc'
|
||||
if (state.checkInMode == 'nfc') {
|
||||
await _showNFCDialog(context);
|
||||
} else {
|
||||
_bloc.add(
|
||||
CheckInRequested(
|
||||
shiftId: selectedShift.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onCheckOut: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) =>
|
||||
LunchBreakDialog(
|
||||
onComplete: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
).pop(); // Close dialog first
|
||||
_bloc.add(
|
||||
const CheckOutRequested(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
] else if (selectedShift != null &&
|
||||
checkOutTime != null) ...<Widget>[
|
||||
// Shift Completed State
|
||||
Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.tagSuccess,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(
|
||||
color: UiColors.success.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.tagActive,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.check,
|
||||
color: UiColors.textSuccess,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Text(
|
||||
i18n.shift_completed,
|
||||
style: UiTypography.body1b.textSuccess,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
i18n.great_work,
|
||||
style: UiTypography.body2r.textSuccess,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else ...<Widget>[
|
||||
// No Shift State
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.bgSecondary,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
i18n.no_shifts_today,
|
||||
style: UiTypography.body1m.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
i18n.accept_shift_cta,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Checked In Banner
|
||||
if (isCheckedIn && checkInTime != null) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.tagSuccess,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(
|
||||
color: UiColors.success.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
i18n.checked_in_at_label,
|
||||
style: UiTypography.body3m.textSuccess,
|
||||
),
|
||||
Text(
|
||||
DateFormat(
|
||||
'h:mm a',
|
||||
).format(checkInTime),
|
||||
style: UiTypography.body1b.textSuccess,
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.tagActive,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.check,
|
||||
color: UiColors.textSuccess,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Recent Activity List (Temporarily removed)
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModeTab(
|
||||
String label,
|
||||
IconData icon,
|
||||
String value,
|
||||
String currentMode,
|
||||
) {
|
||||
final bool isSelected = currentMode == value;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => _bloc.add(CheckInModeChanged(value)),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: UiConstants.space2),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? UiColors.white : UiColors.transparent,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
boxShadow: isSelected
|
||||
? <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: UiColors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 2,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
]
|
||||
: <BoxShadow>[],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: isSelected ? UiColors.foreground : UiColors.iconThird,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: UiTypography.body2m.copyWith(
|
||||
color: isSelected
|
||||
? UiColors.foreground
|
||||
: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
return isInitialLoading
|
||||
? const ClockInPageSkeleton()
|
||||
: const ClockInBody();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showNFCDialog(BuildContext context) async {
|
||||
final TranslationsStaffClockInEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in;
|
||||
bool scanned = false;
|
||||
|
||||
// Using a local navigator context since we are in a dialog
|
||||
await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (BuildContext context, setState) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
scanned
|
||||
? i18n.nfc_dialog.scanned_title
|
||||
: i18n.nfc_dialog.scan_title,
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
decoration: BoxDecoration(
|
||||
color: scanned
|
||||
? UiColors.tagSuccess
|
||||
: UiColors.tagInProgress,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
scanned ? UiIcons.check : UiIcons.nfc,
|
||||
size: 48,
|
||||
color: scanned ? UiColors.textSuccess : UiColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
Text(
|
||||
scanned
|
||||
? i18n.nfc_dialog.processing
|
||||
: i18n.nfc_dialog.ready_to_scan,
|
||||
style: UiTypography.headline4m,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
scanned
|
||||
? i18n.nfc_dialog.please_wait
|
||||
: i18n.nfc_dialog.scan_instruction,
|
||||
textAlign: TextAlign.center,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
if (!scanned) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
scanned = true;
|
||||
});
|
||||
// Simulate NFC scan delay
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: 1000),
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(dialogContext).pop();
|
||||
// Trigger BLoC event
|
||||
// Need to access the bloc from the outer context or via passed reference
|
||||
// Since _bloc is a field of the page state, we can use it if we are inside the page class
|
||||
// But this dialog is just a function call.
|
||||
// It's safer to just return a result
|
||||
},
|
||||
icon: const Icon(UiIcons.nfc, size: 24),
|
||||
label: Text(
|
||||
i18n.nfc_dialog.tap_to_scan,
|
||||
style: UiTypography.headline4m.white,
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: UiColors.primary,
|
||||
foregroundColor: UiColors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// After dialog closes, trigger the event if scan was successful (simulated)
|
||||
// In real app, we would check the dialog result
|
||||
if (scanned && _bloc.state.selectedShift != null) {
|
||||
_bloc.add(CheckInRequested(shiftId: _bloc.state.selectedShift!.id));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper Methods ---
|
||||
|
||||
String _formatTime(String timeStr) {
|
||||
if (timeStr.isEmpty) return '';
|
||||
try {
|
||||
// Try parsing as ISO string first (which contains date)
|
||||
final DateTime dt = DateTime.parse(timeStr);
|
||||
return DateFormat('h:mm a').format(dt);
|
||||
} catch (_) {
|
||||
// Fallback for strict "HH:mm" or "HH:mm:ss" strings
|
||||
try {
|
||||
final List<String> parts = timeStr.split(':');
|
||||
if (parts.length >= 2) {
|
||||
final DateTime dt = DateTime(
|
||||
2022,
|
||||
1,
|
||||
1,
|
||||
int.parse(parts[0]),
|
||||
int.parse(parts[1]),
|
||||
);
|
||||
return DateFormat('h:mm a').format(dt);
|
||||
}
|
||||
return timeStr;
|
||||
} catch (e) {
|
||||
return timeStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _isCheckInAllowed(Shift shift) {
|
||||
try {
|
||||
// Parse shift date (e.g. 2024-01-31T09:00:00)
|
||||
// The Shift entity has 'date' which is the start DateTime string
|
||||
final DateTime shiftStart = DateTime.parse(shift.startTime);
|
||||
final DateTime windowStart = shiftStart.subtract(
|
||||
const Duration(minutes: 15),
|
||||
);
|
||||
return DateTime.now().isAfter(windowStart);
|
||||
} catch (e) {
|
||||
// Fallback: If parsing fails, allow check in to avoid blocking.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
String _getCheckInAvailabilityTime(Shift shift) {
|
||||
try {
|
||||
final DateTime shiftStart = DateTime.parse(shift.startTime.trim());
|
||||
final DateTime windowStart = shiftStart.subtract(
|
||||
const Duration(minutes: 15),
|
||||
);
|
||||
return DateFormat('h:mm a').format(windowStart);
|
||||
} catch (e) {
|
||||
final TranslationsStaffClockInEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in;
|
||||
return i18n.soon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Interface for different clock-in/out interaction methods (swipe, NFC, etc.).
|
||||
///
|
||||
/// Each implementation encapsulates the UI and behavior for a specific
|
||||
/// check-in mode, allowing the action section to remain mode-agnostic.
|
||||
abstract class CheckInInteraction {
|
||||
/// Unique identifier for this interaction mode (e.g. "swipe", "nfc").
|
||||
String get mode;
|
||||
|
||||
/// Builds the action widget for this interaction method.
|
||||
///
|
||||
/// The returned widget handles user interaction (swipe gesture, NFC tap,
|
||||
/// etc.) and invokes [onCheckIn] or [onCheckOut] when the action completes.
|
||||
Widget buildActionWidget({
|
||||
required bool isCheckedIn,
|
||||
required bool isDisabled,
|
||||
required bool isLoading,
|
||||
required bool hasClockinError,
|
||||
required VoidCallback onCheckIn,
|
||||
required VoidCallback onCheckOut,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../widgets/nfc_scan_dialog.dart';
|
||||
import 'check_in_interaction.dart';
|
||||
|
||||
/// NFC-based check-in interaction that shows a tap button and scan dialog.
|
||||
///
|
||||
/// When tapped, presents the [showNfcScanDialog] and triggers [onCheckIn]
|
||||
/// or [onCheckOut] upon a successful scan.
|
||||
class NfcCheckInInteraction implements CheckInInteraction {
|
||||
/// Creates an NFC check-in interaction.
|
||||
const NfcCheckInInteraction();
|
||||
|
||||
@override
|
||||
String get mode => 'nfc';
|
||||
|
||||
@override
|
||||
Widget buildActionWidget({
|
||||
required bool isCheckedIn,
|
||||
required bool isDisabled,
|
||||
required bool isLoading,
|
||||
required bool hasClockinError,
|
||||
required VoidCallback onCheckIn,
|
||||
required VoidCallback onCheckOut,
|
||||
}) {
|
||||
return _NfcCheckInButton(
|
||||
isCheckedIn: isCheckedIn,
|
||||
isDisabled: isDisabled,
|
||||
isLoading: isLoading,
|
||||
onCheckIn: onCheckIn,
|
||||
onCheckOut: onCheckOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tap button that launches the NFC scan dialog and triggers check-in/out.
|
||||
class _NfcCheckInButton extends StatelessWidget {
|
||||
const _NfcCheckInButton({
|
||||
required this.isCheckedIn,
|
||||
required this.isDisabled,
|
||||
required this.isLoading,
|
||||
required this.onCheckIn,
|
||||
required this.onCheckOut,
|
||||
});
|
||||
|
||||
/// Whether the user is currently checked in.
|
||||
final bool isCheckedIn;
|
||||
|
||||
/// Whether the button should be disabled (e.g. geofence blocking).
|
||||
final bool isDisabled;
|
||||
|
||||
/// Whether a check-in/out action is in progress.
|
||||
final bool isLoading;
|
||||
|
||||
/// Called after a successful NFC scan when checking in.
|
||||
final VoidCallback onCheckIn;
|
||||
|
||||
/// Called after a successful NFC scan when checking out.
|
||||
final VoidCallback onCheckOut;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffClockInSwipeEn i18n =
|
||||
Translations.of(context).staff.clock_in.swipe;
|
||||
final Color baseColor = isCheckedIn ? UiColors.success : UiColors.primary;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _handleTap(context),
|
||||
child: Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: isDisabled ? UiColors.bgSecondary : baseColor,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
boxShadow: isDisabled
|
||||
? <BoxShadow>[]
|
||||
: <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: baseColor.withValues(alpha: 0.4),
|
||||
blurRadius: 25,
|
||||
offset: const Offset(0, 10),
|
||||
spreadRadius: -5,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const Icon(UiIcons.wifi, color: UiColors.white),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Text(
|
||||
isLoading
|
||||
? (isCheckedIn ? i18n.checking_out : i18n.checking_in)
|
||||
: (isCheckedIn ? i18n.nfc_checkout : i18n.nfc_checkin),
|
||||
style: UiTypography.body1b.copyWith(
|
||||
color: isDisabled ? UiColors.textDisabled : UiColors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Opens the NFC scan dialog and triggers the appropriate callback on success.
|
||||
Future<void> _handleTap(BuildContext context) async {
|
||||
if (isLoading || isDisabled) return;
|
||||
|
||||
final bool scanned = await showNfcScanDialog(context);
|
||||
if (scanned && context.mounted) {
|
||||
if (isCheckedIn) {
|
||||
onCheckOut();
|
||||
} else {
|
||||
onCheckIn();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../widgets/swipe_to_check_in.dart';
|
||||
import 'check_in_interaction.dart';
|
||||
|
||||
/// Swipe-based check-in interaction using the [SwipeToCheckIn] slider widget.
|
||||
class SwipeCheckInInteraction implements CheckInInteraction {
|
||||
/// Creates a swipe check-in interaction.
|
||||
const SwipeCheckInInteraction();
|
||||
|
||||
@override
|
||||
String get mode => 'swipe';
|
||||
|
||||
@override
|
||||
Widget buildActionWidget({
|
||||
required bool isCheckedIn,
|
||||
required bool isDisabled,
|
||||
required bool isLoading,
|
||||
required bool hasClockinError,
|
||||
required VoidCallback onCheckIn,
|
||||
required VoidCallback onCheckOut,
|
||||
}) {
|
||||
return SwipeToCheckIn(
|
||||
isCheckedIn: isCheckedIn,
|
||||
isDisabled: isDisabled,
|
||||
isLoading: isLoading,
|
||||
hasClockinError: hasClockinError,
|
||||
onCheckIn: onCheckIn,
|
||||
onCheckOut: onCheckOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../bloc/clock_in/clock_in_bloc.dart';
|
||||
import '../bloc/clock_in/clock_in_event.dart';
|
||||
|
||||
/// A single selectable tab within a check-in mode toggle strip.
|
||||
///
|
||||
/// Used to switch between different check-in methods (e.g. swipe, NFC).
|
||||
class CheckInModeTab extends StatelessWidget {
|
||||
/// Creates a mode tab.
|
||||
const CheckInModeTab({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.value,
|
||||
required this.currentMode,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The display label for this mode.
|
||||
final String label;
|
||||
|
||||
/// The icon shown next to the label.
|
||||
final IconData icon;
|
||||
|
||||
/// The mode value this tab represents.
|
||||
final String value;
|
||||
|
||||
/// The currently active mode, used to determine selection state.
|
||||
final String currentMode;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool isSelected = currentMode == value;
|
||||
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () =>
|
||||
ReadContext(context).read<ClockInBloc>().add(CheckInModeChanged(value)),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: UiConstants.space2),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? UiColors.white : UiColors.transparent,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
boxShadow: isSelected
|
||||
? <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: UiColors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 2,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
]
|
||||
: <BoxShadow>[],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: isSelected ? UiColors.foreground : UiColors.iconThird,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
label,
|
||||
style: UiTypography.body2m.copyWith(
|
||||
color: isSelected
|
||||
? UiColors.foreground
|
||||
: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// A green-tinted banner confirming that the user is currently checked in.
|
||||
///
|
||||
/// Displays the exact check-in time alongside a check icon.
|
||||
class CheckedInBanner extends StatelessWidget {
|
||||
/// Creates a checked-in banner for the given [checkInTime].
|
||||
const CheckedInBanner({required this.checkInTime, super.key});
|
||||
|
||||
/// The time the user checked in.
|
||||
final DateTime checkInTime;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffClockInEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.tagSuccess,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(
|
||||
color: UiColors.success.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
i18n.checked_in_at_label,
|
||||
style: UiTypography.body3m.textSuccess,
|
||||
),
|
||||
Text(
|
||||
DateFormat('h:mm a').format(checkInTime),
|
||||
style: UiTypography.body1b.textSuccess,
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.tagActive,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.check,
|
||||
color: UiColors.textSuccess,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
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 'package:staff_clock_in/src/presentation/widgets/early_check_in_banner.dart';
|
||||
import 'package:staff_clock_in/src/presentation/widgets/early_check_out_banner.dart';
|
||||
|
||||
import '../bloc/clock_in/clock_in_bloc.dart';
|
||||
import '../bloc/clock_in/clock_in_event.dart';
|
||||
import '../bloc/geofence/geofence_bloc.dart';
|
||||
import '../bloc/geofence/geofence_state.dart';
|
||||
import '../strategies/check_in_interaction.dart';
|
||||
import '../strategies/nfc_check_in_interaction.dart';
|
||||
import '../strategies/swipe_check_in_interaction.dart';
|
||||
import 'geofence_status_banner/geofence_status_banner.dart';
|
||||
import 'lunch_break_modal.dart';
|
||||
import 'no_shifts_banner.dart';
|
||||
import 'shift_completed_banner.dart';
|
||||
|
||||
/// Orchestrates which action widget is displayed based on the current state.
|
||||
///
|
||||
/// Uses the [CheckInInteraction] strategy pattern to delegate the actual
|
||||
/// check-in/out UI to mode-specific implementations (swipe, NFC, etc.).
|
||||
/// Also shows the [GeofenceStatusBanner]. Background tracking lifecycle
|
||||
/// is managed by [ClockInBloc], not this widget.
|
||||
class ClockInActionSection extends StatelessWidget {
|
||||
/// Creates the action section.
|
||||
const ClockInActionSection({
|
||||
required this.selectedShift,
|
||||
required this.isCheckedIn,
|
||||
required this.checkOutTime,
|
||||
required this.checkInMode,
|
||||
required this.isActionInProgress,
|
||||
this.hasClockinError = false,
|
||||
this.isCheckInAllowed = true,
|
||||
this.isCheckOutAllowed = true,
|
||||
this.checkInAvailabilityTime,
|
||||
this.checkOutAvailabilityTime,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Available check-in interaction strategies keyed by mode identifier.
|
||||
static const Map<String, CheckInInteraction> _interactions =
|
||||
<String, CheckInInteraction>{
|
||||
'swipe': SwipeCheckInInteraction(),
|
||||
'nfc': NfcCheckInInteraction(),
|
||||
};
|
||||
|
||||
/// The currently selected shift, or null if none is selected.
|
||||
final Shift? selectedShift;
|
||||
|
||||
/// Whether the user is currently checked in for the active shift.
|
||||
final bool isCheckedIn;
|
||||
|
||||
/// The check-out time, or null if the user has not checked out.
|
||||
final DateTime? checkOutTime;
|
||||
|
||||
/// The current check-in mode (e.g. "swipe" or "nfc").
|
||||
final String checkInMode;
|
||||
|
||||
/// Whether a check-in or check-out action is currently in progress.
|
||||
final bool isActionInProgress;
|
||||
|
||||
/// Whether the last action attempt resulted in an error.
|
||||
final bool hasClockinError;
|
||||
|
||||
/// Whether the time window allows check-in, computed by the BLoC.
|
||||
final bool isCheckInAllowed;
|
||||
|
||||
/// Whether the time window allows check-out, computed by the BLoC.
|
||||
final bool isCheckOutAllowed;
|
||||
|
||||
/// Formatted earliest time when check-in becomes available, or `null`.
|
||||
final String? checkInAvailabilityTime;
|
||||
|
||||
/// Formatted earliest time when check-out becomes available, or `null`.
|
||||
final String? checkOutAvailabilityTime;
|
||||
|
||||
/// Resolves the [CheckInInteraction] for the current mode.
|
||||
///
|
||||
/// Falls back to [SwipeCheckInInteraction] if the mode is unrecognized.
|
||||
CheckInInteraction get _currentInteraction =>
|
||||
_interactions[checkInMode] ?? const SwipeCheckInInteraction();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (selectedShift != null && checkOutTime == null) {
|
||||
return _buildActiveShiftAction(context);
|
||||
}
|
||||
|
||||
if (selectedShift != null && checkOutTime != null) {
|
||||
return const ShiftCompletedBanner();
|
||||
}
|
||||
|
||||
return const NoShiftsBanner();
|
||||
}
|
||||
|
||||
/// Builds the action widget for an active (not completed) shift.
|
||||
Widget _buildActiveShiftAction(BuildContext context) {
|
||||
final String soonLabel = Translations.of(context).staff.clock_in.soon;
|
||||
|
||||
// Show geofence status and time-based availability banners when relevant.
|
||||
if (!isCheckedIn && !isCheckInAllowed) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
const GeofenceStatusBanner(),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
EarlyCheckInBanner(
|
||||
availabilityTime: checkInAvailabilityTime ?? soonLabel,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (isCheckedIn && !isCheckOutAllowed) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
const GeofenceStatusBanner(),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
EarlyCheckOutBanner(
|
||||
availabilityTime: checkOutAvailabilityTime ?? soonLabel,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return BlocBuilder<GeofenceBloc, GeofenceState>(
|
||||
builder: (BuildContext context, GeofenceState geofenceState) {
|
||||
final bool hasCoordinates =
|
||||
selectedShift?.latitude != null && selectedShift?.longitude != null;
|
||||
|
||||
// Geofence only gates clock-in, never clock-out. When already
|
||||
// checked in the swipe must always be enabled for checkout.
|
||||
final bool isGeofenceBlocking =
|
||||
hasCoordinates &&
|
||||
!geofenceState.isLocationVerified &&
|
||||
!geofenceState.isLocationTimedOut &&
|
||||
!geofenceState.isGeofenceOverridden;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: UiConstants.space4,
|
||||
children: <Widget>[
|
||||
const GeofenceStatusBanner(),
|
||||
_currentInteraction.buildActionWidget(
|
||||
isCheckedIn: isCheckedIn,
|
||||
isDisabled: isGeofenceBlocking,
|
||||
isLoading: isActionInProgress,
|
||||
hasClockinError: hasClockinError,
|
||||
onCheckIn: () => _handleCheckIn(context),
|
||||
onCheckOut: () => _handleCheckOut(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Triggers the check-in flow, passing notification strings and
|
||||
/// override notes from geofence state.
|
||||
///
|
||||
/// Returns early if [selectedShift] is null to avoid force-unwrap errors.
|
||||
void _handleCheckIn(BuildContext context) {
|
||||
if (selectedShift == null) return;
|
||||
final GeofenceState geofenceState = ReadContext(
|
||||
context,
|
||||
).read<GeofenceBloc>().state;
|
||||
final TranslationsStaffClockInGeofenceEn geofenceI18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in.geofence;
|
||||
|
||||
ReadContext(context).read<ClockInBloc>().add(
|
||||
CheckInRequested(
|
||||
shiftId: selectedShift!.id,
|
||||
notes: geofenceState.overrideNotes,
|
||||
clockInGreetingTitle: geofenceI18n.clock_in_greeting_title,
|
||||
clockInGreetingBody: geofenceI18n.clock_in_greeting_body,
|
||||
leftGeofenceTitle: geofenceI18n.background_left_title,
|
||||
leftGeofenceBody: geofenceI18n.background_left_body,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Triggers the check-out flow via the lunch-break confirmation dialog.
|
||||
void _handleCheckOut(BuildContext context) {
|
||||
final TranslationsStaffClockInGeofenceEn geofenceI18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in.geofence;
|
||||
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) => LunchBreakDialog(
|
||||
onComplete: (int breakTimeMinutes) {
|
||||
Modular.to.popSafe();
|
||||
ReadContext(context).read<ClockInBloc>().add(
|
||||
CheckOutRequested(
|
||||
breakTimeMinutes: breakTimeMinutes,
|
||||
clockOutTitle: geofenceI18n.clock_out_title,
|
||||
clockOutBody: geofenceI18n.clock_out_body,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../bloc/clock_in/clock_in_bloc.dart';
|
||||
import '../bloc/clock_in/clock_in_event.dart';
|
||||
import '../bloc/clock_in/clock_in_state.dart';
|
||||
import '../bloc/geofence/geofence_bloc.dart';
|
||||
import '../bloc/geofence/geofence_event.dart';
|
||||
import 'checked_in_banner.dart';
|
||||
import 'clock_in_action_section.dart';
|
||||
import 'date_selector.dart';
|
||||
import 'shift_card_list.dart';
|
||||
|
||||
/// The scrollable main content of the clock-in page.
|
||||
///
|
||||
/// Composes the date selector, activity header, shift cards, action section,
|
||||
/// and the checked-in status banner into a single scrollable column.
|
||||
/// 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 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) =>
|
||||
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) => 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,
|
||||
hasClockinError: state.status == ClockInStatus.failure,
|
||||
isCheckInAllowed: state.isCheckInAllowed,
|
||||
isCheckOutAllowed: state.isCheckOutAllowed,
|
||||
checkInAvailabilityTime: state.checkInAvailabilityTime,
|
||||
checkOutAvailabilityTime: state.checkOutAvailabilityTime,
|
||||
),
|
||||
|
||||
// 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),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ class DateSelector extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final DateTime today = DateTime.now();
|
||||
final List<DateTime> dates = List.generate(7, (int index) {
|
||||
final List<DateTime> dates = List<DateTime>.generate(7, (int index) {
|
||||
return today.add(Duration(days: index - 3));
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ class DateSelector extends StatelessWidget {
|
||||
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => onSelect(date),
|
||||
onTap: isToday ? () => onSelect(date) : null,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
margin: const EdgeInsets.symmetric(
|
||||
@@ -40,58 +40,55 @@ class DateSelector extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? UiColors.primary : UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
boxShadow: isSelected
|
||||
? <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: UiColors.primary.withValues(alpha: 0.3),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
]
|
||||
: <BoxShadow>[],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
DateFormat('d').format(date),
|
||||
style: UiTypography.title1m.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color:
|
||||
isSelected ? UiColors.white : UiColors.foreground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
DateFormat('E').format(date),
|
||||
style: UiTypography.footnote2r.copyWith(
|
||||
color: isSelected
|
||||
? UiColors.white.withValues(alpha: 0.8)
|
||||
: UiColors.textInactive,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
if (hasShift)
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? UiColors.white : UiColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
child: Opacity(
|
||||
opacity: isToday ? 1.0 : 0.4,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
DateFormat('d').format(date),
|
||||
style: UiTypography.title1m.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isSelected
|
||||
? UiColors.white
|
||||
: UiColors.foreground,
|
||||
),
|
||||
)
|
||||
else if (isToday && !isSelected)
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.border,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
DateFormat('E').format(date),
|
||||
style: UiTypography.footnote2r.copyWith(
|
||||
color: isSelected
|
||||
? UiColors.white.withValues(alpha: 0.8)
|
||||
: UiColors.textInactive,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
if (hasShift)
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? UiColors.white
|
||||
: UiColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
)
|
||||
else if (isToday && !isSelected)
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.border,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -100,11 +97,13 @@ class DateSelector extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// Helper to check if two dates are on the same calendar day (ignoring time).
|
||||
bool _isSameDay(DateTime a, DateTime b) {
|
||||
return a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
}
|
||||
|
||||
/// Formats a [DateTime] as an ISO date string (yyyy-MM-dd) for comparison with shift dates.
|
||||
String _formatDateIso(DateTime date) {
|
||||
return DateFormat('yyyy-MM-dd').format(date);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Banner shown when the user arrives too early to check in.
|
||||
///
|
||||
/// Displays a clock icon and a message indicating when check-in
|
||||
/// will become available.
|
||||
class EarlyCheckInBanner extends StatelessWidget {
|
||||
/// Creates an early check-in banner.
|
||||
const EarlyCheckInBanner({
|
||||
required this.availabilityTime,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Formatted time string when check-in becomes available (e.g. "8:45 AM").
|
||||
final String availabilityTime;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffClockInEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.bgSecondary,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
const Icon(UiIcons.clock, size: 48, color: UiColors.iconThird),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Text(
|
||||
i18n.early_title,
|
||||
style: UiTypography.body1m.textSecondary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
i18n.check_in_at(time: availabilityTime),
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Banner shown when the user tries to check out too early.
|
||||
///
|
||||
/// Displays a clock icon and a message indicating when check-out
|
||||
/// will become available.
|
||||
class EarlyCheckOutBanner extends StatelessWidget {
|
||||
/// Creates an early check-out banner.
|
||||
const EarlyCheckOutBanner({
|
||||
required this.availabilityTime,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Formatted time string when check-out becomes available (e.g. "4:45 PM").
|
||||
final String availabilityTime;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffClockInEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.bgSecondary,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
const Icon(UiIcons.clock, size: 48, color: UiColors.iconThird),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Text(
|
||||
i18n.early_checkout_title,
|
||||
style: UiTypography.body1m.textSecondary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
i18n.check_out_at(time: availabilityTime),
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 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,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// 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 UiButton.secondary(
|
||||
text: label,
|
||||
size: UiButtonSize.extraSmall,
|
||||
style: color != null
|
||||
? ButtonStyle(
|
||||
foregroundColor: WidgetStateProperty.all(color),
|
||||
side: WidgetStateProperty.all(BorderSide(color: color!)),
|
||||
shape: WidgetStateProperty.all(
|
||||
RoundedRectangleBorder(borderRadius: UiConstants.radiusMd),
|
||||
),
|
||||
)
|
||||
: ButtonStyle(
|
||||
shape: WidgetStateProperty.all(
|
||||
RoundedRectangleBorder(borderRadius: UiConstants.radiusMd),
|
||||
),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A row that displays one or two banner action buttons with consistent spacing.
|
||||
///
|
||||
/// Used by geofence failure banners to show both the primary action
|
||||
/// (e.g. "Retry", "Open Settings") and the "Clock In Anyway" override action.
|
||||
class BannerActionsRow extends StatelessWidget {
|
||||
/// Creates a [BannerActionsRow].
|
||||
const BannerActionsRow({
|
||||
required this.children,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The action buttons to display in the row.
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: UiConstants.space2,
|
||||
runSpacing: UiConstants.space2,
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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 '../../bloc/geofence/geofence_bloc.dart';
|
||||
import '../../bloc/geofence/geofence_event.dart';
|
||||
|
||||
/// Modal bottom sheet that collects a justification note before allowing
|
||||
/// a geofence-overridden clock-in.
|
||||
///
|
||||
/// The worker must provide a non-empty justification. On submit, a
|
||||
/// [CheckInRequested] event is dispatched with [isGeofenceOverridden] set
|
||||
/// to true and the justification as notes.
|
||||
class GeofenceOverrideModal extends StatefulWidget {
|
||||
/// Creates a [GeofenceOverrideModal].
|
||||
const GeofenceOverrideModal({super.key});
|
||||
|
||||
/// Shows the override modal as a bottom sheet.
|
||||
///
|
||||
/// Requires [GeofenceBloc] to be available in [context].
|
||||
static void show(BuildContext context) {
|
||||
// Capture the bloc before opening the sheet so we don't access a
|
||||
// deactivated widget's ancestor inside the builder.
|
||||
final GeofenceBloc bloc = ReadContext(context).read<GeofenceBloc>();
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(UiConstants.space4),
|
||||
),
|
||||
),
|
||||
builder: (_) => BlocProvider<GeofenceBloc>.value(
|
||||
value: bloc,
|
||||
child: const GeofenceOverrideModal(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<GeofenceOverrideModal> createState() => _GeofenceOverrideModalState();
|
||||
}
|
||||
|
||||
class _GeofenceOverrideModalState extends State<GeofenceOverrideModal> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
|
||||
/// Whether the submit button should be enabled.
|
||||
bool _hasText = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffClockInGeofenceEn i18n =
|
||||
Translations.of(context).staff.clock_in.geofence;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: UiConstants.space4,
|
||||
right: UiConstants.space4,
|
||||
top: UiConstants.space5,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + UiConstants.space4,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
Text(i18n.override_title, style: UiTypography.title1b),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
i18n.override_desc,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
UiTextField(
|
||||
hintText: i18n.override_hint,
|
||||
controller: _controller,
|
||||
maxLines: 4,
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.newline,
|
||||
onChanged: (String value) {
|
||||
final bool hasContent = value.trim().isNotEmpty;
|
||||
if (hasContent != _hasText) {
|
||||
setState(() => _hasText = hasContent);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
UiButton.primary(
|
||||
text: i18n.override_submit,
|
||||
fullWidth: true,
|
||||
onPressed: _hasText ? () => _submit(context) : null,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Stores the override justification in GeofenceBloc state (enabling the
|
||||
/// swipe slider), then closes the modal.
|
||||
void _submit(BuildContext context) {
|
||||
final String justification = _controller.text.trim();
|
||||
if (justification.isEmpty) return;
|
||||
|
||||
ReadContext(context).read<GeofenceBloc>().add(
|
||||
GeofenceOverrideApproved(notes: justification),
|
||||
);
|
||||
|
||||
Navigator.of(context).pop();
|
||||
//Modular.to.popSafe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../bloc/geofence/geofence_bloc.dart';
|
||||
import '../../bloc/geofence/geofence_state.dart';
|
||||
import 'permission_denied_banner.dart';
|
||||
import 'permission_denied_forever_banner.dart';
|
||||
import 'service_disabled_banner.dart';
|
||||
import 'overridden_banner.dart';
|
||||
import 'timeout_banner.dart';
|
||||
import 'too_far_banner.dart';
|
||||
import 'verified_banner.dart';
|
||||
import 'verifying_banner.dart';
|
||||
|
||||
/// Banner that displays the current geofence verification status.
|
||||
///
|
||||
/// Reads [GeofenceBloc] state directly and renders the appropriate
|
||||
/// banner variant based on permission, location, and verification conditions.
|
||||
class GeofenceStatusBanner extends StatelessWidget {
|
||||
/// Creates a [GeofenceStatusBanner].
|
||||
const GeofenceStatusBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<GeofenceBloc, GeofenceState>(
|
||||
builder: (BuildContext context, GeofenceState state) {
|
||||
if (state.targetLat == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return _buildBannerForState(state);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Determines which banner variant to display based on the current state.
|
||||
Widget _buildBannerForState(GeofenceState state) {
|
||||
// If the worker overrode the geofence check, show a warning banner
|
||||
// indicating location was not verified but justification was recorded.
|
||||
if (state.isGeofenceOverridden) {
|
||||
return const OverriddenBanner();
|
||||
}
|
||||
|
||||
// 1. Location services disabled.
|
||||
if (state.permissionStatus == LocationPermissionStatus.serviceDisabled ||
|
||||
(state.isLocationTimedOut && !state.isLocationServiceEnabled)) {
|
||||
return const ServiceDisabledBanner();
|
||||
}
|
||||
|
||||
// 2. Permission denied (can re-request).
|
||||
if (state.permissionStatus == LocationPermissionStatus.denied) {
|
||||
return PermissionDeniedBanner(state: state);
|
||||
}
|
||||
|
||||
// 3. Permission permanently denied.
|
||||
if (state.permissionStatus == LocationPermissionStatus.deniedForever) {
|
||||
return const PermissionDeniedForeverBanner();
|
||||
}
|
||||
|
||||
// 4. Actively verifying location.
|
||||
if (state.isVerifying) {
|
||||
return const VerifyingBanner();
|
||||
}
|
||||
|
||||
// 5. Location verified successfully.
|
||||
if (state.isLocationVerified) {
|
||||
return const VerifiedBanner();
|
||||
}
|
||||
|
||||
// 6. Timed out but location services are enabled.
|
||||
if (state.isLocationTimedOut && state.isLocationServiceEnabled) {
|
||||
return const TimeoutBanner();
|
||||
}
|
||||
|
||||
// 7. Not verified and too far away (distance known).
|
||||
if (!state.isLocationVerified &&
|
||||
!state.isLocationTimedOut &&
|
||||
state.distanceFromTarget != null) {
|
||||
return TooFarBanner(distanceMeters: state.distanceFromTarget!);
|
||||
}
|
||||
|
||||
// Default: hide banner for unmatched states.
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Banner shown when the worker has overridden the geofence check with a
|
||||
/// justification note. Displays a warning indicating location was not verified.
|
||||
class OverriddenBanner extends StatelessWidget {
|
||||
/// Creates an [OverriddenBanner].
|
||||
const OverriddenBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in.geofence;
|
||||
|
||||
return UiNoticeBanner(
|
||||
backgroundColor: UiColors.tagPending,
|
||||
icon: UiIcons.warning,
|
||||
iconColor: UiColors.textWarning,
|
||||
title: i18n.overridden_title,
|
||||
titleColor: UiColors.textWarning,
|
||||
description: i18n.overridden_desc,
|
||||
descriptionColor: UiColors.textWarning,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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 '../../bloc/geofence/geofence_bloc.dart';
|
||||
import '../../bloc/geofence/geofence_event.dart';
|
||||
import '../../bloc/geofence/geofence_state.dart';
|
||||
import 'banner_action_button.dart';
|
||||
import 'banner_actions_row.dart';
|
||||
import 'geofence_override_modal.dart';
|
||||
|
||||
/// Banner shown when location permission has been denied (can re-request).
|
||||
class PermissionDeniedBanner extends StatelessWidget {
|
||||
/// Creates a [PermissionDeniedBanner].
|
||||
const PermissionDeniedBanner({required this.state, super.key});
|
||||
|
||||
/// Current geofence state used to re-dispatch [GeofenceStarted].
|
||||
final GeofenceState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in.geofence;
|
||||
|
||||
return UiNoticeBanner(
|
||||
backgroundColor: UiColors.tagError,
|
||||
icon: UiIcons.error,
|
||||
iconColor: UiColors.textError,
|
||||
title: i18n.permission_required,
|
||||
titleColor: UiColors.textError,
|
||||
description: i18n.permission_required_desc,
|
||||
descriptionColor: UiColors.textError,
|
||||
action: BannerActionsRow(
|
||||
children: <Widget>[
|
||||
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!,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
BannerActionButton(
|
||||
label: i18n.clock_in_anyway,
|
||||
onPressed: () => GeofenceOverrideModal.show(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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 '../../../domain/services/geofence_service_interface.dart';
|
||||
import 'banner_action_button.dart';
|
||||
import 'banner_actions_row.dart';
|
||||
import 'geofence_override_modal.dart';
|
||||
|
||||
/// Banner shown when location permission has been permanently denied.
|
||||
class PermissionDeniedForeverBanner extends StatelessWidget {
|
||||
/// Creates a [PermissionDeniedForeverBanner].
|
||||
const PermissionDeniedForeverBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in.geofence;
|
||||
|
||||
return UiNoticeBanner(
|
||||
backgroundColor: UiColors.tagError,
|
||||
icon: UiIcons.error,
|
||||
iconColor: UiColors.textError,
|
||||
title: i18n.permission_denied_forever,
|
||||
titleColor: UiColors.textError,
|
||||
description: i18n.permission_denied_forever_desc,
|
||||
descriptionColor: UiColors.textError,
|
||||
action: BannerActionsRow(
|
||||
children: <Widget>[
|
||||
BannerActionButton(
|
||||
label: i18n.clock_in_anyway,
|
||||
color: UiColors.textError,
|
||||
onPressed: () => GeofenceOverrideModal.show(context),
|
||||
),
|
||||
BannerActionButton(
|
||||
label: i18n.open_settings,
|
||||
onPressed: () =>
|
||||
Modular.get<GeofenceServiceInterface>().openAppSettings(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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 '../../../domain/services/geofence_service_interface.dart';
|
||||
import 'banner_action_button.dart';
|
||||
import 'banner_actions_row.dart';
|
||||
import 'geofence_override_modal.dart';
|
||||
|
||||
/// Banner shown when device location services are disabled.
|
||||
class ServiceDisabledBanner extends StatelessWidget {
|
||||
/// Creates a [ServiceDisabledBanner].
|
||||
const ServiceDisabledBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in.geofence;
|
||||
|
||||
return UiNoticeBanner(
|
||||
backgroundColor: UiColors.tagError,
|
||||
icon: UiIcons.error,
|
||||
iconColor: UiColors.textError,
|
||||
title: i18n.service_disabled,
|
||||
titleColor: UiColors.textError,
|
||||
action: BannerActionsRow(
|
||||
children: <Widget>[
|
||||
BannerActionButton(
|
||||
label: i18n.open_settings,
|
||||
onPressed: () =>
|
||||
Modular.get<GeofenceServiceInterface>().openLocationSettings(),
|
||||
),
|
||||
BannerActionButton(
|
||||
label: i18n.clock_in_anyway,
|
||||
onPressed: () => GeofenceOverrideModal.show(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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 '../../bloc/geofence/geofence_bloc.dart';
|
||||
import '../../bloc/geofence/geofence_event.dart';
|
||||
import 'banner_action_button.dart';
|
||||
import 'banner_actions_row.dart';
|
||||
import 'geofence_override_modal.dart';
|
||||
|
||||
/// Banner shown when GPS timed out but location services are enabled.
|
||||
class TimeoutBanner extends StatelessWidget {
|
||||
/// Creates a [TimeoutBanner].
|
||||
const TimeoutBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in.geofence;
|
||||
|
||||
return UiNoticeBanner(
|
||||
backgroundColor: UiColors.tagPending,
|
||||
icon: UiIcons.warning,
|
||||
iconColor: UiColors.textWarning,
|
||||
title: i18n.timeout_title,
|
||||
titleColor: UiColors.textWarning,
|
||||
description: i18n.timeout_desc,
|
||||
descriptionColor: UiColors.textWarning,
|
||||
action: BannerActionsRow(
|
||||
children: <Widget>[
|
||||
BannerActionButton(
|
||||
label: i18n.retry,
|
||||
color: UiColors.textWarning,
|
||||
onPressed: () {
|
||||
ReadContext(context).read<GeofenceBloc>().add(
|
||||
const GeofenceRetryRequested(),
|
||||
);
|
||||
},
|
||||
),
|
||||
BannerActionButton(
|
||||
label: i18n.clock_in_anyway,
|
||||
color: UiColors.textWarning,
|
||||
onPressed: () => GeofenceOverrideModal.show(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'banner_action_button.dart';
|
||||
import 'geofence_override_modal.dart';
|
||||
|
||||
/// Banner shown when the device is outside the geofence radius.
|
||||
class TooFarBanner extends StatelessWidget {
|
||||
/// Creates a [TooFarBanner].
|
||||
const TooFarBanner({required this.distanceMeters, super.key});
|
||||
|
||||
/// Distance from the target location in meters.
|
||||
final double distanceMeters;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in.geofence;
|
||||
|
||||
return UiNoticeBanner(
|
||||
backgroundColor: UiColors.tagPending,
|
||||
icon: UiIcons.warning,
|
||||
iconColor: UiColors.textWarning,
|
||||
title: i18n.too_far_title,
|
||||
titleColor: UiColors.textWarning,
|
||||
description: i18n.too_far_desc(distance: formatDistance(distanceMeters)),
|
||||
descriptionColor: UiColors.textWarning,
|
||||
action: BannerActionButton(
|
||||
label: i18n.clock_in_anyway,
|
||||
color: UiColors.textWarning,
|
||||
onPressed: () => GeofenceOverrideModal.show(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Banner shown when the device location has been verified within range.
|
||||
class VerifiedBanner extends StatelessWidget {
|
||||
/// Creates a [VerifiedBanner].
|
||||
const VerifiedBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in.geofence;
|
||||
|
||||
return UiNoticeBanner(
|
||||
backgroundColor: UiColors.tagSuccess,
|
||||
icon: UiIcons.checkCircle,
|
||||
iconColor: UiColors.textSuccess,
|
||||
title: i18n.verified,
|
||||
titleColor: UiColors.textSuccess,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Banner shown while actively verifying the device location.
|
||||
class VerifyingBanner extends StatelessWidget {
|
||||
/// Creates a [VerifyingBanner].
|
||||
const VerifyingBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in.geofence;
|
||||
|
||||
return UiNoticeBanner(
|
||||
backgroundColor: UiColors.tagInProgress,
|
||||
iconColor: UiColors.primary,
|
||||
title: i18n.verifying,
|
||||
titleColor: UiColors.primary,
|
||||
leading: const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,16 @@ import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Dialog that collects lunch break information during the check-out flow.
|
||||
///
|
||||
/// Returns the break duration in minutes via [onComplete]. If the user
|
||||
/// indicates they did not take a lunch break, the value will be `0`.
|
||||
class LunchBreakDialog extends StatefulWidget {
|
||||
/// Creates a [LunchBreakDialog] with the required [onComplete] callback.
|
||||
const LunchBreakDialog({super.key, required this.onComplete});
|
||||
final VoidCallback onComplete;
|
||||
|
||||
/// Called when the user finishes the dialog, passing break time in minutes.
|
||||
final ValueChanged<int> onComplete;
|
||||
|
||||
@override
|
||||
State<LunchBreakDialog> createState() => _LunchBreakDialogState();
|
||||
@@ -25,6 +32,36 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
|
||||
|
||||
final List<String> _timeOptions = _generateTimeOptions();
|
||||
|
||||
/// Computes the break duration in minutes from [_breakStart] and [_breakEnd].
|
||||
///
|
||||
/// Returns `0` when the user did not take lunch or the times are invalid.
|
||||
int _computeBreakMinutes() {
|
||||
if (_tookLunch != true || _breakStart == null || _breakEnd == null) {
|
||||
return 0;
|
||||
}
|
||||
final int? startMinutes = _parseTimeToMinutes(_breakStart!);
|
||||
final int? endMinutes = _parseTimeToMinutes(_breakEnd!);
|
||||
if (startMinutes == null || endMinutes == null) return 0;
|
||||
final int diff = endMinutes - startMinutes;
|
||||
return diff > 0 ? diff : 0;
|
||||
}
|
||||
|
||||
/// Parses a time string like "12:30pm" into total minutes since midnight.
|
||||
static int? _parseTimeToMinutes(String time) {
|
||||
final String lower = time.toLowerCase().trim();
|
||||
final bool isPm = lower.endsWith('pm');
|
||||
final String cleaned = lower.replaceAll(RegExp(r'[ap]m'), '');
|
||||
final List<String> parts = cleaned.split(':');
|
||||
if (parts.length != 2) return null;
|
||||
final int? hour = int.tryParse(parts[0]);
|
||||
final int? minute = int.tryParse(parts[1]);
|
||||
if (hour == null || minute == null) return null;
|
||||
int hour24 = hour;
|
||||
if (isPm && hour != 12) hour24 += 12;
|
||||
if (!isPm && hour == 12) hour24 = 0;
|
||||
return hour24 * 60 + minute;
|
||||
}
|
||||
|
||||
static List<String> _generateTimeOptions() {
|
||||
final List<String> options = <String>[];
|
||||
for (int h = 0; h < 24; h++) {
|
||||
@@ -258,9 +295,11 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
|
||||
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
setState(() => _step = 3);
|
||||
},
|
||||
onPressed: _noLunchReason != null
|
||||
? () {
|
||||
setState(() => _step = 3);
|
||||
}
|
||||
: null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: UiColors.primary,
|
||||
minimumSize: const Size(double.infinity, 48),
|
||||
@@ -325,7 +364,7 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
ElevatedButton(
|
||||
onPressed: widget.onComplete,
|
||||
onPressed: () => widget.onComplete(_computeBreakMinutes()),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: UiColors.primary,
|
||||
minimumSize: const Size(double.infinity, 48),
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
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.
|
||||
///
|
||||
/// The dialog is non-dismissible and simulates an NFC tap with a short delay.
|
||||
/// Returns `false` if the dialog is closed without a successful scan.
|
||||
Future<bool> showNfcScanDialog(BuildContext context) async {
|
||||
final TranslationsStaffClockInEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in;
|
||||
bool scanned = false;
|
||||
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (BuildContext context, StateSetter setState) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
scanned
|
||||
? i18n.nfc_dialog.scanned_title
|
||||
: i18n.nfc_dialog.scan_title,
|
||||
),
|
||||
content: _NfcDialogContent(
|
||||
scanned: scanned,
|
||||
i18n: i18n,
|
||||
onTapToScan: () async {
|
||||
setState(() {
|
||||
scanned = true;
|
||||
});
|
||||
await Future<void>.delayed(
|
||||
const Duration(milliseconds: 1000),
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
Modular.to.popSafe();
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return scanned;
|
||||
}
|
||||
|
||||
/// Internal content widget for the NFC scan dialog.
|
||||
///
|
||||
/// Displays the scan icon/status and a tap-to-scan button.
|
||||
class _NfcDialogContent extends StatelessWidget {
|
||||
const _NfcDialogContent({
|
||||
required this.scanned,
|
||||
required this.i18n,
|
||||
required this.onTapToScan,
|
||||
});
|
||||
|
||||
/// Whether an NFC tag has been scanned.
|
||||
final bool scanned;
|
||||
|
||||
/// Localization accessor for clock-in strings.
|
||||
final TranslationsStaffClockInEn i18n;
|
||||
|
||||
/// Called when the user taps the scan button.
|
||||
final VoidCallback onTapToScan;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
decoration: BoxDecoration(
|
||||
color: scanned ? UiColors.tagSuccess : UiColors.tagInProgress,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
scanned ? UiIcons.check : UiIcons.nfc,
|
||||
size: 48,
|
||||
color: scanned ? UiColors.textSuccess : UiColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
Text(
|
||||
scanned
|
||||
? i18n.nfc_dialog.processing
|
||||
: i18n.nfc_dialog.ready_to_scan,
|
||||
style: UiTypography.headline4m,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
scanned
|
||||
? i18n.nfc_dialog.please_wait
|
||||
: i18n.nfc_dialog.scan_instruction,
|
||||
textAlign: TextAlign.center,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
if (!scanned) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: onTapToScan,
|
||||
icon: const Icon(UiIcons.nfc, size: 24),
|
||||
label: Text(
|
||||
i18n.nfc_dialog.tap_to_scan,
|
||||
style: UiTypography.headline4m.white,
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: UiColors.primary,
|
||||
foregroundColor: UiColors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Placeholder banner shown when there are no shifts scheduled for today.
|
||||
///
|
||||
/// Encourages the user to browse available shifts.
|
||||
class NoShiftsBanner extends StatelessWidget {
|
||||
/// Creates a no-shifts banner.
|
||||
const NoShiftsBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffClockInEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.bgSecondary,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
i18n.no_shifts_today,
|
||||
style: UiTypography.body1m.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
i18n.accept_shift_cta,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:krow_core/core.dart' show formatTime;
|
||||
|
||||
/// A selectable card that displays a single shift's summary information.
|
||||
///
|
||||
/// Shows the shift title, client/location, time range, and hourly rate.
|
||||
/// Highlights with a primary border when [isSelected] is true.
|
||||
class ShiftCard extends StatelessWidget {
|
||||
/// Creates a shift card for the given [shift].
|
||||
const ShiftCard({
|
||||
required this.shift,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The shift to display.
|
||||
final Shift shift;
|
||||
|
||||
/// Whether this card is currently selected.
|
||||
final bool isSelected;
|
||||
|
||||
/// Called when the user taps this card.
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffClockInEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(
|
||||
color: isSelected ? UiColors.primary : UiColors.border,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Expanded(child: _ShiftDetails(shift: shift, isSelected: isSelected, i18n: i18n)),
|
||||
_ShiftTimeAndRate(shift: shift),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays the shift title, client name, and location on the left side.
|
||||
class _ShiftDetails extends StatelessWidget {
|
||||
const _ShiftDetails({
|
||||
required this.shift,
|
||||
required this.isSelected,
|
||||
required this.i18n,
|
||||
});
|
||||
|
||||
/// The shift whose details to display.
|
||||
final Shift shift;
|
||||
|
||||
/// Whether the parent card is selected.
|
||||
final bool isSelected;
|
||||
|
||||
/// Localization accessor for clock-in strings.
|
||||
final TranslationsStaffClockInEn i18n;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
isSelected ? i18n.selected_shift_badge : i18n.today_shift_badge,
|
||||
style: UiTypography.titleUppercase4b.copyWith(
|
||||
color: isSelected ? UiColors.primary : UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(shift.title, style: UiTypography.body2b),
|
||||
Text(
|
||||
'${shift.clientName} ${shift.location}',
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays the shift time range and hourly rate on the right side.
|
||||
class _ShiftTimeAndRate extends StatelessWidget {
|
||||
const _ShiftTimeAndRate({required this.shift});
|
||||
|
||||
/// The shift whose time and rate to display.
|
||||
final Shift shift;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffClockInEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'${formatTime(shift.startTime)} - ${formatTime(shift.endTime)}',
|
||||
style: UiTypography.body3m.textSecondary,
|
||||
),
|
||||
Text(
|
||||
i18n.per_hr(amount: shift.hourlyRate),
|
||||
style: UiTypography.body3m.copyWith(color: UiColors.primary),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'shift_card.dart';
|
||||
|
||||
/// Renders a vertical list of [ShiftCard] widgets for today's shifts.
|
||||
///
|
||||
/// Highlights the currently selected shift and notifies the parent
|
||||
/// when a different shift is tapped.
|
||||
class ShiftCardList extends StatelessWidget {
|
||||
/// Creates a shift card list from [shifts].
|
||||
const ShiftCardList({
|
||||
required this.shifts,
|
||||
required this.selectedShiftId,
|
||||
required this.onShiftSelected,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// All shifts to display.
|
||||
final List<Shift> shifts;
|
||||
|
||||
/// The ID of the currently selected shift, if any.
|
||||
final String? selectedShiftId;
|
||||
|
||||
/// Called when the user taps a shift card.
|
||||
final ValueChanged<Shift> onShiftSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: shifts
|
||||
.map(
|
||||
(Shift shift) => ShiftCard(
|
||||
shift: shift,
|
||||
isSelected: shift.id == selectedShiftId,
|
||||
onTap: () => onShiftSelected(shift),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Success banner displayed after a shift has been completed.
|
||||
///
|
||||
/// Shows a check icon with congratulatory text in a green-tinted container.
|
||||
class ShiftCompletedBanner extends StatelessWidget {
|
||||
/// Creates a shift completed banner.
|
||||
const ShiftCompletedBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffClockInEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.tagSuccess,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(
|
||||
color: UiColors.success.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.tagActive,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.check,
|
||||
color: UiColors.textSuccess,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Text(i18n.shift_completed, style: UiTypography.body1b.textSuccess),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(i18n.great_work, style: UiTypography.body2r.textSuccess),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,23 +3,41 @@ import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A swipe-to-confirm slider for clock-in and clock-out actions.
|
||||
///
|
||||
/// Displays a draggable handle that the user slides to the end to confirm
|
||||
/// check-in or check-out. This widget only handles the swipe interaction;
|
||||
/// NFC mode is handled by a separate [CheckInInteraction] strategy.
|
||||
class SwipeToCheckIn extends StatefulWidget {
|
||||
/// Creates a swipe-to-check-in slider.
|
||||
const SwipeToCheckIn({
|
||||
super.key,
|
||||
this.onCheckIn,
|
||||
this.onCheckOut,
|
||||
this.isLoading = false,
|
||||
this.mode = 'swipe',
|
||||
this.isCheckedIn = false,
|
||||
this.isDisabled = false,
|
||||
this.hasClockinError = false,
|
||||
});
|
||||
|
||||
/// Called when the user completes the swipe to check in.
|
||||
final VoidCallback? onCheckIn;
|
||||
|
||||
/// Called when the user completes the swipe to check out.
|
||||
final VoidCallback? onCheckOut;
|
||||
|
||||
/// Whether a check-in/out action is currently in progress.
|
||||
final bool isLoading;
|
||||
final String mode; // 'swipe' or 'nfc'
|
||||
|
||||
/// Whether the user is currently checked in.
|
||||
final bool isCheckedIn;
|
||||
|
||||
/// Whether the slider is disabled (e.g. geofence blocking).
|
||||
final bool isDisabled;
|
||||
|
||||
/// Whether an error occurred during the last action attempt.
|
||||
final bool hasClockinError;
|
||||
|
||||
@override
|
||||
State<SwipeToCheckIn> createState() => _SwipeToCheckInState();
|
||||
}
|
||||
@@ -33,12 +51,23 @@ class _SwipeToCheckInState extends State<SwipeToCheckIn>
|
||||
@override
|
||||
void didUpdateWidget(SwipeToCheckIn oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Reset on check-in state change (successful action).
|
||||
if (widget.isCheckedIn != oldWidget.isCheckedIn) {
|
||||
setState(() {
|
||||
_isComplete = false;
|
||||
_dragValue = 0.0;
|
||||
});
|
||||
}
|
||||
// Reset on error: loading finished without state change, or validation error.
|
||||
if (_isComplete &&
|
||||
widget.isCheckedIn == oldWidget.isCheckedIn &&
|
||||
((oldWidget.isLoading && !widget.isLoading) ||
|
||||
(!oldWidget.hasClockinError && widget.hasClockinError))) {
|
||||
setState(() {
|
||||
_isComplete = false;
|
||||
_dragValue = 0.0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onDragUpdate(DragUpdateDetails details, double maxWidth) {
|
||||
@@ -60,6 +89,7 @@ class _SwipeToCheckInState extends State<SwipeToCheckIn>
|
||||
_isComplete = true;
|
||||
});
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
if (!mounted) return;
|
||||
if (widget.isCheckedIn) {
|
||||
widget.onCheckOut?.call();
|
||||
} else {
|
||||
@@ -76,57 +106,6 @@ class _SwipeToCheckInState extends State<SwipeToCheckIn>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffClockInSwipeEn i18n = Translations.of(context).staff.clock_in.swipe;
|
||||
final Color baseColor = widget.isCheckedIn
|
||||
? UiColors.success
|
||||
: UiColors.primary;
|
||||
|
||||
if (widget.mode == 'nfc') {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (widget.isLoading || widget.isDisabled) return;
|
||||
// Simulate completion for NFC tap
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
if (widget.isCheckedIn) {
|
||||
widget.onCheckOut?.call();
|
||||
} else {
|
||||
widget.onCheckIn?.call();
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.isDisabled ? UiColors.bgSecondary : baseColor,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
boxShadow: widget.isDisabled ? [] : <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: baseColor.withValues(alpha: 0.4),
|
||||
blurRadius: 25,
|
||||
offset: const Offset(0, 10),
|
||||
spreadRadius: -5,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const Icon(UiIcons.wifi, color: UiColors.white),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Text(
|
||||
widget.isLoading
|
||||
? (widget.isCheckedIn
|
||||
? i18n.checking_out
|
||||
: i18n.checking_in)
|
||||
: (widget.isCheckedIn ? i18n.nfc_checkout : i18n.nfc_checkin),
|
||||
style: UiTypography.body1b.copyWith(
|
||||
color: widget.isDisabled ? UiColors.textDisabled : UiColors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
@@ -151,13 +130,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,91 @@ 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/clock_in_notification_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 'domain/validators/validators/clock_in_validator.dart';
|
||||
import 'domain/validators/validators/composite_clock_in_validator.dart';
|
||||
import 'domain/validators/validators/geofence_validator.dart';
|
||||
import 'domain/validators/validators/override_notes_validator.dart';
|
||||
import 'domain/validators/validators/time_window_validator.dart';
|
||||
import 'presentation/bloc/clock_in/clock_in_bloc.dart';
|
||||
import 'presentation/bloc/geofence/geofence_bloc.dart';
|
||||
import 'presentation/pages/clock_in_page.dart';
|
||||
|
||||
/// Module for the staff clock-in feature.
|
||||
///
|
||||
/// Registers repositories, use cases, validators, 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>(),
|
||||
storageService: i.get<StorageService>(),
|
||||
),
|
||||
);
|
||||
|
||||
// Notification Service (clock-in / clock-out / geofence notifications)
|
||||
i.add<ClockInNotificationService>(
|
||||
() => ClockInNotificationService(
|
||||
notificationService: i.get<NotificationService>(),
|
||||
),
|
||||
);
|
||||
|
||||
// Use Cases
|
||||
i.add<GetTodaysShiftUseCase>(GetTodaysShiftUseCase.new);
|
||||
i.add<GetAttendanceStatusUseCase>(GetAttendanceStatusUseCase.new);
|
||||
i.add<ClockInUseCase>(ClockInUseCase.new);
|
||||
i.add<ClockOutUseCase>(ClockOutUseCase.new);
|
||||
|
||||
// BLoC
|
||||
i.add<ClockInBloc>(ClockInBloc.new);
|
||||
// Validators
|
||||
i.addLazySingleton<CompositeClockInValidator>(
|
||||
() => const CompositeClockInValidator(<ClockInValidator>[
|
||||
GeofenceValidator(),
|
||||
TimeWindowValidator(),
|
||||
OverrideNotesValidator(),
|
||||
]),
|
||||
);
|
||||
|
||||
// BLoCs
|
||||
// GeofenceBloc is a lazy singleton so that ClockInBloc and the widget tree
|
||||
// share the same instance within a navigation scope.
|
||||
i.addLazySingleton<GeofenceBloc>(
|
||||
() => GeofenceBloc(
|
||||
geofenceService: i.get<GeofenceServiceInterface>(),
|
||||
backgroundGeofenceService: i.get<BackgroundGeofenceService>(),
|
||||
notificationService: i.get<ClockInNotificationService>(),
|
||||
),
|
||||
);
|
||||
i.add<ClockInBloc>(
|
||||
() => ClockInBloc(
|
||||
getTodaysShift: i.get<GetTodaysShiftUseCase>(),
|
||||
getAttendanceStatus: i.get<GetAttendanceStatusUseCase>(),
|
||||
clockIn: i.get<ClockInUseCase>(),
|
||||
clockOut: i.get<ClockOutUseCase>(),
|
||||
geofenceBloc: i.get<GeofenceBloc>(),
|
||||
validator: i.get<CompositeClockInValidator>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
library;
|
||||
|
||||
export 'src/data/services/background_geofence_service.dart'
|
||||
show backgroundGeofenceDispatcher;
|
||||
export 'src/staff_clock_in_module.dart';
|
||||
export 'src/presentation/pages/clock_in_page.dart';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -155,7 +155,9 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
||||
),
|
||||
),
|
||||
ShiftDetailsHeader(shift: displayShift),
|
||||
|
||||
const Divider(height: 1, thickness: 0.5),
|
||||
|
||||
ShiftStatsRow(
|
||||
estimatedTotal: estimatedTotal,
|
||||
hourlyRate: displayShift.hourlyRate,
|
||||
@@ -164,7 +166,9 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
||||
hourlyRateLabel: i18n.hourly_rate,
|
||||
hoursLabel: i18n.hours,
|
||||
),
|
||||
|
||||
const Divider(height: 1, thickness: 0.5),
|
||||
|
||||
ShiftDateTimeSection(
|
||||
date: displayShift.date,
|
||||
endDate: displayShift.endDate,
|
||||
|
||||
@@ -43,13 +43,6 @@ class ShiftDetailsBottomBar extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
border: Border(top: BorderSide(color: UiColors.border)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: UiColors.popupShadow.withValues(alpha: 0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: _buildButtons(status, i18n, context),
|
||||
);
|
||||
|
||||
@@ -8,93 +8,67 @@ class ShiftDetailsHeader extends StatelessWidget {
|
||||
final Shift shift;
|
||||
|
||||
/// Creates a [ShiftDetailsHeader].
|
||||
const ShiftDetailsHeader({
|
||||
super.key,
|
||||
required this.shift,
|
||||
});
|
||||
const ShiftDetailsHeader({super.key, required this.shift});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
spacing: UiConstants.space4,
|
||||
children: [
|
||||
Container(
|
||||
width: 114,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primary.withAlpha(20),
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusBase,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space4,
|
||||
children: [
|
||||
// Icon + role name + client name
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
spacing: UiConstants.space4,
|
||||
children: [
|
||||
Container(
|
||||
width: 68,
|
||||
height: 68,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primary.withAlpha(20),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.primary, width: 0.5),
|
||||
),
|
||||
border: Border.all(color: UiColors.primary),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
UiIcons.briefcase,
|
||||
color: UiColors.primary,
|
||||
size: 24,
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
UiIcons.briefcase,
|
||||
color: UiColors.primary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
spacing: UiConstants.space3,
|
||||
children: [
|
||||
Text(
|
||||
shift.title,
|
||||
style: UiTypography.headline1b.textPrimary,
|
||||
),
|
||||
Column(
|
||||
spacing: UiConstants.space1,
|
||||
children: [
|
||||
// Client name
|
||||
Row(
|
||||
spacing: UiConstants.space1,
|
||||
children: [
|
||||
const Icon(
|
||||
UiIcons.building,
|
||||
size: 16,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
shift.clientName,
|
||||
style: UiTypography.body1m.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Location address (if available)
|
||||
Row(
|
||||
spacing: UiConstants.space1,
|
||||
children: [
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: 16,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
shift.locationAddress,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(shift.title, style: UiTypography.headline1b.textPrimary),
|
||||
Text(shift.clientName, style: UiTypography.body1m.textSecondary),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Location address
|
||||
Row(
|
||||
spacing: UiConstants.space1,
|
||||
children: [
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: 16,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
shift.locationAddress,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user