feat: Enhance geofence functionality with new status banners and utility functions
This commit is contained in:
@@ -47,6 +47,7 @@ If any of these files are missing or unreadable, notify the user before proceedi
|
|||||||
- Skip tests for business logic
|
- Skip tests for business logic
|
||||||
|
|
||||||
### ALWAYS:
|
### 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/`
|
- Use feature-first packaging: `domain/`, `data/`, `presentation/`
|
||||||
- Export public API via barrel files
|
- Export public API via barrel files
|
||||||
- Use BLoC with `SessionHandlerMixin` for complex state
|
- Use BLoC with `SessionHandlerMixin` for complex state
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export 'src/core_module.dart';
|
|||||||
export 'src/domain/arguments/usecase_argument.dart';
|
export 'src/domain/arguments/usecase_argument.dart';
|
||||||
export 'src/domain/usecases/usecase.dart';
|
export 'src/domain/usecases/usecase.dart';
|
||||||
export 'src/utils/date_time_utils.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/widgets/web_mobile_frame.dart';
|
||||||
export 'src/presentation/mixins/bloc_error_handler.dart';
|
export 'src/presentation/mixins/bloc_error_handler.dart';
|
||||||
export 'src/presentation/observers/core_bloc_observer.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": {
|
"geofence": {
|
||||||
"service_disabled": "Location services are turned off. Enable them to clock in.",
|
"service_disabled": "Location services are turned off. Enable them to clock in.",
|
||||||
"permission_required": "Location permission is required 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",
|
"open_settings": "Open Settings",
|
||||||
"grant_permission": "Grant Permission",
|
"grant_permission": "Grant Permission",
|
||||||
"verifying": "Verifying your location...",
|
"verifying": "Verifying your location...",
|
||||||
|
|||||||
@@ -925,7 +925,9 @@
|
|||||||
"geofence": {
|
"geofence": {
|
||||||
"service_disabled": "Los servicios de ubicación están desactivados. Actívelos para registrar entrada.",
|
"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_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",
|
"open_settings": "Abrir Configuración",
|
||||||
"grant_permission": "Otorgar Permiso",
|
"grant_permission": "Otorgar Permiso",
|
||||||
"verifying": "Verificando su ubicación...",
|
"verifying": "Verificando su ubicación...",
|
||||||
|
|||||||
@@ -15,18 +15,32 @@ class UiNoticeBanner extends StatelessWidget {
|
|||||||
this.backgroundColor,
|
this.backgroundColor,
|
||||||
this.borderRadius,
|
this.borderRadius,
|
||||||
this.padding,
|
this.padding,
|
||||||
|
this.iconColor,
|
||||||
|
this.titleColor,
|
||||||
|
this.descriptionColor,
|
||||||
|
this.action,
|
||||||
|
this.leading,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The icon to display on the left side.
|
/// 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;
|
final IconData? icon;
|
||||||
|
|
||||||
|
/// Custom color for the icon. Defaults to [UiColors.primary].
|
||||||
|
final Color? iconColor;
|
||||||
|
|
||||||
/// The title text to display.
|
/// The title text to display.
|
||||||
final String title;
|
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.
|
/// Optional description text to display below the title.
|
||||||
final String? description;
|
final String? description;
|
||||||
|
|
||||||
|
/// Custom color for the description text. Defaults to secondary text color.
|
||||||
|
final Color? descriptionColor;
|
||||||
|
|
||||||
/// The background color of the banner.
|
/// The background color of the banner.
|
||||||
/// Defaults to [UiColors.primary] with 8% opacity.
|
/// Defaults to [UiColors.primary] with 8% opacity.
|
||||||
final Color? backgroundColor;
|
final Color? backgroundColor;
|
||||||
@@ -39,6 +53,12 @@ class UiNoticeBanner extends StatelessWidget {
|
|||||||
/// Defaults to [UiConstants.space4] on all sides.
|
/// Defaults to [UiConstants.space4] on all sides.
|
||||||
final EdgeInsetsGeometry? padding;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
@@ -50,8 +70,11 @@ class UiNoticeBanner extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
if (icon != null) ...<Widget>[
|
if (leading != null) ...<Widget>[
|
||||||
Icon(icon, color: UiColors.primary, size: 24),
|
leading!,
|
||||||
|
const SizedBox(width: UiConstants.space3),
|
||||||
|
] else if (icon != null) ...<Widget>[
|
||||||
|
Icon(icon, color: iconColor ?? UiColors.primary, size: 24),
|
||||||
const SizedBox(width: UiConstants.space3),
|
const SizedBox(width: UiConstants.space3),
|
||||||
],
|
],
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -60,18 +83,24 @@ class UiNoticeBanner extends StatelessWidget {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: UiTypography.body2m.textPrimary,
|
style: UiTypography.body2b.copyWith(color: titleColor),
|
||||||
),
|
),
|
||||||
if (description != null) ...<Widget>[
|
if (description != null) ...<Widget>[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
description!,
|
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:async';
|
||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.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].
|
/// Implementation of [GeofenceServiceInterface] using core [LocationService].
|
||||||
class GeofenceServiceImpl implements GeofenceServiceInterface {
|
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.
|
/// The core location service for device GPS access.
|
||||||
final LocationService _locationService;
|
final LocationService _locationService;
|
||||||
|
|
||||||
@@ -18,12 +23,6 @@ class GeofenceServiceImpl implements GeofenceServiceInterface {
|
|||||||
/// Average walking speed in meters per minute for ETA estimation.
|
/// Average walking speed in meters per minute for ETA estimation.
|
||||||
static const double _walkingSpeedMetersPerMinute = 80;
|
static const double _walkingSpeedMetersPerMinute = 80;
|
||||||
|
|
||||||
/// Creates a [GeofenceServiceImpl] instance.
|
|
||||||
GeofenceServiceImpl({
|
|
||||||
required LocationService locationService,
|
|
||||||
this.debugAlwaysInRange = false,
|
|
||||||
}) : _locationService = locationService;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<LocationPermissionStatus> ensurePermission() {
|
Future<LocationPermissionStatus> ensurePermission() {
|
||||||
return _locationService.checkAndRequestPermission();
|
return _locationService.checkAndRequestPermission();
|
||||||
@@ -93,7 +92,7 @@ class GeofenceServiceImpl implements GeofenceServiceInterface {
|
|||||||
required double targetLng,
|
required double targetLng,
|
||||||
required double radiusMeters,
|
required double radiusMeters,
|
||||||
}) {
|
}) {
|
||||||
final distance = _calculateDistance(
|
final distance = calculateDistance(
|
||||||
location.latitude,
|
location.latitude,
|
||||||
location.longitude,
|
location.longitude,
|
||||||
targetLat,
|
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 '../bloc/geofence_state.dart';
|
||||||
import 'clock_in_helpers.dart';
|
import 'clock_in_helpers.dart';
|
||||||
import 'early_check_in_banner.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 'lunch_break_modal.dart';
|
||||||
import 'nfc_scan_dialog.dart';
|
import 'nfc_scan_dialog.dart';
|
||||||
import 'no_shifts_banner.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