feat: Enhance geofence functionality with new status banners and utility functions

This commit is contained in:
Achintha Isuru
2026-03-13 16:34:09 -04:00
parent 7b576c0ed4
commit accff00155
18 changed files with 439 additions and 361 deletions

View File

@@ -47,6 +47,7 @@ If any of these files are missing or unreadable, notify the user before proceedi
- Skip tests for business logic
### ALWAYS:
- Place reusable utility functions (math, geo, formatting, etc.) in `apps/mobile/packages/core/lib/src/utils/` and export from `core.dart` — never keep them as private methods in feature packages
- Use feature-first packaging: `domain/`, `data/`, `presentation/`
- Export public API via barrel files
- Use BLoC with `SessionHandlerMixin` for complex state

View File

@@ -5,6 +5,7 @@ export 'src/core_module.dart';
export 'src/domain/arguments/usecase_argument.dart';
export 'src/domain/usecases/usecase.dart';
export 'src/utils/date_time_utils.dart';
export 'src/utils/geo_utils.dart';
export 'src/presentation/widgets/web_mobile_frame.dart';
export 'src/presentation/mixins/bloc_error_handler.dart';
export 'src/presentation/observers/core_bloc_observer.dart';

View File

@@ -0,0 +1,32 @@
import 'dart:math';
/// Calculates the distance in meters between two geographic coordinates
/// using the Haversine formula.
double calculateDistance(
double lat1,
double lng1,
double lat2,
double lng2,
) {
const double earthRadius = 6371000.0;
final double dLat = _toRadians(lat2 - lat1);
final double dLng = _toRadians(lng2 - lng1);
final double a = sin(dLat / 2) * sin(dLat / 2) +
cos(_toRadians(lat1)) *
cos(_toRadians(lat2)) *
sin(dLng / 2) *
sin(dLng / 2);
final double c = 2 * atan2(sqrt(a), sqrt(1 - a));
return earthRadius * c;
}
/// Formats a distance in meters to a human-readable string.
String formatDistance(double meters) {
if (meters >= 1000) {
return '${(meters / 1000).toStringAsFixed(1)} km';
}
return '${meters.round()} m';
}
/// Converts degrees to radians.
double _toRadians(double degrees) => degrees * pi / 180;

View File

