From 8fcf1d9d982de6ae43f09cab218a9b2535694489 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 13 Mar 2026 21:44:39 -0400 Subject: [PATCH] feat: Enhance background geofence functionality with notifications and localization support --- apps/mobile/apps/staff/lib/main.dart | 19 +-- .../notification/notification_service.dart | 13 ++ .../lib/src/l10n/en.i18n.json | 2 + .../lib/src/l10n/es.i18n.json | 2 + .../shifts_connector_repository_impl.dart | 6 +- .../widgets/hub_address_autocomplete.dart | 3 +- .../services/background_geofence_service.dart | 116 +++++++++++++++++- .../src/presentation/bloc/geofence_bloc.dart | 7 ++ .../src/presentation/bloc/geofence_event.dart | 14 ++- .../widgets/clock_in_action_section.dart | 53 ++++---- .../staff/clock_in/lib/staff_clock_in.dart | 2 + .../shift_details_bottom_bar.dart | 7 -- 12 files changed, 188 insertions(+), 56 deletions(-) diff --git a/apps/mobile/apps/staff/lib/main.dart b/apps/mobile/apps/staff/lib/main.dart index 19cd106b..34a7321e 100644 --- a/apps/mobile/apps/staff/lib/main.dart +++ b/apps/mobile/apps/staff/lib/main.dart @@ -10,31 +10,18 @@ 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 'package:workmanager/workmanager.dart'; import 'src/widgets/session_listener.dart'; -/// Top-level callback dispatcher for background tasks. -/// -/// Must be a top-level function because workmanager executes it in a separate -/// isolate where the DI container is not available. -@pragma('vm:entry-point') -void callbackDispatcher() { - Workmanager().executeTask((String task, Map? inputData) async { - // Background geofence check placeholder. - // Full implementation will parse inputData for target coordinates - // and perform a proximity check in the background isolate. - return true; - }); -} - void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); // Initialize background task processing for geofence checks - await const BackgroundTaskService().initialize(callbackDispatcher); + await const BackgroundTaskService().initialize(backgroundGeofenceDispatcher); // Register global BLoC observer for centralized error logging Bloc.observer = CoreBlocObserver( diff --git a/apps/mobile/packages/core/lib/src/services/device/notification/notification_service.dart b/apps/mobile/packages/core/lib/src/services/device/notification/notification_service.dart index d54796ab..fec59c1b 100644 --- a/apps/mobile/packages/core/lib/src/services/device/notification/notification_service.dart +++ b/apps/mobile/packages/core/lib/src/services/device/notification/notification_service.dart @@ -12,8 +12,14 @@ class NotificationService extends BaseDeviceService { /// 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 initialize() async { + if (_initialized) return; return action(() async { const AndroidInitializationSettings androidSettings = AndroidInitializationSettings( '@mipmap/ic_launcher', @@ -28,15 +34,22 @@ class NotificationService extends BaseDeviceService { iOS: iosSettings, ); await _plugin.initialize(settings: settings); + _initialized = true; }); } + /// Ensures the plugin is initialized before use. + Future _ensureInitialized() async { + if (!_initialized) await initialize(); + } + /// Displays a local notification with the given [title] and [body]. Future showNotification({ required String title, required String body, int id = 0, }) async { + await _ensureInitialized(); return action(() async { const AndroidNotificationDetails androidDetails = AndroidNotificationDetails( 'krow_geofence', diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index b7c05176..4c6ad9c3 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -947,6 +947,8 @@ "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", diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index a1c3e424..1651da22 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -942,6 +942,8 @@ "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", diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart index cb760a6f..4f6e1ed9 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart @@ -428,9 +428,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 diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart index ee196446..487c55b7 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart @@ -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); diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart index d3a8e792..eaa83136 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart @@ -1,9 +1,97 @@ +import 'package:flutter/foundation.dart'; import 'package:krow_core/core.dart'; +import 'package:workmanager/workmanager.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() { + Workmanager().executeTask( + (String task, Map? inputData) async { + debugPrint('[BackgroundGeofence] Task triggered: $task'); + debugPrint('[BackgroundGeofence] Input data: $inputData'); + debugPrint( + '[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?; + + debugPrint( + '[BackgroundGeofence] Target: lat=$targetLat, lng=$targetLng, ' + 'shiftId=$shiftId', + ); + + if (targetLat == null || targetLng == null) { + debugPrint( + '[BackgroundGeofence] Missing target coordinates, skipping check', + ); + return true; + } + + try { + const LocationService locationService = LocationService(); + final location = await locationService.getCurrentLocation(); + debugPrint( + '[BackgroundGeofence] Current position: ' + 'lat=${location.latitude}, lng=${location.longitude}', + ); + + final double distance = calculateDistance( + location.latitude, + location.longitude, + targetLat, + targetLng, + ); + debugPrint( + '[BackgroundGeofence] Distance from target: ${distance.round()}m', + ); + + if (distance > BackgroundGeofenceService.geofenceRadiusMeters) { + debugPrint( + '[BackgroundGeofence] Worker is outside geofence ' + '(${distance.round()}m > ' + '${BackgroundGeofenceService.geofenceRadiusMeters.round()}m), ' + 'showing notification', + ); + + final NotificationService notificationService = + NotificationService(); + await notificationService.showNotification( + id: BackgroundGeofenceService.leftGeofenceNotificationId, + title: "You've Left the Workplace", + body: + 'You appear to be more than 500m from your shift location.', + ); + } else { + debugPrint( + '[BackgroundGeofence] Worker is within geofence ' + '(${distance.round()}m <= ' + '${BackgroundGeofenceService.geofenceRadiusMeters.round()}m)', + ); + } + } catch (e) { + debugPrint('[BackgroundGeofence] Error during background check: $e'); + } + + debugPrint('[BackgroundGeofence] Background check completed'); + return true; + }, + ); +} /// Service that manages periodic background geofence checks while clocked in. /// -/// Uses core services exclusively -- no direct imports of workmanager, -/// flutter_local_notifications, or shared_preferences. +/// Uses core services for foreground operations. The background isolate logic +/// lives in the top-level [backgroundGeofenceDispatcher] function above. class BackgroundGeofenceService { /// The core background task service for scheduling periodic work. final BackgroundTaskService _backgroundTaskService; @@ -36,7 +124,13 @@ class BackgroundGeofenceService { static const _clockInNotificationId = 1; /// Notification ID for left-geofence warnings. - static const _leftGeofenceNotificationId = 2; + static const int leftGeofenceNotificationId = 2; + + /// Geofence radius in meters. + static const double geofenceRadiusMeters = 500; + + /// Notification ID for clock-out notifications. + static const _clockOutNotificationId = 3; /// Creates a [BackgroundGeofenceService] instance. BackgroundGeofenceService({ @@ -66,7 +160,7 @@ class BackgroundGeofenceService { await _backgroundTaskService.registerPeriodicTask( uniqueName: taskUniqueName, taskName: taskName, - frequency: const Duration(minutes: 15), + frequency: const Duration(seconds: 10), inputData: { 'targetLat': targetLat, 'targetLng': targetLng, @@ -103,7 +197,7 @@ class BackgroundGeofenceService { await _notificationService.showNotification( title: title, body: body, - id: _leftGeofenceNotificationId, + id: leftGeofenceNotificationId, ); } @@ -118,4 +212,16 @@ class BackgroundGeofenceService { id: _clockInNotificationId, ); } + + /// Shows a notification upon successful clock-out. + Future showClockOutNotification({ + required String title, + required String body, + }) async { + await _notificationService.showNotification( + title: title, + body: body, + id: _clockOutNotificationId, + ); + } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_bloc.dart index ad5154a3..afb48987 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_bloc.dart @@ -233,6 +233,13 @@ class GeofenceBloc extends Bloc emit: emit.call, action: () async { await _backgroundGeofenceService.stopBackgroundTracking(); + + // Show clock-out notification using localized strings from the UI. + await _backgroundGeofenceService.showClockOutNotification( + title: event.clockOutTitle, + body: event.clockOutBody, + ); + emit(state.copyWith(isBackgroundTrackingActive: false)); }, onError: (String errorKey) => state.copyWith( diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_event.dart index e88b8463..65454979 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_event.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_event.dart @@ -95,8 +95,20 @@ class BackgroundTrackingStarted extends GeofenceEvent { /// Stops background tracking after clock-out. class BackgroundTrackingStopped extends GeofenceEvent { + /// 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; + /// Creates a [BackgroundTrackingStopped] event. - const BackgroundTrackingStopped(); + const BackgroundTrackingStopped({ + required this.clockOutTitle, + required this.clockOutBody, + }); + + @override + List get props => [clockOutTitle, clockOutBody]; } /// Worker approved geofence override by providing justification notes. diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart index 2a03d44c..c23ae13c 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart @@ -58,11 +58,13 @@ class ClockInActionSection extends StatelessWidget { listeners: >[ // Start background tracking after successful check-in. BlocListener( - listenWhen: (ClockInState previous, ClockInState current) => - previous.status == ClockInStatus.actionInProgress && - current.status == ClockInStatus.success && - current.attendance.isCheckedIn && - !previous.attendance.isCheckedIn, + listenWhen: (ClockInState previous, ClockInState current) { + return previous.status == ClockInStatus.actionInProgress && + current.status == ClockInStatus.success && + current.attendance.isCheckedIn && + !previous.attendance.isCheckedIn; + }, + listener: (BuildContext context, ClockInState state) { _startBackgroundTracking(context, state); }, @@ -73,9 +75,14 @@ class ClockInActionSection extends StatelessWidget { previous.attendance.isCheckedIn && !current.attendance.isCheckedIn, listener: (BuildContext context, ClockInState _) { - ReadContext( - context, - ).read().add(const BackgroundTrackingStopped()); + final TranslationsStaffClockInGeofenceEn geofenceI18n = + Translations.of(context).staff.clock_in.geofence; + ReadContext(context).read().add( + BackgroundTrackingStopped( + clockOutTitle: geofenceI18n.clock_out_title, + clockOutBody: geofenceI18n.clock_out_body, + ), + ); }, ), ], @@ -98,21 +105,21 @@ class ClockInActionSection extends StatelessWidget { /// Builds the action widget for an active (not completed) shift. Widget _buildActiveShiftAction(BuildContext context) { - if (!isCheckedIn && !ClockInHelpers.isCheckInAllowed(selectedShift!)) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - const GeofenceStatusBanner(), - const SizedBox(height: UiConstants.space3), - EarlyCheckInBanner( - availabilityTime: ClockInHelpers.getCheckInAvailabilityTime( - selectedShift!, - context, - ), - ), - ], - ); - } + // if (!isCheckedIn && !ClockInHelpers.isCheckInAllowed(selectedShift!)) { + // return Column( + // mainAxisSize: MainAxisSize.min, + // children: [ + // const GeofenceStatusBanner(), + // const SizedBox(height: UiConstants.space3), + // EarlyCheckInBanner( + // availabilityTime: ClockInHelpers.getCheckInAvailabilityTime( + // selectedShift!, + // context, + // ), + // ), + // ], + // ); + // } return BlocBuilder( builder: (BuildContext context, GeofenceState geofenceState) { diff --git a/apps/mobile/packages/features/staff/clock_in/lib/staff_clock_in.dart b/apps/mobile/packages/features/staff/clock_in/lib/staff_clock_in.dart index 60e7610d..016f1414 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/staff_clock_in.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/staff_clock_in.dart @@ -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'; diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart index ccfeae3b..4ad8cba7 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart @@ -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), );