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.",
|
"background_left_body": "You appear to be more than 500m from your shift location.",
|
||||||
"always_permission_title": "Background Location Needed",
|
"always_permission_title": "Background Location Needed",
|
||||||
"always_permission_desc": "To verify your location during shifts, please allow location access 'Always'.",
|
"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": {
|
"availability": {
|
||||||
|
|||||||
@@ -944,7 +944,12 @@
|
|||||||
"background_left_body": "Parece que está a más de 500m de la ubicación de su turno.",
|
"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_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'.",
|
"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": {
|
"availability": {
|
||||||
|
|||||||
@@ -120,10 +120,12 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
|||||||
shift != null && shift.latitude != null && shift.longitude != null;
|
shift != null && shift.latitude != null && shift.longitude != null;
|
||||||
|
|
||||||
// If the shift requires location verification but geofence has not
|
// 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 &&
|
if (shiftHasLocation &&
|
||||||
!event.isLocationVerified &&
|
!event.isLocationVerified &&
|
||||||
!event.isLocationTimedOut) {
|
!event.isLocationTimedOut &&
|
||||||
|
!event.isGeofenceOverridden) {
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
status: ClockInStatus.failure,
|
status: ClockInStatus.failure,
|
||||||
errorMessage: 'errors.clock_in.location_verification_required',
|
errorMessage: 'errors.clock_in.location_verification_required',
|
||||||
@@ -131,9 +133,10 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// When location timed out, require the user to provide notes explaining
|
// When location timed out or geofence is overridden, require the user to
|
||||||
// why they are clocking in without verified proximity.
|
// provide notes explaining why they are clocking in without verified
|
||||||
if (event.isLocationTimedOut &&
|
// proximity.
|
||||||
|
if ((event.isLocationTimedOut || event.isGeofenceOverridden) &&
|
||||||
(event.notes == null || event.notes!.trim().isEmpty)) {
|
(event.notes == null || event.notes!.trim().isEmpty)) {
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
status: ClockInStatus.failure,
|
status: ClockInStatus.failure,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class CheckInRequested extends ClockInEvent {
|
|||||||
this.notes,
|
this.notes,
|
||||||
this.isLocationVerified = false,
|
this.isLocationVerified = false,
|
||||||
this.isLocationTimedOut = false,
|
this.isLocationTimedOut = false,
|
||||||
|
this.isGeofenceOverridden = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The ID of the shift to clock into.
|
/// The ID of the shift to clock into.
|
||||||
@@ -58,9 +59,17 @@ class CheckInRequested extends ClockInEvent {
|
|||||||
/// Whether the geofence verification timed out (GPS unavailable).
|
/// Whether the geofence verification timed out (GPS unavailable).
|
||||||
final bool isLocationTimedOut;
|
final bool isLocationTimedOut;
|
||||||
|
|
||||||
|
/// Whether the worker explicitly overrode geofence via the justification modal.
|
||||||
|
final bool isGeofenceOverridden;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props =>
|
List<Object?> get props => <Object?>[
|
||||||
<Object?>[shiftId, notes, isLocationVerified, isLocationTimedOut];
|
shiftId,
|
||||||
|
notes,
|
||||||
|
isLocationVerified,
|
||||||
|
isLocationTimedOut,
|
||||||
|
isGeofenceOverridden,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emitted when the user requests to clock out.
|
/// Emitted when the user requests to clock out.
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ class BannerActionButton extends StatelessWidget {
|
|||||||
RoundedRectangleBorder(borderRadius: UiConstants.radiusMd),
|
RoundedRectangleBorder(borderRadius: UiConstants.radiusMd),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: ButtonStyle(
|
||||||
|
shape: WidgetStateProperty.all(
|
||||||
|
RoundedRectangleBorder(borderRadius: UiConstants.radiusMd),
|
||||||
|
),
|
||||||
|
),
|
||||||
onPressed: onPressed,
|
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_event.dart';
|
||||||
import '../../bloc/geofence_state.dart';
|
import '../../bloc/geofence_state.dart';
|
||||||
import 'banner_action_button.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).
|
/// Banner shown when location permission has been denied (can re-request).
|
||||||
class PermissionDeniedBanner extends StatelessWidget {
|
class PermissionDeniedBanner extends StatelessWidget {
|
||||||
@@ -30,18 +32,26 @@ class PermissionDeniedBanner extends StatelessWidget {
|
|||||||
titleColor: UiColors.textError,
|
titleColor: UiColors.textError,
|
||||||
description: i18n.permission_required_desc,
|
description: i18n.permission_required_desc,
|
||||||
descriptionColor: UiColors.textError,
|
descriptionColor: UiColors.textError,
|
||||||
action: BannerActionButton(
|
action: BannerActionsRow(
|
||||||
label: i18n.grant_permission,
|
children: <Widget>[
|
||||||
onPressed: () {
|
BannerActionButton(
|
||||||
if (state.targetLat != null && state.targetLng != null) {
|
label: i18n.grant_permission,
|
||||||
ReadContext(context).read<GeofenceBloc>().add(
|
onPressed: () {
|
||||||
GeofenceStarted(
|
if (state.targetLat != null && state.targetLng != null) {
|
||||||
targetLat: state.targetLat!,
|
ReadContext(context).read<GeofenceBloc>().add(
|
||||||
targetLng: state.targetLng!,
|
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 '../../../domain/services/geofence_service_interface.dart';
|
||||||
import 'banner_action_button.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.
|
/// Banner shown when location permission has been permanently denied.
|
||||||
class PermissionDeniedForeverBanner extends StatelessWidget {
|
class PermissionDeniedForeverBanner extends StatelessWidget {
|
||||||
@@ -25,11 +27,19 @@ class PermissionDeniedForeverBanner extends StatelessWidget {
|
|||||||
titleColor: UiColors.textError,
|
titleColor: UiColors.textError,
|
||||||
description: i18n.permission_denied_forever_desc,
|
description: i18n.permission_denied_forever_desc,
|
||||||
descriptionColor: UiColors.textError,
|
descriptionColor: UiColors.textError,
|
||||||
action: BannerActionButton(
|
action: BannerActionsRow(
|
||||||
label: i18n.open_settings,
|
children: <Widget>[
|
||||||
color: UiColors.textError,
|
BannerActionButton(
|
||||||
onPressed: () =>
|
label: i18n.clock_in_anyway,
|
||||||
Modular.get<GeofenceServiceInterface>().openAppSettings(),
|
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 '../../../domain/services/geofence_service_interface.dart';
|
||||||
import 'banner_action_button.dart';
|
import 'banner_action_button.dart';
|
||||||
|
import 'banner_actions_row.dart';
|
||||||
|
import 'geofence_override_modal.dart';
|
||||||
|
|
||||||
/// Banner shown when device location services are disabled.
|
/// Banner shown when device location services are disabled.
|
||||||
class ServiceDisabledBanner extends StatelessWidget {
|
class ServiceDisabledBanner extends StatelessWidget {
|
||||||
@@ -23,10 +25,18 @@ class ServiceDisabledBanner extends StatelessWidget {
|
|||||||
iconColor: UiColors.textError,
|
iconColor: UiColors.textError,
|
||||||
title: i18n.service_disabled,
|
title: i18n.service_disabled,
|
||||||
titleColor: UiColors.textError,
|
titleColor: UiColors.textError,
|
||||||
action: BannerActionButton(
|
action: BannerActionsRow(
|
||||||
label: i18n.open_settings,
|
children: <Widget>[
|
||||||
onPressed: () =>
|
BannerActionButton(
|
||||||
Modular.get<GeofenceServiceInterface>().openLocationSettings(),
|
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_bloc.dart';
|
||||||
import '../../bloc/geofence_event.dart';
|
import '../../bloc/geofence_event.dart';
|
||||||
import 'banner_action_button.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.
|
/// Banner shown when GPS timed out but location services are enabled.
|
||||||
class TimeoutBanner extends StatelessWidget {
|
class TimeoutBanner extends StatelessWidget {
|
||||||
@@ -26,14 +28,23 @@ class TimeoutBanner extends StatelessWidget {
|
|||||||
titleColor: UiColors.textWarning,
|
titleColor: UiColors.textWarning,
|
||||||
description: i18n.timeout_desc,
|
description: i18n.timeout_desc,
|
||||||
descriptionColor: UiColors.textWarning,
|
descriptionColor: UiColors.textWarning,
|
||||||
action: BannerActionButton(
|
action: BannerActionsRow(
|
||||||
label: i18n.retry,
|
children: <Widget>[
|
||||||
color: UiColors.textWarning,
|
BannerActionButton(
|
||||||
onPressed: () {
|
label: i18n.retry,
|
||||||
ReadContext(context).read<GeofenceBloc>().add(
|
color: UiColors.textWarning,
|
||||||
const GeofenceRetryRequested(),
|
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:flutter/material.dart';
|
||||||
import 'package:krow_core/core.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.
|
/// Banner shown when the device is outside the geofence radius.
|
||||||
class TooFarBanner extends StatelessWidget {
|
class TooFarBanner extends StatelessWidget {
|
||||||
/// Creates a [TooFarBanner].
|
/// Creates a [TooFarBanner].
|
||||||
@@ -25,6 +28,11 @@ class TooFarBanner extends StatelessWidget {
|
|||||||
titleColor: UiColors.textWarning,
|
titleColor: UiColors.textWarning,
|
||||||
description: i18n.too_far_desc(distance: formatDistance(distanceMeters)),
|
description: i18n.too_far_desc(distance: formatDistance(distanceMeters)),
|
||||||
descriptionColor: UiColors.textWarning,
|
descriptionColor: UiColors.textWarning,
|
||||||
|
action: BannerActionButton(
|
||||||
|
label: i18n.clock_in_anyway,
|
||||||
|
color: UiColors.textWarning,
|
||||||
|
onPressed: () => GeofenceOverrideModal.show(context),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user