feat: Add geofence override functionality with justification modal and update banners
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -33,7 +33,11 @@ class BannerActionButton extends StatelessWidget {
|
||||
RoundedRectangleBorder(borderRadius: UiConstants.radiusMd),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
: 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,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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user