@@ -930,7 +930,9 @@
"geofence": {
"service_disabled": "Location services are turned off. Enable them to clock in.",
"permission_required": "Location permission is required to clock in.",
"permission_denied_forever": "Location was permanently denied. Enable it in Settings.",
"permission_required_desc": "Grant location permission to verify you're at the workplace when clocking in.",
"permission_denied_forever": "Location was permanently denied.",
"permission_denied_forever_desc": "Grant location permission in your device settings to verify you're at the workplace when clocking in.",
"open_settings": "Open Settings",
"grant_permission": "Grant Permission",
"verifying": "Verifying your location...",

View File

@@ -925,7 +925,9 @@
"geofence": {
"service_disabled": "Los servicios de ubicación están desactivados. Actívelos para registrar entrada.",
"permission_required": "Se requiere permiso de ubicación para registrar entrada.",
"permission_denied_forever": "La ubicación fue denegada permanentemente. Actívela en Configuración.",
"permission_required_desc": "Otorgue permiso de ubicación para verificar que está en el lugar de trabajo al registrar entrada.",
"permission_denied_forever": "La ubicación fue denegada permanentemente.",
"permission_denied_forever_desc": "Otorgue permiso de ubicación en la configuración de su dispositivo para verificar que está en el lugar de trabajo al registrar entrada.",
"open_settings": "Abrir Configuración",
"grant_permission": "Otorgar Permiso",
"verifying": "Verificando su ubicación...",

View File

@@ -15,18 +15,32 @@ class UiNoticeBanner extends StatelessWidget {
this.backgroundColor,
this.borderRadius,
this.padding,
this.iconColor,
this.titleColor,
this.descriptionColor,
this.action,
this.leading,
});
/// The icon to display on the left side.
/// Defaults to null. The icon will be rendered with primary color and 24pt size.
/// Ignored when [leading] is provided.
final IconData? icon;
/// Custom color for the icon. Defaults to [UiColors.primary].
final Color? iconColor;
/// The title text to display.
final String title;
/// Custom color for the title text. Defaults to primary text color.
final Color? titleColor;
/// Optional description text to display below the title.
final String? description;
/// Custom color for the description text. Defaults to secondary text color.
final Color? descriptionColor;
/// The background color of the banner.
/// Defaults to [UiColors.primary] with 8% opacity.
final Color? backgroundColor;
@@ -39,6 +53,12 @@ class UiNoticeBanner extends StatelessWidget {
/// Defaults to [UiConstants.space4] on all sides.
final EdgeInsetsGeometry? padding;
/// Optional action widget displayed on the right side of the banner.
final Widget? action;
/// Optional custom leading widget that replaces the icon when provided.
final Widget? leading;
@override
Widget build(BuildContext context) {
return Container(
@@ -50,8 +70,11 @@ class UiNoticeBanner extends StatelessWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (icon != null) ...<Widget>[
Icon(icon, color: UiColors.primary, size: 24),
if (leading != null) ...<Widget>[
leading!,
const SizedBox(width: UiConstants.space3),
] else if (icon != null) ...<Widget>[
Icon(icon, color: iconColor ?? UiColors.primary, size: 24),
const SizedBox(width: UiConstants.space3),
],
Expanded(
@@ -60,18 +83,24 @@ class UiNoticeBanner extends StatelessWidget {
children: <Widget>[
Text(
title,
style: UiTypography.body2m.textPrimary,
style: UiTypography.body2b.copyWith(color: titleColor),
),
if (description != null) ...<Widget>[
const SizedBox(height: 2),
Text(
description!,
style: UiTypography.body2r.textSecondary,
style: UiTypography.body3r.copyWith(
color: descriptionColor,
),
),
],
],
),
),
if (action != null) ...<Widget>[
const SizedBox(width: UiConstants.space3),
action!,
],
],
),
);

View File

@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:math';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
@@ -9,6 +8,12 @@ import '../../domain/services/geofence_service_interface.dart';
/// Implementation of [GeofenceServiceInterface] using core [LocationService].
class GeofenceServiceImpl implements GeofenceServiceInterface {
/// Creates a [GeofenceServiceImpl] instance.
GeofenceServiceImpl({
required LocationService locationService,
this.debugAlwaysInRange = false,
}) : _locationService = locationService;
/// The core location service for device GPS access.
final LocationService _locationService;
@@ -18,12 +23,6 @@ class GeofenceServiceImpl implements GeofenceServiceInterface {
/// Average walking speed in meters per minute for ETA estimation.
static const double _walkingSpeedMetersPerMinute = 80;
/// Creates a [GeofenceServiceImpl] instance.
GeofenceServiceImpl({
required LocationService locationService,
this.debugAlwaysInRange = false,
}) : _locationService = locationService;
@override
Future<LocationPermissionStatus> ensurePermission() {
return _locationService.checkAndRequestPermission();
@@ -93,7 +92,7 @@ class GeofenceServiceImpl implements GeofenceServiceInterface {
required double targetLng,
required double radiusMeters,
}) {
final distance = _calculateDistance(
final distance = calculateDistance(
location.latitude,
location.longitude,
targetLat,
@@ -112,25 +111,4 @@ class GeofenceServiceImpl implements GeofenceServiceInterface {
);
}
/// Haversine formula for distance between two coordinates in meters.
double _calculateDistance(
double lat1,
double lng1,
double lat2,
double lng2,
) {
const earthRadius = 6371000.0;
final dLat = _toRadians(lat2 - lat1);
final dLng = _toRadians(lng2 - lng1);
final a = sin(dLat / 2) * sin(dLat / 2) +
cos(_toRadians(lat1)) *
cos(_toRadians(lat2)) *
sin(dLng / 2) *
sin(dLng / 2);
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
return earthRadius * c;
}
/// Converts degrees to radians.
double _toRadians(double degrees) => degrees * pi / 180;
}

View File

@@ -14,7 +14,7 @@ import '../bloc/geofence_event.dart';
import '../bloc/geofence_state.dart';
import 'clock_in_helpers.dart';
import 'early_check_in_banner.dart';
import 'geofence_status_banner.dart';
import 'geofence_status_banner/geofence_status_banner.dart';
import 'lunch_break_modal.dart';
import 'nfc_scan_dialog.dart';
import 'no_shifts_banner.dart';

View File

@@ -1,324 +0,0 @@
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_domain/krow_domain.dart';
import '../../domain/services/geofence_service_interface.dart';
import '../bloc/geofence_bloc.dart';
import '../bloc/geofence_event.dart';
import '../bloc/geofence_state.dart';
/// Banner that displays the current geofence verification status.
///
/// Reads [GeofenceBloc] state directly and renders the appropriate
/// status message with action buttons based on permission, location,
/// and verification conditions.
class GeofenceStatusBanner extends StatelessWidget {
/// Creates a [GeofenceStatusBanner].
const GeofenceStatusBanner({super.key});
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
context,
).staff.clock_in.geofence;
return BlocBuilder<GeofenceBloc, GeofenceState>(
builder: (BuildContext context, GeofenceState state) {
// Hide banner when no target coordinates are set.
if (state.targetLat == null) {
return const SizedBox.shrink();
}
return _buildBannerForState(context, state, i18n);
},
);
}
/// Determines which banner variant to display based on the current state.
Widget _buildBannerForState(
BuildContext context,
GeofenceState state,
TranslationsStaffClockInGeofenceEn i18n,
) {
// 1. Location services disabled.
if (state.permissionStatus == LocationPermissionStatus.serviceDisabled ||
(state.isLocationTimedOut && !state.isLocationServiceEnabled)) {
return _BannerContainer(
backgroundColor: UiColors.tagError,
borderColor: UiColors.error,
icon: UiIcons.error,
iconColor: UiColors.textError,
title: i18n.service_disabled,
titleStyle: UiTypography.body3m.textError,
action: _BannerActionButton(
label: i18n.open_settings,
onPressed: () => _openLocationSettings(),
),
);
}
// 2. Permission denied (can re-request).
if (state.permissionStatus == LocationPermissionStatus.denied) {
return _BannerContainer(
backgroundColor: UiColors.tagError,
borderColor: UiColors.error,
icon: UiIcons.error,
iconColor: UiColors.textError,
title: i18n.permission_required,
titleStyle: UiTypography.body3m.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!,
),
);
}
},
),
);
}
// 3. Permission permanently denied.
if (state.permissionStatus == LocationPermissionStatus.deniedForever) {
return _BannerContainer(
backgroundColor: UiColors.tagError,
borderColor: UiColors.error,
icon: UiIcons.error,
iconColor: UiColors.textError,
title: i18n.permission_denied_forever,
titleStyle: UiTypography.body3m.textError,
action: _BannerActionButton(
label: i18n.open_settings,
onPressed: () => _openAppSettings(),
),
);
}
// 4. Actively verifying location.
if (state.isVerifying) {
return _BannerContainer(
backgroundColor: UiColors.tagInProgress,
borderColor: UiColors.primary,
icon: null,
iconColor: UiColors.primary,
title: i18n.verifying,
titleStyle: UiTypography.body3m.primary,
leading: const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: UiColors.primary,
),
),
);
}
// 5. Location verified successfully.
if (state.isLocationVerified) {
return _BannerContainer(
backgroundColor: UiColors.tagSuccess,
borderColor: UiColors.success,
icon: UiIcons.checkCircle,
iconColor: UiColors.textSuccess,
title: i18n.verified,
titleStyle: UiTypography.body3m.textSuccess,
);
}
// 6. Timed out but location services are enabled.
if (state.isLocationTimedOut && state.isLocationServiceEnabled) {
return _BannerContainer(
backgroundColor: UiColors.tagPending,
borderColor: UiColors.textWarning,
icon: UiIcons.warning,
iconColor: UiColors.textWarning,
title: i18n.timeout_title,
titleStyle: UiTypography.body3m.textWarning,
subtitle: i18n.timeout_desc,
subtitleStyle: UiTypography.body3r.textWarning,
action: _BannerActionButton(
label: i18n.retry,
color: UiColors.textWarning,
onPressed: () {
ReadContext(
context,
).read<GeofenceBloc>().add(const GeofenceRetryRequested());
},
),
);
}
// 7. Not verified and too far away (distance known).
if (!state.isLocationVerified &&
!state.isLocationTimedOut &&
state.distanceFromTarget != null) {
return _BannerContainer(
backgroundColor: UiColors.tagPending,
borderColor: UiColors.textWarning,
icon: UiIcons.warning,
iconColor: UiColors.textWarning,
title: i18n.too_far_title,
titleStyle: UiTypography.body3m.textWarning,
subtitle: i18n.too_far_desc(
distance: _formatDistance(state.distanceFromTarget!),
),
subtitleStyle: UiTypography.body3r.textWarning,
);
}
// Default: hide banner for unmatched states.
return const SizedBox.shrink();
}
/// Opens the device location settings via the geofence service.
void _openLocationSettings() {
Modular.get<GeofenceServiceInterface>().openLocationSettings();
}
/// Opens the app settings page via the geofence service.
void _openAppSettings() {
Modular.get<GeofenceServiceInterface>().openAppSettings();
}
/// Formats a distance in meters to a human-readable string.
String _formatDistance(double meters) {
if (meters >= 1000) {
return '${(meters / 1000).toStringAsFixed(1)} km';
}
return '${meters.round()} m';
}
}
/// Internal container widget that provides consistent banner styling.
///
/// Renders a rounded container with an icon (or custom leading widget),
/// title/subtitle text, and an optional action button.
class _BannerContainer extends StatelessWidget {
/// Creates a [_BannerContainer].
const _BannerContainer({
required this.backgroundColor,
required this.borderColor,
required this.icon,
required this.iconColor,
required this.title,
required this.titleStyle,
this.subtitle,
this.subtitleStyle,
this.action,
this.leading,
});
/// Background color of the banner container.
final Color backgroundColor;
/// Border color of the banner container.
final Color borderColor;
/// Icon to display on the left side, or null if [leading] is used.
final IconData? icon;
/// Color for the icon.
final Color iconColor;
/// Primary message displayed in the banner.
final String title;
/// Text style for the title.
final TextStyle titleStyle;
/// Optional secondary message below the title.
final String? subtitle;
/// Text style for the subtitle.
final TextStyle? subtitleStyle;
/// Optional action button on the right side.
final Widget? action;
/// Optional custom leading widget, used instead of the icon.
final Widget? leading;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: borderColor.withValues(alpha: 0.3)),
),
child: Row(
children: <Widget>[
// Icon or custom leading widget.
if (leading != null)
leading!
else if (icon != null)
Icon(icon, color: iconColor, size: 20),
const SizedBox(width: UiConstants.space2),
// Title and optional subtitle.
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(title, style: titleStyle),
if (subtitle != null) ...<Widget>[
const SizedBox(height: UiConstants.space1),
Text(subtitle!, style: subtitleStyle),
],
],
),
),
// Optional action button.
if (action != null) ...<Widget>[
const SizedBox(width: UiConstants.space2),
action!,
],
],
),
);
}
}
/// Tappable text button used as a banner action.
class _BannerActionButton extends StatelessWidget {
/// Creates a [_BannerActionButton].
const _BannerActionButton({
required this.label,
required this.onPressed,
this.color,
});
/// Text label for the button.
final String label;
/// Callback when the button is pressed.
final VoidCallback onPressed;
/// Optional override color for the button text.
final Color? color;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onPressed,
child: Text(
label,
style: UiTypography.body3m.copyWith(
color: color ?? UiColors.primary,
decoration: TextDecoration.underline,
decorationColor: color ?? UiColors.primary,
),
),
);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Tappable text button used as a banner action.
class BannerActionButton extends StatelessWidget {
/// Creates a [BannerActionButton].
const BannerActionButton({
required this.label,
required this.onPressed,
this.color,
super.key,
});
/// Text label for the button.
final String label;
/// Callback when the button is pressed.
final VoidCallback onPressed;
/// Optional override color for the button text.
final Color? color;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onPressed,
child: Text(
label,
style: UiTypography.body3m.copyWith(
color: color ?? UiColors.primary,
decoration: TextDecoration.underline,
decorationColor: color ?? UiColors.primary,
),
),
);
}
}

