diff --git a/.claude/agents/mobile-builder.md b/.claude/agents/mobile-builder.md index adb14d7f..7ada36c8 100644 --- a/.claude/agents/mobile-builder.md +++ b/.claude/agents/mobile-builder.md @@ -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 diff --git a/apps/mobile/packages/core/lib/core.dart b/apps/mobile/packages/core/lib/core.dart index f450c6e2..5e29efb5 100644 --- a/apps/mobile/packages/core/lib/core.dart +++ b/apps/mobile/packages/core/lib/core.dart @@ -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'; diff --git a/apps/mobile/packages/core/lib/src/utils/geo_utils.dart b/apps/mobile/packages/core/lib/src/utils/geo_utils.dart new file mode 100644 index 00000000..0026273d --- /dev/null +++ b/apps/mobile/packages/core/lib/src/utils/geo_utils.dart @@ -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; diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 24f8e555..8a18acea 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -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...", diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 858249f1..99c5e947 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -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...", diff --git a/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart b/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart index 430d163d..ee41bd98 100644 --- a/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart +++ b/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart @@ -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: [ - if (icon != null) ...[ - Icon(icon, color: UiColors.primary, size: 24), + if (leading != null) ...[ + leading!, + const SizedBox(width: UiConstants.space3), + ] else if (icon != null) ...[ + Icon(icon, color: iconColor ?? UiColors.primary, size: 24), const SizedBox(width: UiConstants.space3), ], Expanded( @@ -60,18 +83,24 @@ class UiNoticeBanner extends StatelessWidget { children: [ Text( title, - style: UiTypography.body2m.textPrimary, + style: UiTypography.body2b.copyWith(color: titleColor), ), if (description != null) ...[ const SizedBox(height: 2), Text( description!, - style: UiTypography.body2r.textSecondary, + style: UiTypography.body3r.copyWith( + color: descriptionColor, + ), ), ], ], ), ), + if (action != null) ...[ + const SizedBox(width: UiConstants.space3), + action!, + ], ], ), ); diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/geofence_service_impl.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/geofence_service_impl.dart index 9071bf4c..4a76b07b 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/geofence_service_impl.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/geofence_service_impl.dart @@ -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 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; } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart index 5cf56ed7..d2e4436f 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart @@ -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'; diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner.dart deleted file mode 100644 index 8b422880..00000000 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner.dart +++ /dev/null @@ -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( - 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().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().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().openLocationSettings(); - } - - /// Opens the app settings page via the geofence service. - void _openAppSettings() { - Modular.get().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: [ - // 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: [ - Text(title, style: titleStyle), - if (subtitle != null) ...[ - const SizedBox(height: UiConstants.space1), - Text(subtitle!, style: subtitleStyle), - ], - ], - ), - ), - - // Optional action button. - if (action != null) ...[ - 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, - ), - ), - ); - } -} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/banner_action_button.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/banner_action_button.dart new file mode 100644 index 00000000..177f3075 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/banner_action_button.dart @@ -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, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_status_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_status_banner.dart new file mode 100644 index 00000000..2a4e4993 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_status_banner.dart @@ -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( + 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(); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/permission_denied_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/permission_denied_banner.dart new file mode 100644 index 00000000..8624e192 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/permission_denied_banner.dart @@ -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().add( + GeofenceStarted( + targetLat: state.targetLat!, + targetLng: state.targetLng!, + ), + ); + } + }, + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/permission_denied_forever_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/permission_denied_forever_banner.dart new file mode 100644 index 00000000..2715a971 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/permission_denied_forever_banner.dart @@ -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().openAppSettings(), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/service_disabled_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/service_disabled_banner.dart new file mode 100644 index 00000000..6494150b --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/service_disabled_banner.dart @@ -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().openLocationSettings(), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/timeout_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/timeout_banner.dart new file mode 100644 index 00000000..c89a77a6 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/timeout_banner.dart @@ -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().add( + const GeofenceRetryRequested(), + ); + }, + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/too_far_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/too_far_banner.dart new file mode 100644 index 00000000..79551f50 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/too_far_banner.dart @@ -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, + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/verified_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/verified_banner.dart new file mode 100644 index 00000000..08653cdc --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/verified_banner.dart @@ -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, + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/verifying_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/verifying_banner.dart new file mode 100644 index 00000000..537d388e --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/verifying_banner.dart @@ -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, + ), + ), + ); + } +}