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 8a18acea..5bcb0e57 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 @@ -949,7 +949,12 @@ "background_left_body": "You appear to be more than 500m from your shift location.", "always_permission_title": "Background Location Needed", "always_permission_desc": "To verify your location during shifts, please allow location access 'Always'.", - "retry": "Retry" + "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" } }, "availability": { 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 99c5e947..9f101f12 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 @@ -944,7 +944,12 @@ "background_left_body": "Parece que está a más de 500m de la ubicación de su turno.", "always_permission_title": "Se Necesita Ubicación en Segundo Plano", "always_permission_desc": "Para verificar su ubicación durante los turnos, permita el acceso a la ubicación 'Siempre'.", - "retry": "Reintentar" + "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" } }, "availability": { diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart index 3a87d7f5..c30703f5 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart @@ -120,10 +120,12 @@ class ClockInBloc extends Bloc shift != null && shift.latitude != null && shift.longitude != null; // If the shift requires location verification but geofence has not - // confirmed proximity and has not timed out, reject the attempt. + // confirmed proximity, has not timed out, and the worker has not + // explicitly overridden via the justification modal, reject the attempt. if (shiftHasLocation && !event.isLocationVerified && - !event.isLocationTimedOut) { + !event.isLocationTimedOut && + !event.isGeofenceOverridden) { emit(state.copyWith( status: ClockInStatus.failure, errorMessage: 'errors.clock_in.location_verification_required', @@ -131,9 +133,10 @@ class ClockInBloc extends Bloc return; } - // When location timed out, require the user to provide notes explaining - // why they are clocking in without verified proximity. - if (event.isLocationTimedOut && + // When location timed out or geofence is overridden, require the user to + // provide notes explaining why they are clocking in without verified + // proximity. + if ((event.isLocationTimedOut || event.isGeofenceOverridden) && (event.notes == null || event.notes!.trim().isEmpty)) { emit(state.copyWith( status: ClockInStatus.failure, diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart index 39545d9f..181ed372 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart @@ -44,6 +44,7 @@ class CheckInRequested extends ClockInEvent { this.notes, this.isLocationVerified = false, this.isLocationTimedOut = false, + this.isGeofenceOverridden = false, }); /// The ID of the shift to clock into. @@ -58,9 +59,17 @@ class CheckInRequested extends ClockInEvent { /// Whether the geofence verification timed out (GPS unavailable). final bool isLocationTimedOut; + /// Whether the worker explicitly overrode geofence via the justification modal. + final bool isGeofenceOverridden; + @override - List get props => - [shiftId, notes, isLocationVerified, isLocationTimedOut]; + List get props => [ + shiftId, + notes, + isLocationVerified, + isLocationTimedOut, + isGeofenceOverridden, + ]; } /// Emitted when the user requests to clock out. diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/banner_action_button.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/banner_action_button.dart index c06362f3..74f74e90 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/banner_action_button.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/banner_action_button.dart @@ -33,7 +33,11 @@ class BannerActionButton extends StatelessWidget { RoundedRectangleBorder(borderRadius: UiConstants.radiusMd), ), ) - : null, + : ButtonStyle( + shape: WidgetStateProperty.all( + RoundedRectangleBorder(borderRadius: UiConstants.radiusMd), + ), + ), onPressed: onPressed, ); } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/banner_actions_row.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/banner_actions_row.dart new file mode 100644 index 00000000..0a76c97f --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/banner_actions_row.dart @@ -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 children; + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: UiConstants.space2, + runSpacing: UiConstants.space2, + children: children, + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart new file mode 100644 index 00000000..1164261b --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart @@ -0,0 +1,128 @@ +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/clock_in_bloc.dart'; +import '../../bloc/clock_in_event.dart'; +import '../../bloc/clock_in_state.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 [ClockInBloc] to be available in [context]. + static void show(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(UiConstants.space4), + ), + ), + builder: (_) => BlocProvider.value( + value: ReadContext(context).read(), + child: const GeofenceOverrideModal(), + ), + ); + } + + @override + State createState() => _GeofenceOverrideModalState(); +} + +class _GeofenceOverrideModalState extends State { + 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: [ + Text(i18n.override_title, style: UiTypography.title1b), + const SizedBox(height: UiConstants.space2), + Text( + i18n.override_desc, + style: UiTypography.body2r.copyWith( + color: UiColors.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), + ], + ), + ); + } + + /// Dispatches the clock-in event with the override flag and justification, + /// then closes the modal. + void _submit(BuildContext context) { + final String justification = _controller.text.trim(); + if (justification.isEmpty) return; + + final ClockInState clockInState = + ReadContext(context).read().state; + final String? shiftId = clockInState.selectedShift?.id; + if (shiftId == null) return; + + ReadContext(context).read().add( + CheckInRequested( + shiftId: shiftId, + notes: justification, + isGeofenceOverridden: true, + ), + ); + + Modular.to.popSafe(); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/permission_denied_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/permission_denied_banner.dart index 8624e192..87333c44 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/permission_denied_banner.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/permission_denied_banner.dart @@ -7,6 +7,8 @@ import '../../bloc/geofence_bloc.dart'; import '../../bloc/geofence_event.dart'; import '../../bloc/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 { @@ -30,18 +32,26 @@ class PermissionDeniedBanner extends StatelessWidget { titleColor: UiColors.textError, description: i18n.permission_required_desc, descriptionColor: UiColors.textError, - action: BannerActionButton( - label: i18n.grant_permission, - onPressed: () { - if (state.targetLat != null && state.targetLng != null) { - ReadContext(context).read().add( - GeofenceStarted( - targetLat: state.targetLat!, - targetLng: state.targetLng!, - ), - ); - } - }, + action: BannerActionsRow( + children: [ + BannerActionButton( + label: i18n.grant_permission, + onPressed: () { + if (state.targetLat != null && state.targetLng != null) { + ReadContext(context).read().add( + GeofenceStarted( + targetLat: state.targetLat!, + targetLng: state.targetLng!, + ), + ); + } + }, + ), + BannerActionButton( + label: i18n.clock_in_anyway, + onPressed: () => GeofenceOverrideModal.show(context), + ), + ], ), ); } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/permission_denied_forever_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/permission_denied_forever_banner.dart index 11e8463a..7cc4a157 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/permission_denied_forever_banner.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/permission_denied_forever_banner.dart @@ -5,6 +5,8 @@ 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 { @@ -25,11 +27,19 @@ class PermissionDeniedForeverBanner extends StatelessWidget { titleColor: UiColors.textError, description: i18n.permission_denied_forever_desc, descriptionColor: UiColors.textError, - action: BannerActionButton( - label: i18n.open_settings, - color: UiColors.textError, - onPressed: () => - Modular.get().openAppSettings(), + action: BannerActionsRow( + children: [ + BannerActionButton( + label: i18n.clock_in_anyway, + color: UiColors.textError, + onPressed: () => GeofenceOverrideModal.show(context), + ), + BannerActionButton( + label: i18n.open_settings, + onPressed: () => + Modular.get().openAppSettings(), + ), + ], ), ); } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/service_disabled_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/service_disabled_banner.dart index 6494150b..687de2ad 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/service_disabled_banner.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/service_disabled_banner.dart @@ -5,6 +5,8 @@ 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 { @@ -23,10 +25,18 @@ class ServiceDisabledBanner extends StatelessWidget { iconColor: UiColors.textError, title: i18n.service_disabled, titleColor: UiColors.textError, - action: BannerActionButton( - label: i18n.open_settings, - onPressed: () => - Modular.get().openLocationSettings(), + action: BannerActionsRow( + children: [ + BannerActionButton( + label: i18n.open_settings, + onPressed: () => + Modular.get().openLocationSettings(), + ), + BannerActionButton( + label: i18n.clock_in_anyway, + onPressed: () => GeofenceOverrideModal.show(context), + ), + ], ), ); } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/timeout_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/timeout_banner.dart index c89a77a6..0977f8fb 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/timeout_banner.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/timeout_banner.dart @@ -6,6 +6,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../bloc/geofence_bloc.dart'; import '../../bloc/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 { @@ -26,14 +28,23 @@ class TimeoutBanner extends StatelessWidget { titleColor: UiColors.textWarning, description: i18n.timeout_desc, descriptionColor: UiColors.textWarning, - action: BannerActionButton( - label: i18n.retry, - color: UiColors.textWarning, - onPressed: () { - ReadContext(context).read().add( - const GeofenceRetryRequested(), - ); - }, + action: BannerActionsRow( + children: [ + BannerActionButton( + label: i18n.retry, + color: UiColors.textWarning, + onPressed: () { + ReadContext(context).read().add( + const GeofenceRetryRequested(), + ); + }, + ), + BannerActionButton( + label: i18n.clock_in_anyway, + color: UiColors.textWarning, + onPressed: () => GeofenceOverrideModal.show(context), + ), + ], ), ); } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/too_far_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/too_far_banner.dart index 79551f50..b6c5c56a 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/too_far_banner.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/too_far_banner.dart @@ -3,6 +3,9 @@ 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]. @@ -25,6 +28,11 @@ class TooFarBanner extends StatelessWidget { 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), + ), ); } }