View File

@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../bloc/geofence_bloc.dart';
import '../../bloc/geofence_state.dart';
import 'permission_denied_banner.dart';
import 'permission_denied_forever_banner.dart';
import 'service_disabled_banner.dart';
import 'timeout_banner.dart';
import 'too_far_banner.dart';
import 'verified_banner.dart';
import 'verifying_banner.dart';
/// Banner that displays the current geofence verification status.
///
/// Reads [GeofenceBloc] state directly and renders the appropriate
/// banner variant based on permission, location, and verification conditions.
class GeofenceStatusBanner extends StatelessWidget {
/// Creates a [GeofenceStatusBanner].
const GeofenceStatusBanner({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<GeofenceBloc, GeofenceState>(
builder: (BuildContext context, GeofenceState state) {
if (state.targetLat == null) {
return const SizedBox.shrink();
}
return _buildBannerForState(state);
},
);
}
/// Determines which banner variant to display based on the current state.
Widget _buildBannerForState(GeofenceState state) {
// 1. Location services disabled.
if (state.permissionStatus == LocationPermissionStatus.serviceDisabled ||
(state.isLocationTimedOut && !state.isLocationServiceEnabled)) {
return const ServiceDisabledBanner();
}
// 2. Permission denied (can re-request).
if (state.permissionStatus == LocationPermissionStatus.denied) {
return PermissionDeniedBanner(state: state);
}
// 3. Permission permanently denied.
if (state.permissionStatus == LocationPermissionStatus.deniedForever) {
return const PermissionDeniedForeverBanner();
}
// 4. Actively verifying location.
if (state.isVerifying) {
return const VerifyingBanner();
}
// 5. Location verified successfully.
if (state.isLocationVerified) {
return const VerifiedBanner();
}
// 6. Timed out but location services are enabled.
if (state.isLocationTimedOut && state.isLocationServiceEnabled) {
return const TimeoutBanner();
}
// 7. Not verified and too far away (distance known).
if (!state.isLocationVerified &&
!state.isLocationTimedOut &&
state.distanceFromTarget != null) {
return TooFarBanner(distanceMeters: state.distanceFromTarget!);
}
// Default: hide banner for unmatched states.
return const SizedBox.shrink();
}
}

View File

@@ -0,0 +1,48 @@
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 '../../bloc/geofence_bloc.dart';
import '../../bloc/geofence_event.dart';
import '../../bloc/geofence_state.dart';
import 'banner_action_button.dart';
/// Banner shown when location permission has been denied (can re-request).
class PermissionDeniedBanner extends StatelessWidget {
/// Creates a [PermissionDeniedBanner].
const PermissionDeniedBanner({required this.state, super.key});
/// Current geofence state used to re-dispatch [GeofenceStarted].
final GeofenceState state;
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
context,
).staff.clock_in.geofence;
return UiNoticeBanner(
backgroundColor: UiColors.tagError,
icon: UiIcons.error,
iconColor: UiColors.textError,
title: i18n.permission_required,
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!,
),
);
}
},
),
);
}
}

