feat: Enhance geofence functionality with new status banners and utility functions
This commit is contained in:
@@ -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';
|
||||
|
||||
32
apps/mobile/packages/core/lib/src/utils/geo_utils.dart
Normal file
32
apps/mobile/packages/core/lib/src/utils/geo_utils.dart
Normal 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;
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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!,
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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!,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user