feat: Add geofence override functionality with justification modal and update banners

This commit is contained in:
Achintha Isuru
2026-03-13 17:04:40 -04:00
parent a2b102a96d
commit ab1cd8c355
12 changed files with 268 additions and 39 deletions

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -120,10 +120,12 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
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<ClockInEvent, ClockInState>
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,

View File

@@ -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<Object?> get props =>
<Object?>[shiftId, notes, isLocationVerified, isLocationTimedOut];
List<Object?> get props => <Object?>[
shiftId,
notes,
isLocationVerified,
isLocationTimedOut,
isGeofenceOverridden,
];
}
/// Emitted when the user requests to clock out.

View File

@@ -33,7 +33,11 @@ class BannerActionButton extends StatelessWidget {
RoundedRectangleBorder(borderRadius: UiConstants.radiusMd),
),
)
: null,
: ButtonStyle(
shape: WidgetStateProperty.all(
RoundedRectangleBorder(borderRadius: UiConstants.radiusMd),
),
),
onPressed: onPressed,
);
}

View File

@@ -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,
);
}
}

View File

@@ -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<void>(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(UiConstants.space4),
),
),
builder: (_) => BlocProvider<ClockInBloc>.value(
value: ReadContext(context).read<ClockInBloc>(),
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.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<ClockInBloc>().state;
final String? shiftId = clockInState.selectedShift?.id;
if (shiftId == null) return;
ReadContext(context).read<ClockInBloc>().add(
CheckInRequested(
shiftId: shiftId,
notes: justification,
isGeofenceOverridden: true,
),
);
Modular.to.popSafe();
}
}

View File

@@ -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<GeofenceBloc>().add(
GeofenceStarted(
targetLat: state.targetLat!,
targetLng: state.targetLng!,
),
);
}
},
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),
),
],
),
);
}

View File

@@ -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<GeofenceServiceInterface>().openAppSettings(),
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(),
),
],
),
);
}

View File

@@ -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<GeofenceServiceInterface>().openLocationSettings(),
action: BannerActionsRow(
children: <Widget>[
BannerActionButton(
label: i18n.open_settings,
onPressed: () =>
Modular.get<GeofenceServiceInterface>().openLocationSettings(),
),
BannerActionButton(
label: i18n.clock_in_anyway,
onPressed: () => GeofenceOverrideModal.show(context),
),
],
),
);
}

View File

@@ -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<GeofenceBloc>().add(
const GeofenceRetryRequested(),
);
},
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),
),
],
),
);
}

View File

@@ -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),
),
);
}
}