View File

@@ -0,0 +1,35 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import '../../../domain/services/geofence_service_interface.dart';
import 'banner_action_button.dart';
/// Banner shown when location permission has been permanently denied.
class PermissionDeniedForeverBanner extends StatelessWidget {
/// Creates a [PermissionDeniedForeverBanner].
const PermissionDeniedForeverBanner({super.key});
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
context,
).staff.clock_in.geofence;
return UiNoticeBanner(
backgroundColor: UiColors.tagError,
icon: UiIcons.error,
iconColor: UiColors.textError,
title: i18n.permission_denied_forever,
titleColor: UiColors.textError,
description: i18n.permission_denied_forever_desc,
descriptionColor: UiColors.textError,
action: BannerActionButton(
label: i18n.open_settings,
onPressed: () =>
Modular.get<GeofenceServiceInterface>().openAppSettings(),
),
);
}
}

View File

@@ -0,0 +1,33 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import '../../../domain/services/geofence_service_interface.dart';
import 'banner_action_button.dart';
/// Banner shown when device location services are disabled.
class ServiceDisabledBanner extends StatelessWidget {
/// Creates a [ServiceDisabledBanner].
const ServiceDisabledBanner({super.key});
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
context,
).staff.clock_in.geofence;
return UiNoticeBanner(
backgroundColor: UiColors.tagError,
icon: UiIcons.error,
iconColor: UiColors.textError,
title: i18n.service_disabled,
titleColor: UiColors.textError,
action: BannerActionButton(
label: i18n.open_settings,
onPressed: () =>
Modular.get<GeofenceServiceInterface>().openLocationSettings(),
),
);
}
}

View File

@@ -0,0 +1,40 @@
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 '../../bloc/geofence_bloc.dart';
import '../../bloc/geofence_event.dart';
import 'banner_action_button.dart';
/// Banner shown when GPS timed out but location services are enabled.
class TimeoutBanner extends StatelessWidget {
/// Creates a [TimeoutBanner].
const TimeoutBanner({super.key});
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
context,
).staff.clock_in.geofence;
return UiNoticeBanner(
backgroundColor: UiColors.tagPending,
icon: UiIcons.warning,
iconColor: UiColors.textWarning,
title: i18n.timeout_title,
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(),
);
},
),
);
}
}

View File

@@ -0,0 +1,30 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_core/core.dart';
/// Banner shown when the device is outside the geofence radius.
class TooFarBanner extends StatelessWidget {
/// Creates a [TooFarBanner].
const TooFarBanner({required this.distanceMeters, super.key});
/// Distance from the target location in meters.
final double distanceMeters;
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
context,
).staff.clock_in.geofence;
return UiNoticeBanner(
backgroundColor: UiColors.tagPending,
icon: UiIcons.warning,
iconColor: UiColors.textWarning,
title: i18n.too_far_title,
titleColor: UiColors.textWarning,
description: i18n.too_far_desc(distance: formatDistance(distanceMeters)),
descriptionColor: UiColors.textWarning,
);
}
}

View File

@@ -0,0 +1,24 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Banner shown when the device location has been verified within range.
class VerifiedBanner extends StatelessWidget {
/// Creates a [VerifiedBanner].
const VerifiedBanner({super.key});
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
context,
).staff.clock_in.geofence;
return UiNoticeBanner(
backgroundColor: UiColors.tagSuccess,
icon: UiIcons.checkCircle,
iconColor: UiColors.textSuccess,
title: i18n.verified,
titleColor: UiColors.textSuccess,
);
}
}

View File

@@ -0,0 +1,31 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Banner shown while actively verifying the device location.
class VerifyingBanner extends StatelessWidget {
/// Creates a [VerifyingBanner].
const VerifyingBanner({super.key});
@override
Widget build(BuildContext context) {
final TranslationsStaffClockInGeofenceEn i18n = Translations.of(
context,
).staff.clock_in.geofence;
return UiNoticeBanner(
backgroundColor: UiColors.tagInProgress,
iconColor: UiColors.primary,
title: i18n.verifying,
titleColor: UiColors.primary,
leading: const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: UiColors.primary,
),
),
);
}
}