From ec880007d0ac89509ae544df01cce94165e61439 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 13 Mar 2026 11:55:59 -0400 Subject: [PATCH 01/33] feat(clock-in): add adaptive launcher icons and implement clock-in features - Added adaptive launcher icons for both dev and stage environments in mobile apps. - Introduced CheckInModeTab widget for selecting check-in methods. - Created CheckedInBanner to display current check-in status with time. - Implemented ClockInActionSection to manage check-in/out actions based on shift status. - Developed ClockInBody to compose the main content of the clock-in page. - Added utility functions in ClockInHelpers for time formatting and check-in availability. - Created EarlyCheckInBanner to notify users arriving too early to check in. - Implemented NFC scan dialog for NFC-based check-ins. - Added NoShiftsBanner to inform users when no shifts are scheduled. - Developed ShiftCard and ShiftCardList for displaying shifts in a selectable format. - Created ShiftCompletedBanner to show success message after completing a shift. --- .../ic_launcher.xml | 0 .../ic_launcher.xml | 0 .../ic_launcher.xml | 0 .../ic_launcher.xml | 0 .../src/presentation/pages/clock_in_page.dart | 749 +----------------- .../widgets/check_in_mode_tab.dart | 79 ++ .../widgets/checked_in_banner.dart | 63 ++ .../widgets/clock_in_action_section.dart | 108 +++ .../presentation/widgets/clock_in_body.dart | 105 +++ .../widgets/clock_in_helpers.dart | 78 ++ .../widgets/early_check_in_banner.dart | 50 ++ .../presentation/widgets/nfc_scan_dialog.dart | 127 +++ .../widgets/no_shifts_banner.dart | 42 + .../src/presentation/widgets/shift_card.dart | 123 +++ .../presentation/widgets/shift_card_list.dart | 42 + .../widgets/shift_completed_banner.dart | 50 ++ 16 files changed, 899 insertions(+), 717 deletions(-) rename apps/mobile/apps/client/android/app/src/dev/res/{values => mipmap-anydpi-v26}/ic_launcher.xml (100%) rename apps/mobile/apps/client/android/app/src/stage/res/{values => mipmap-anydpi-v26}/ic_launcher.xml (100%) rename apps/mobile/apps/staff/android/app/src/dev/res/{values => mipmap-anydpi-v26}/ic_launcher.xml (100%) rename apps/mobile/apps/staff/android/app/src/stage/res/{values => mipmap-anydpi-v26}/ic_launcher.xml (100%) create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/check_in_mode_tab.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/checked_in_banner.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_helpers.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/early_check_in_banner.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/nfc_scan_dialog.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/no_shifts_banner.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card_list.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_completed_banner.dart diff --git a/apps/mobile/apps/client/android/app/src/dev/res/values/ic_launcher.xml b/apps/mobile/apps/client/android/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from apps/mobile/apps/client/android/app/src/dev/res/values/ic_launcher.xml rename to apps/mobile/apps/client/android/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/apps/mobile/apps/client/android/app/src/stage/res/values/ic_launcher.xml b/apps/mobile/apps/client/android/app/src/stage/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from apps/mobile/apps/client/android/app/src/stage/res/values/ic_launcher.xml rename to apps/mobile/apps/client/android/app/src/stage/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/apps/mobile/apps/staff/android/app/src/dev/res/values/ic_launcher.xml b/apps/mobile/apps/staff/android/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from apps/mobile/apps/staff/android/app/src/dev/res/values/ic_launcher.xml rename to apps/mobile/apps/staff/android/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/apps/mobile/apps/staff/android/app/src/stage/res/values/ic_launcher.xml b/apps/mobile/apps/staff/android/app/src/stage/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from apps/mobile/apps/staff/android/app/src/stage/res/values/ic_launcher.xml rename to apps/mobile/apps/staff/android/app/src/stage/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart index 76636878..511179ad 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart @@ -1,745 +1,60 @@ -// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports, unused_element, unused_field, duplicate_ignore 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:intl/intl.dart'; -import 'package:krow_domain/krow_domain.dart'; import '../bloc/clock_in_bloc.dart'; -import '../bloc/clock_in_event.dart'; import '../bloc/clock_in_state.dart'; +import '../widgets/clock_in_body.dart'; import '../widgets/clock_in_page_skeleton/clock_in_page_skeleton.dart'; -import '../widgets/commute_tracker.dart'; -import '../widgets/date_selector.dart'; -import '../widgets/lunch_break_modal.dart'; -import '../widgets/swipe_to_check_in.dart'; -class ClockInPage extends StatefulWidget { +/// Top-level page for the staff clock-in feature. +/// +/// Acts as a thin shell that provides the [ClockInBloc] and delegates +/// rendering to [ClockInBody] (loaded state) or [ClockInPageSkeleton] +/// (loading state). Error snackbars are handled via [BlocListener]. +class ClockInPage extends StatelessWidget { + /// Creates the clock-in page. const ClockInPage({super.key}); - @override - State createState() => _ClockInPageState(); -} - -class _ClockInPageState extends State { - late final ClockInBloc _bloc; - - @override - void initState() { - super.initState(); - _bloc = Modular.get(); - } - @override Widget build(BuildContext context) { final TranslationsStaffClockInEn i18n = Translations.of( context, ).staff.clock_in; + return BlocProvider.value( - value: _bloc, - child: BlocConsumer( + value: Modular.get(), + child: BlocListener( + listenWhen: (ClockInState previous, ClockInState current) => + current.status == ClockInStatus.failure && + current.errorMessage != null, listener: (BuildContext context, ClockInState state) { - if (state.status == ClockInStatus.failure && - state.errorMessage != null) { - UiSnackbar.show( - context, - message: translateErrorKey(state.errorMessage!), - type: UiSnackbarType.error, - ); - } - }, - builder: (BuildContext context, ClockInState state) { - if (state.status == ClockInStatus.loading && - state.todayShifts.isEmpty) { - return Scaffold( - appBar: UiAppBar(title: i18n.title, showBackButton: false), - body: const SafeArea(child: ClockInPageSkeleton()), - ); - } - - final List todayShifts = state.todayShifts; - final Shift? selectedShift = state.selectedShift; - final String? activeShiftId = state.attendance.activeShiftId; - final bool isActiveSelected = - selectedShift != null && selectedShift.id == activeShiftId; - final DateTime? checkInTime = isActiveSelected - ? state.attendance.checkInTime - : null; - final DateTime? checkOutTime = isActiveSelected - ? state.attendance.checkOutTime - : null; - final bool isCheckedIn = - state.attendance.isCheckedIn && isActiveSelected; - - return Scaffold( - appBar: UiAppBar(title: i18n.title, showBackButton: false), - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.only( - bottom: UiConstants.space24, - top: UiConstants.space6, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space5, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // // Commute Tracker (shows before date selector when applicable) - // if (selectedShift != null) - // CommuteTracker( - // shift: selectedShift, - // hasLocationConsent: state.hasLocationConsent, - // isCommuteModeOn: state.isCommuteModeOn, - // distanceMeters: state.distanceFromVenue, - // etaMinutes: state.etaMinutes, - // onCommuteToggled: (bool value) { - // _bloc.add(CommuteModeToggled(value)); - // }, - // ), - // Date Selector - DateSelector( - selectedDate: state.selectedDate, - onSelect: (DateTime date) => - _bloc.add(DateSelected(date)), - shiftDates: [ - DateFormat('yyyy-MM-dd').format(DateTime.now()), - ], - ), - const SizedBox(height: UiConstants.space5), - - // Your Activity Header - Text( - i18n.your_activity, - textAlign: TextAlign.start, - style: UiTypography.headline4m, - ), - - const SizedBox(height: UiConstants.space4), - - // Selected Shift Info Card - if (todayShifts.isNotEmpty) - Column( - children: todayShifts - .map( - (Shift shift) => GestureDetector( - onTap: () => - _bloc.add(ShiftSelected(shift)), - child: Container( - padding: const EdgeInsets.all( - UiConstants.space3, - ), - margin: const EdgeInsets.only( - bottom: UiConstants.space3, - ), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusLg, - border: Border.all( - color: shift.id == selectedShift?.id - ? UiColors.primary - : UiColors.border, - width: shift.id == selectedShift?.id - ? 2 - : 1, - ), - ), - child: Row( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - shift.id == - selectedShift?.id - ? i18n.selected_shift_badge - : i18n.today_shift_badge, - style: UiTypography - .titleUppercase4b - .copyWith( - color: - shift.id == - selectedShift - ?.id - ? UiColors.primary - : UiColors - .textSecondary, - ), - ), - const SizedBox(height: 2), - Text( - shift.title, - style: UiTypography.body2b, - ), - Text( - "${shift.clientName} ${shift.location}", - style: UiTypography - .body3r - .textSecondary, - ), - ], - ), - ), - Column( - crossAxisAlignment: - CrossAxisAlignment.end, - children: [ - Text( - "${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}", - style: UiTypography - .body3m - .textSecondary, - ), - Text( - "\$${shift.hourlyRate}/hr", - style: UiTypography.body3m - .copyWith( - color: UiColors.primary, - ), - ), - ], - ), - ], - ), - ), - ), - ) - .toList(), - ), - - // Swipe To Check In / Checked Out State / No Shift State - if (selectedShift != null && - checkOutTime == null) ...[ - if (!isCheckedIn && - !_isCheckInAllowed(selectedShift)) - Container( - width: double.infinity, - padding: const EdgeInsets.all( - UiConstants.space6, - ), - decoration: BoxDecoration( - color: UiColors.bgSecondary, - borderRadius: UiConstants.radiusLg, - ), - child: Column( - children: [ - const Icon( - UiIcons.clock, - size: 48, - color: UiColors.iconThird, - ), - const SizedBox(height: UiConstants.space4), - Text( - i18n.early_title, - style: UiTypography.body1m.textSecondary, - ), - const SizedBox(height: UiConstants.space1), - Text( - i18n.check_in_at( - time: _getCheckInAvailabilityTime( - selectedShift, - ), - ), - style: UiTypography.body2r.textSecondary, - textAlign: TextAlign.center, - ), - ], - ), - ) - else ...[ - // Attire Photo Section - // if (!isCheckedIn) ...[ - // Container( - // padding: const EdgeInsets.all( - // UiConstants.space4, - // ), - // margin: const EdgeInsets.only( - // bottom: UiConstants.space4, - // ), - // decoration: BoxDecoration( - // color: UiColors.white, - // borderRadius: UiConstants.radiusLg, - // border: Border.all(color: UiColors.border), - // ), - // child: Row( - // children: [ - // Container( - // width: 48, - // height: 48, - // decoration: BoxDecoration( - // color: UiColors.bgSecondary, - // borderRadius: UiConstants.radiusMd, - // ), - // child: const Icon( - // UiIcons.camera, - // color: UiColors.primary, - // ), - // ), - // const SizedBox(width: UiConstants.space3), - // Expanded( - // child: Column( - // crossAxisAlignment: - // CrossAxisAlignment.start, - // children: [ - // Text( - // i18n.attire_photo_label, - // style: UiTypography.body2b, - // ), - // Text( - // i18n.attire_photo_desc, - // style: UiTypography - // .body3r - // .textSecondary, - // ), - // ], - // ), - // ), - // UiButton.secondary( - // text: i18n.take_attire_photo, - // onPressed: () { - // UiSnackbar.show( - // context, - // message: i18n.attire_captured, - // type: UiSnackbarType.success, - // ); - // }, - // ), - // ], - // ), - // ), - // ], - - // if (!isCheckedIn && - // (!state.isLocationVerified || - // state.currentLocation == - // null)) ...[ - // Container( - // width: double.infinity, - // padding: const EdgeInsets.all( - // UiConstants.space4, - // ), - // margin: const EdgeInsets.only( - // bottom: UiConstants.space4, - // ), - // decoration: BoxDecoration( - // color: UiColors.tagError, - // borderRadius: UiConstants.radiusLg, - // ), - // child: Row( - // children: [ - // const Icon( - // UiIcons.error, - // color: UiColors.textError, - // size: 20, - // ), - // const SizedBox(width: UiConstants.space3), - // Expanded( - // child: Text( - // state.currentLocation == null - // ? i18n.location_verifying - // : i18n.not_in_range( - // distance: '500', - // ), - // style: UiTypography.body3m.textError, - // ), - // ), - // ], - // ), - // ), - // ], - SwipeToCheckIn( - isCheckedIn: isCheckedIn, - mode: state.checkInMode, - isDisabled: isCheckedIn, - isLoading: - state.status == - ClockInStatus.actionInProgress, - onCheckIn: () async { - // Show NFC dialog if mode is 'nfc' - if (state.checkInMode == 'nfc') { - await _showNFCDialog(context); - } else { - _bloc.add( - CheckInRequested( - shiftId: selectedShift.id, - ), - ); - } - }, - onCheckOut: () { - showDialog( - context: context, - builder: (BuildContext context) => - LunchBreakDialog( - onComplete: () { - Navigator.of( - context, - ).pop(); // Close dialog first - _bloc.add( - const CheckOutRequested(), - ); - }, - ), - ); - }, - ), - ], - ] else if (selectedShift != null && - checkOutTime != null) ...[ - // Shift Completed State - Container( - padding: const EdgeInsets.all(UiConstants.space6), - decoration: BoxDecoration( - color: UiColors.tagSuccess, - borderRadius: UiConstants.radiusLg, - border: Border.all( - color: UiColors.success.withValues( - alpha: 0.3, - ), - ), - ), - child: Column( - children: [ - Container( - width: 48, - height: 48, - decoration: const BoxDecoration( - color: UiColors.tagActive, - shape: BoxShape.circle, - ), - child: const Icon( - UiIcons.check, - color: UiColors.textSuccess, - size: 24, - ), - ), - const SizedBox(height: UiConstants.space3), - Text( - i18n.shift_completed, - style: UiTypography.body1b.textSuccess, - ), - const SizedBox(height: UiConstants.space1), - Text( - i18n.great_work, - style: UiTypography.body2r.textSuccess, - ), - ], - ), - ), - ] else ...[ - // No Shift State - Container( - width: double.infinity, - padding: const EdgeInsets.all(UiConstants.space6), - decoration: BoxDecoration( - color: UiColors.bgSecondary, - borderRadius: UiConstants.radiusLg, - ), - child: Column( - children: [ - Text( - i18n.no_shifts_today, - style: UiTypography.body1m.textSecondary, - textAlign: TextAlign.center, - ), - const SizedBox(height: UiConstants.space1), - Text( - i18n.accept_shift_cta, - style: UiTypography.body2r.textSecondary, - textAlign: TextAlign.center, - ), - ], - ), - ), - ], - - // Checked In Banner - if (isCheckedIn && checkInTime != null) ...[ - const SizedBox(height: UiConstants.space3), - Container( - padding: const EdgeInsets.all(UiConstants.space3), - decoration: BoxDecoration( - color: UiColors.tagSuccess, - borderRadius: UiConstants.radiusLg, - border: Border.all( - color: UiColors.success.withValues( - alpha: 0.3, - ), - ), - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - i18n.checked_in_at_label, - style: UiTypography.body3m.textSuccess, - ), - Text( - DateFormat( - 'h:mm a', - ).format(checkInTime), - style: UiTypography.body1b.textSuccess, - ), - ], - ), - Container( - width: 40, - height: 40, - decoration: const BoxDecoration( - color: UiColors.tagActive, - shape: BoxShape.circle, - ), - child: const Icon( - UiIcons.check, - color: UiColors.textSuccess, - ), - ), - ], - ), - ), - ], - - const SizedBox(height: 16), - - // Recent Activity List (Temporarily removed) - const SizedBox(height: 16), - ], - ), - ), - ], - ), - ), - ), + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage!), + type: UiSnackbarType.error, ); }, - ), - ); - } + child: Scaffold( + appBar: UiAppBar(title: i18n.title, showBackButton: false), + body: BlocBuilder( + buildWhen: (ClockInState previous, ClockInState current) => + previous.status != current.status || + previous.todayShifts != current.todayShifts, + builder: (BuildContext context, ClockInState state) { + final bool isInitialLoading = + state.status == ClockInStatus.loading && + state.todayShifts.isEmpty; - Widget _buildModeTab( - String label, - IconData icon, - String value, - String currentMode, - ) { - final bool isSelected = currentMode == value; - return Expanded( - child: GestureDetector( - onTap: () => _bloc.add(CheckInModeChanged(value)), - child: Container( - padding: const EdgeInsets.symmetric(vertical: UiConstants.space2), - decoration: BoxDecoration( - color: isSelected ? UiColors.white : UiColors.transparent, - borderRadius: UiConstants.radiusMd, - boxShadow: isSelected - ? [ - BoxShadow( - color: UiColors.black.withValues(alpha: 0.05), - blurRadius: 2, - offset: const Offset(0, 1), - ), - ] - : [], - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - size: 16, - color: isSelected ? UiColors.foreground : UiColors.iconThird, - ), - const SizedBox(width: 6), - Text( - label, - style: UiTypography.body2m.copyWith( - color: isSelected - ? UiColors.foreground - : UiColors.textSecondary, - ), - ), - ], + return isInitialLoading + ? const ClockInPageSkeleton() + : const ClockInBody(); + }, ), ), ), ); } - - Future _showNFCDialog(BuildContext context) async { - final TranslationsStaffClockInEn i18n = Translations.of( - context, - ).staff.clock_in; - bool scanned = false; - - // Using a local navigator context since we are in a dialog - await showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext dialogContext) { - return StatefulBuilder( - builder: (BuildContext context, setState) { - return AlertDialog( - title: Text( - scanned - ? i18n.nfc_dialog.scanned_title - : i18n.nfc_dialog.scan_title, - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 96, - height: 96, - decoration: BoxDecoration( - color: scanned - ? UiColors.tagSuccess - : UiColors.tagInProgress, - shape: BoxShape.circle, - ), - child: Icon( - scanned ? UiIcons.check : UiIcons.nfc, - size: 48, - color: scanned ? UiColors.textSuccess : UiColors.primary, - ), - ), - const SizedBox(height: UiConstants.space6), - Text( - scanned - ? i18n.nfc_dialog.processing - : i18n.nfc_dialog.ready_to_scan, - style: UiTypography.headline4m, - ), - const SizedBox(height: UiConstants.space2), - Text( - scanned - ? i18n.nfc_dialog.please_wait - : i18n.nfc_dialog.scan_instruction, - textAlign: TextAlign.center, - style: UiTypography.body2r.textSecondary, - ), - if (!scanned) ...[ - const SizedBox(height: UiConstants.space6), - SizedBox( - width: double.infinity, - height: 56, - child: ElevatedButton.icon( - onPressed: () async { - setState(() { - scanned = true; - }); - // Simulate NFC scan delay - await Future.delayed( - const Duration(milliseconds: 1000), - ); - if (!context.mounted) return; - Navigator.of(dialogContext).pop(); - // Trigger BLoC event - // Need to access the bloc from the outer context or via passed reference - // Since _bloc is a field of the page state, we can use it if we are inside the page class - // But this dialog is just a function call. - // It's safer to just return a result - }, - icon: const Icon(UiIcons.nfc, size: 24), - label: Text( - i18n.nfc_dialog.tap_to_scan, - style: UiTypography.headline4m.white, - ), - style: ElevatedButton.styleFrom( - backgroundColor: UiColors.primary, - foregroundColor: UiColors.white, - shape: RoundedRectangleBorder( - borderRadius: UiConstants.radiusLg, - ), - ), - ), - ), - ], - ], - ), - ); - }, - ); - }, - ); - - // After dialog closes, trigger the event if scan was successful (simulated) - // In real app, we would check the dialog result - if (scanned && _bloc.state.selectedShift != null) { - _bloc.add(CheckInRequested(shiftId: _bloc.state.selectedShift!.id)); - } - } - - // --- Helper Methods --- - - String _formatTime(String timeStr) { - if (timeStr.isEmpty) return ''; - try { - // Try parsing as ISO string first (which contains date) - final DateTime dt = DateTime.parse(timeStr); - return DateFormat('h:mm a').format(dt); - } catch (_) { - // Fallback for strict "HH:mm" or "HH:mm:ss" strings - try { - final List parts = timeStr.split(':'); - if (parts.length >= 2) { - final DateTime dt = DateTime( - 2022, - 1, - 1, - int.parse(parts[0]), - int.parse(parts[1]), - ); - return DateFormat('h:mm a').format(dt); - } - return timeStr; - } catch (e) { - return timeStr; - } - } - } - - bool _isCheckInAllowed(Shift shift) { - try { - // Parse shift date (e.g. 2024-01-31T09:00:00) - // The Shift entity has 'date' which is the start DateTime string - final DateTime shiftStart = DateTime.parse(shift.startTime); - final DateTime windowStart = shiftStart.subtract( - const Duration(minutes: 15), - ); - return DateTime.now().isAfter(windowStart); - } catch (e) { - // Fallback: If parsing fails, allow check in to avoid blocking. - return true; - } - } - - String _getCheckInAvailabilityTime(Shift shift) { - try { - final DateTime shiftStart = DateTime.parse(shift.startTime.trim()); - final DateTime windowStart = shiftStart.subtract( - const Duration(minutes: 15), - ); - return DateFormat('h:mm a').format(windowStart); - } catch (e) { - final TranslationsStaffClockInEn i18n = Translations.of( - context, - ).staff.clock_in; - return i18n.soon; - } - } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/check_in_mode_tab.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/check_in_mode_tab.dart new file mode 100644 index 00000000..054e15b8 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/check_in_mode_tab.dart @@ -0,0 +1,79 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../bloc/clock_in_bloc.dart'; +import '../bloc/clock_in_event.dart'; + +/// A single selectable tab within a check-in mode toggle strip. +/// +/// Used to switch between different check-in methods (e.g. swipe, NFC). +class CheckInModeTab extends StatelessWidget { + /// Creates a mode tab. + const CheckInModeTab({ + required this.label, + required this.icon, + required this.value, + required this.currentMode, + super.key, + }); + + /// The display label for this mode. + final String label; + + /// The icon shown next to the label. + final IconData icon; + + /// The mode value this tab represents. + final String value; + + /// The currently active mode, used to determine selection state. + final String currentMode; + + @override + Widget build(BuildContext context) { + final bool isSelected = currentMode == value; + + return Expanded( + child: GestureDetector( + onTap: () => + context.read().add(CheckInModeChanged(value)), + child: Container( + padding: const EdgeInsets.symmetric(vertical: UiConstants.space2), + decoration: BoxDecoration( + color: isSelected ? UiColors.white : UiColors.transparent, + borderRadius: UiConstants.radiusMd, + boxShadow: isSelected + ? [ + BoxShadow( + color: UiColors.black.withValues(alpha: 0.05), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ] + : [], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 16, + color: isSelected ? UiColors.foreground : UiColors.iconThird, + ), + const SizedBox(width: UiConstants.space1), + Text( + label, + style: UiTypography.body2m.copyWith( + color: isSelected + ? UiColors.foreground + : UiColors.textSecondary, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/checked_in_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/checked_in_banner.dart new file mode 100644 index 00000000..eb254b01 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/checked_in_banner.dart @@ -0,0 +1,63 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +/// A green-tinted banner confirming that the user is currently checked in. +/// +/// Displays the exact check-in time alongside a check icon. +class CheckedInBanner extends StatelessWidget { + /// Creates a checked-in banner for the given [checkInTime]. + const CheckedInBanner({required this.checkInTime, super.key}); + + /// The time the user checked in. + final DateTime checkInTime; + + @override + Widget build(BuildContext context) { + final TranslationsStaffClockInEn i18n = Translations.of( + context, + ).staff.clock_in; + + return Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.tagSuccess, + borderRadius: UiConstants.radiusLg, + border: Border.all( + color: UiColors.success.withValues(alpha: 0.3), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + i18n.checked_in_at_label, + style: UiTypography.body3m.textSuccess, + ), + Text( + DateFormat('h:mm a').format(checkInTime), + style: UiTypography.body1b.textSuccess, + ), + ], + ), + Container( + width: 40, + height: 40, + decoration: const BoxDecoration( + color: UiColors.tagActive, + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.check, + color: UiColors.textSuccess, + ), + ), + ], + ), + ); + } +} 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 new file mode 100644 index 00000000..b94f2da3 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../bloc/clock_in_bloc.dart'; +import '../bloc/clock_in_event.dart'; +import '../bloc/clock_in_state.dart'; +import 'clock_in_helpers.dart'; +import 'early_check_in_banner.dart'; +import 'lunch_break_modal.dart'; +import 'nfc_scan_dialog.dart'; +import 'no_shifts_banner.dart'; +import 'shift_completed_banner.dart'; +import 'swipe_to_check_in.dart'; + +/// Orchestrates which action widget is displayed based on the current state. +/// +/// Decides between the swipe-to-check-in slider, the early-arrival banner, +/// the shift-completed banner, or the no-shifts placeholder. +class ClockInActionSection extends StatelessWidget { + /// Creates the action section. + const ClockInActionSection({ + required this.selectedShift, + required this.isCheckedIn, + required this.checkOutTime, + required this.checkInMode, + required this.isActionInProgress, + super.key, + }); + + /// The currently selected shift, or null if none is selected. + final Shift? selectedShift; + + /// Whether the user is currently checked in for the active shift. + final bool isCheckedIn; + + /// The check-out time, or null if the user has not checked out. + final DateTime? checkOutTime; + + /// The current check-in mode (e.g. "swipe" or "nfc"). + final String checkInMode; + + /// Whether a check-in or check-out action is currently in progress. + final bool isActionInProgress; + + @override + Widget build(BuildContext context) { + if (selectedShift != null && checkOutTime == null) { + return _buildActiveShiftAction(context); + } + + if (selectedShift != null && checkOutTime != null) { + return const ShiftCompletedBanner(); + } + + return const NoShiftsBanner(); + } + + /// Builds the action widget for an active (not completed) shift. + Widget _buildActiveShiftAction(BuildContext context) { + if (!isCheckedIn && !ClockInHelpers.isCheckInAllowed(selectedShift!)) { + return EarlyCheckInBanner( + availabilityTime: ClockInHelpers.getCheckInAvailabilityTime( + selectedShift!, + context, + ), + ); + } + + return SwipeToCheckIn( + isCheckedIn: isCheckedIn, + mode: checkInMode, + isDisabled: isCheckedIn, + isLoading: isActionInProgress, + onCheckIn: () => _handleCheckIn(context), + onCheckOut: () => _handleCheckOut(context), + ); + } + + /// Triggers the check-in flow, showing an NFC dialog when needed. + Future _handleCheckIn(BuildContext context) async { + if (checkInMode == 'nfc') { + final bool scanned = await showNfcScanDialog(context); + if (scanned && context.mounted) { + context.read().add( + CheckInRequested(shiftId: selectedShift!.id), + ); + } + } else { + context.read().add( + CheckInRequested(shiftId: selectedShift!.id), + ); + } + } + + /// Triggers the check-out flow via the lunch-break confirmation dialog. + void _handleCheckOut(BuildContext context) { + showDialog( + context: context, + builder: (BuildContext dialogContext) => LunchBreakDialog( + onComplete: () { + Navigator.of(dialogContext).pop(); + context.read().add(const CheckOutRequested()); + }, + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart new file mode 100644 index 00000000..58653815 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart @@ -0,0 +1,105 @@ +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:intl/intl.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../bloc/clock_in_bloc.dart'; +import '../bloc/clock_in_event.dart'; +import '../bloc/clock_in_state.dart'; +import 'checked_in_banner.dart'; +import 'clock_in_action_section.dart'; +import 'date_selector.dart'; +import 'shift_card_list.dart'; + +/// The scrollable main content of the clock-in page. +/// +/// Composes the date selector, activity header, shift cards, action section, +/// and the checked-in status banner into a single scrollable column. +class ClockInBody extends StatelessWidget { + /// Creates the clock-in body. + const ClockInBody({super.key}); + + @override + Widget build(BuildContext context) { + final TranslationsStaffClockInEn i18n = Translations.of( + context, + ).staff.clock_in; + + return SingleChildScrollView( + padding: const EdgeInsets.only( + bottom: UiConstants.space24, + top: UiConstants.space6, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), + child: BlocBuilder( + builder: (BuildContext context, ClockInState state) { + final List todayShifts = state.todayShifts; + final Shift? selectedShift = state.selectedShift; + final String? activeShiftId = state.attendance.activeShiftId; + final bool isActiveSelected = + selectedShift != null && selectedShift.id == activeShiftId; + final DateTime? checkInTime = isActiveSelected + ? state.attendance.checkInTime + : null; + final DateTime? checkOutTime = isActiveSelected + ? state.attendance.checkOutTime + : null; + final bool isCheckedIn = + state.attendance.isCheckedIn && isActiveSelected; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // date selector + DateSelector( + selectedDate: state.selectedDate, + onSelect: (DateTime date) => + context.read().add(DateSelected(date)), + shiftDates: [ + DateFormat('yyyy-MM-dd').format(DateTime.now()), + ], + ), + const SizedBox(height: UiConstants.space5), + Text( + i18n.your_activity, + textAlign: TextAlign.start, + style: UiTypography.headline4m, + ), + const SizedBox(height: UiConstants.space4), + + // today's shifts and actions + if (todayShifts.isNotEmpty) + ShiftCardList( + shifts: todayShifts, + selectedShiftId: selectedShift?.id, + onShiftSelected: (Shift shift) => + context.read().add(ShiftSelected(shift)), + ), + + // action section (check-in/out buttons) + ClockInActionSection( + selectedShift: selectedShift, + isCheckedIn: isCheckedIn, + checkOutTime: checkOutTime, + checkInMode: state.checkInMode, + isActionInProgress: + state.status == ClockInStatus.actionInProgress, + ), + + // checked-in banner (only if currently checked in to the selected shift) + if (isCheckedIn && checkInTime != null) ...[ + const SizedBox(height: UiConstants.space3), + CheckedInBanner(checkInTime: checkInTime), + ], + const SizedBox(height: UiConstants.space4), + ], + ); + }, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_helpers.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_helpers.dart new file mode 100644 index 00000000..9f64639d --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_helpers.dart @@ -0,0 +1,78 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Pure utility functions shared across clock-in widgets. +/// +/// These are stateless helpers that handle time formatting and +/// shift check-in availability calculations. +class ClockInHelpers { + const ClockInHelpers._(); + + /// Formats a time string (ISO 8601 or HH:mm) into a human-readable + /// 12-hour format (e.g. "9:00 AM"). + static String formatTime(String timeStr) { + if (timeStr.isEmpty) return ''; + try { + final DateTime dt = DateTime.parse(timeStr); + return DateFormat('h:mm a').format(dt); + } catch (_) { + try { + final List parts = timeStr.split(':'); + if (parts.length >= 2) { + final DateTime dt = DateTime( + 2022, + 1, + 1, + int.parse(parts[0]), + int.parse(parts[1]), + ); + return DateFormat('h:mm a').format(dt); + } + return timeStr; + } catch (e) { + return timeStr; + } + } + } + + /// Whether the user is allowed to check in for the given [shift]. + /// + /// Check-in is permitted 15 minutes before the shift start time. + /// Falls back to `true` if the start time cannot be parsed. + static bool isCheckInAllowed(Shift shift) { + try { + final DateTime shiftStart = DateTime.parse(shift.startTime); + final DateTime windowStart = shiftStart.subtract( + const Duration(minutes: 15), + ); + return DateTime.now().isAfter(windowStart); + } catch (e) { + return true; + } + } + + /// Returns the earliest time the user may check in for the given [shift], + /// formatted as a 12-hour string (e.g. "8:45 AM"). + /// + /// Falls back to the localized "soon" label when the start time cannot + /// be parsed. + static String getCheckInAvailabilityTime( + Shift shift, + BuildContext context, + ) { + try { + final DateTime shiftStart = DateTime.parse(shift.startTime.trim()); + final DateTime windowStart = shiftStart.subtract( + const Duration(minutes: 15), + ); + return DateFormat('h:mm a').format(windowStart); + } catch (e) { + final TranslationsStaffClockInEn i18n = Translations.of( + context, + ).staff.clock_in; + return i18n.soon; + } + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/early_check_in_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/early_check_in_banner.dart new file mode 100644 index 00000000..18f36835 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/early_check_in_banner.dart @@ -0,0 +1,50 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Banner shown when the user arrives too early to check in. +/// +/// Displays a clock icon and a message indicating when check-in +/// will become available. +class EarlyCheckInBanner extends StatelessWidget { + /// Creates an early check-in banner. + const EarlyCheckInBanner({ + required this.availabilityTime, + super.key, + }); + + /// Formatted time string when check-in becomes available (e.g. "8:45 AM"). + final String availabilityTime; + + @override + Widget build(BuildContext context) { + final TranslationsStaffClockInEn i18n = Translations.of( + context, + ).staff.clock_in; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(UiConstants.space6), + decoration: BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: UiConstants.radiusLg, + ), + child: Column( + children: [ + const Icon(UiIcons.clock, size: 48, color: UiColors.iconThird), + const SizedBox(height: UiConstants.space4), + Text( + i18n.early_title, + style: UiTypography.body1m.textSecondary, + ), + const SizedBox(height: UiConstants.space1), + Text( + i18n.check_in_at(time: availabilityTime), + style: UiTypography.body2r.textSecondary, + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/nfc_scan_dialog.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/nfc_scan_dialog.dart new file mode 100644 index 00000000..bbf24b05 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/nfc_scan_dialog.dart @@ -0,0 +1,127 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shows the NFC scanning dialog and returns `true` when a scan completes. +/// +/// The dialog is non-dismissible and simulates an NFC tap with a short delay. +/// Returns `false` if the dialog is closed without a successful scan. +Future showNfcScanDialog(BuildContext context) async { + final TranslationsStaffClockInEn i18n = Translations.of( + context, + ).staff.clock_in; + bool scanned = false; + + await showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext dialogContext) { + return StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return AlertDialog( + title: Text( + scanned + ? i18n.nfc_dialog.scanned_title + : i18n.nfc_dialog.scan_title, + ), + content: _NfcDialogContent( + scanned: scanned, + i18n: i18n, + onTapToScan: () async { + setState(() { + scanned = true; + }); + await Future.delayed( + const Duration(milliseconds: 1000), + ); + if (!context.mounted) return; + Navigator.of(dialogContext).pop(); + }, + ), + ); + }, + ); + }, + ); + + return scanned; +} + +/// Internal content widget for the NFC scan dialog. +/// +/// Displays the scan icon/status and a tap-to-scan button. +class _NfcDialogContent extends StatelessWidget { + const _NfcDialogContent({ + required this.scanned, + required this.i18n, + required this.onTapToScan, + }); + + /// Whether an NFC tag has been scanned. + final bool scanned; + + /// Localization accessor for clock-in strings. + final TranslationsStaffClockInEn i18n; + + /// Called when the user taps the scan button. + final VoidCallback onTapToScan; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 96, + height: 96, + decoration: BoxDecoration( + color: scanned ? UiColors.tagSuccess : UiColors.tagInProgress, + shape: BoxShape.circle, + ), + child: Icon( + scanned ? UiIcons.check : UiIcons.nfc, + size: 48, + color: scanned ? UiColors.textSuccess : UiColors.primary, + ), + ), + const SizedBox(height: UiConstants.space6), + Text( + scanned + ? i18n.nfc_dialog.processing + : i18n.nfc_dialog.ready_to_scan, + style: UiTypography.headline4m, + ), + const SizedBox(height: UiConstants.space2), + Text( + scanned + ? i18n.nfc_dialog.please_wait + : i18n.nfc_dialog.scan_instruction, + textAlign: TextAlign.center, + style: UiTypography.body2r.textSecondary, + ), + if (!scanned) ...[ + const SizedBox(height: UiConstants.space6), + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: onTapToScan, + icon: const Icon(UiIcons.nfc, size: 24), + label: Text( + i18n.nfc_dialog.tap_to_scan, + style: UiTypography.headline4m.white, + ), + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.primary, + foregroundColor: UiColors.white, + shape: RoundedRectangleBorder( + borderRadius: UiConstants.radiusLg, + ), + ), + ), + ), + ], + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/no_shifts_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/no_shifts_banner.dart new file mode 100644 index 00000000..d6b26227 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/no_shifts_banner.dart @@ -0,0 +1,42 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Placeholder banner shown when there are no shifts scheduled for today. +/// +/// Encourages the user to browse available shifts. +class NoShiftsBanner extends StatelessWidget { + /// Creates a no-shifts banner. + const NoShiftsBanner({super.key}); + + @override + Widget build(BuildContext context) { + final TranslationsStaffClockInEn i18n = Translations.of( + context, + ).staff.clock_in; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(UiConstants.space6), + decoration: BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: UiConstants.radiusLg, + ), + child: Column( + children: [ + Text( + i18n.no_shifts_today, + style: UiTypography.body1m.textSecondary, + textAlign: TextAlign.center, + ), + const SizedBox(height: UiConstants.space1), + Text( + i18n.accept_shift_cta, + style: UiTypography.body2r.textSecondary, + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart new file mode 100644 index 00000000..fc63f090 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart @@ -0,0 +1,123 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'clock_in_helpers.dart'; + +/// A selectable card that displays a single shift's summary information. +/// +/// Shows the shift title, client/location, time range, and hourly rate. +/// Highlights with a primary border when [isSelected] is true. +class ShiftCard extends StatelessWidget { + /// Creates a shift card for the given [shift]. + const ShiftCard({ + required this.shift, + required this.isSelected, + required this.onTap, + super.key, + }); + + /// The shift to display. + final Shift shift; + + /// Whether this card is currently selected. + final bool isSelected; + + /// Called when the user taps this card. + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final TranslationsStaffClockInEn i18n = Translations.of( + context, + ).staff.clock_in; + + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(UiConstants.space3), + margin: const EdgeInsets.only(bottom: UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all( + color: isSelected ? UiColors.primary : UiColors.border, + width: isSelected ? 2 : 1, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _ShiftDetails(shift: shift, isSelected: isSelected, i18n: i18n)), + _ShiftTimeAndRate(shift: shift), + ], + ), + ), + ); + } +} + +/// Displays the shift title, client name, and location on the left side. +class _ShiftDetails extends StatelessWidget { + const _ShiftDetails({ + required this.shift, + required this.isSelected, + required this.i18n, + }); + + /// The shift whose details to display. + final Shift shift; + + /// Whether the parent card is selected. + final bool isSelected; + + /// Localization accessor for clock-in strings. + final TranslationsStaffClockInEn i18n; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isSelected ? i18n.selected_shift_badge : i18n.today_shift_badge, + style: UiTypography.titleUppercase4b.copyWith( + color: isSelected ? UiColors.primary : UiColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Text(shift.title, style: UiTypography.body2b), + Text( + '${shift.clientName} ${shift.location}', + style: UiTypography.body3r.textSecondary, + ), + ], + ); + } +} + +/// Displays the shift time range and hourly rate on the right side. +class _ShiftTimeAndRate extends StatelessWidget { + const _ShiftTimeAndRate({required this.shift}); + + /// The shift whose time and rate to display. + final Shift shift; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${ClockInHelpers.formatTime(shift.startTime)} - ${ClockInHelpers.formatTime(shift.endTime)}', + style: UiTypography.body3m.textSecondary, + ), + Text( + '\$${shift.hourlyRate}/hr', + style: UiTypography.body3m.copyWith(color: UiColors.primary), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card_list.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card_list.dart new file mode 100644 index 00000000..8dfa7fb7 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card_list.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'shift_card.dart'; + +/// Renders a vertical list of [ShiftCard] widgets for today's shifts. +/// +/// Highlights the currently selected shift and notifies the parent +/// when a different shift is tapped. +class ShiftCardList extends StatelessWidget { + /// Creates a shift card list from [shifts]. + const ShiftCardList({ + required this.shifts, + required this.selectedShiftId, + required this.onShiftSelected, + super.key, + }); + + /// All shifts to display. + final List shifts; + + /// The ID of the currently selected shift, if any. + final String? selectedShiftId; + + /// Called when the user taps a shift card. + final ValueChanged onShiftSelected; + + @override + Widget build(BuildContext context) { + return Column( + children: shifts + .map( + (Shift shift) => ShiftCard( + shift: shift, + isSelected: shift.id == selectedShiftId, + onTap: () => onShiftSelected(shift), + ), + ) + .toList(), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_completed_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_completed_banner.dart new file mode 100644 index 00000000..add07b7a --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_completed_banner.dart @@ -0,0 +1,50 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Success banner displayed after a shift has been completed. +/// +/// Shows a check icon with congratulatory text in a green-tinted container. +class ShiftCompletedBanner extends StatelessWidget { + /// Creates a shift completed banner. + const ShiftCompletedBanner({super.key}); + + @override + Widget build(BuildContext context) { + final TranslationsStaffClockInEn i18n = Translations.of( + context, + ).staff.clock_in; + + return Container( + padding: const EdgeInsets.all(UiConstants.space6), + decoration: BoxDecoration( + color: UiColors.tagSuccess, + borderRadius: UiConstants.radiusLg, + border: Border.all( + color: UiColors.success.withValues(alpha: 0.3), + ), + ), + child: Column( + children: [ + Container( + width: 48, + height: 48, + decoration: const BoxDecoration( + color: UiColors.tagActive, + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.check, + color: UiColors.textSuccess, + size: 24, + ), + ), + const SizedBox(height: UiConstants.space3), + Text(i18n.shift_completed, style: UiTypography.body1b.textSuccess), + const SizedBox(height: UiConstants.space1), + Text(i18n.great_work, style: UiTypography.body2r.textSuccess), + ], + ), + ); + } +} From 2c1c71ad01a74ecfa3c3dd2259b5254b2219db6c Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 13 Mar 2026 12:14:06 -0400 Subject: [PATCH 02/33] refactor: simplify widget structure and improve date selection logic in clock-in features --- .../widgets/clock_in_action_section.dart | 1 - .../presentation/widgets/date_selector.dart | 103 +++++++------- .../pages/shift_details_page.dart | 4 + .../shift_details/shift_details_header.dart | 130 +++++++----------- 4 files changed, 107 insertions(+), 131 deletions(-) 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 b94f2da3..892f4502 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 @@ -4,7 +4,6 @@ import 'package:krow_domain/krow_domain.dart'; import '../bloc/clock_in_bloc.dart'; import '../bloc/clock_in_event.dart'; -import '../bloc/clock_in_state.dart'; import 'clock_in_helpers.dart'; import 'early_check_in_banner.dart'; import 'lunch_break_modal.dart'; diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/date_selector.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/date_selector.dart index 2d849477..c91be1a4 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/date_selector.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/date_selector.dart @@ -16,7 +16,7 @@ class DateSelector extends StatelessWidget { @override Widget build(BuildContext context) { final DateTime today = DateTime.now(); - final List dates = List.generate(7, (int index) { + final List dates = List.generate(7, (int index) { return today.add(Duration(days: index - 3)); }); @@ -31,7 +31,7 @@ class DateSelector extends StatelessWidget { return Expanded( child: GestureDetector( - onTap: () => onSelect(date), + onTap: isToday ? () => onSelect(date) : null, child: AnimatedContainer( duration: const Duration(milliseconds: 200), margin: const EdgeInsets.symmetric( @@ -40,58 +40,55 @@ class DateSelector extends StatelessWidget { decoration: BoxDecoration( color: isSelected ? UiColors.primary : UiColors.white, borderRadius: UiConstants.radiusLg, - boxShadow: isSelected - ? [ - BoxShadow( - color: UiColors.primary.withValues(alpha: 0.3), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ] - : [], ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - DateFormat('d').format(date), - style: UiTypography.title1m.copyWith( - fontWeight: FontWeight.bold, - color: - isSelected ? UiColors.white : UiColors.foreground, - ), - ), - const SizedBox(height: 2), - Text( - DateFormat('E').format(date), - style: UiTypography.footnote2r.copyWith( - color: isSelected - ? UiColors.white.withValues(alpha: 0.8) - : UiColors.textInactive, - ), - ), - const SizedBox(height: UiConstants.space1), - if (hasShift) - Container( - width: 6, - height: 6, - decoration: BoxDecoration( - color: isSelected ? UiColors.white : UiColors.primary, - shape: BoxShape.circle, + child: Opacity( + opacity: isToday ? 1.0 : 0.4, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + DateFormat('d').format(date), + style: UiTypography.title1m.copyWith( + fontWeight: FontWeight.bold, + color: isSelected + ? UiColors.white + : UiColors.foreground, ), - ) - else if (isToday && !isSelected) - Container( - width: 6, - height: 6, - decoration: const BoxDecoration( - color: UiColors.border, - shape: BoxShape.circle, + ), + const SizedBox(height: 2), + Text( + DateFormat('E').format(date), + style: UiTypography.footnote2r.copyWith( + color: isSelected + ? UiColors.white.withValues(alpha: 0.8) + : UiColors.textInactive, ), - ) - else - const SizedBox(height: 6), - ], + ), + const SizedBox(height: UiConstants.space1), + if (hasShift) + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: isSelected + ? UiColors.white + : UiColors.primary, + shape: BoxShape.circle, + ), + ) + else if (isToday && !isSelected) + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: UiColors.border, + shape: BoxShape.circle, + ), + ) + else + const SizedBox(height: UiConstants.space3), + ], + ), ), ), ), @@ -100,11 +97,13 @@ class DateSelector extends StatelessWidget { ), ); } - + + /// Helper to check if two dates are on the same calendar day (ignoring time). bool _isSameDay(DateTime a, DateTime b) { return a.year == b.year && a.month == b.month && a.day == b.day; } + /// Formats a [DateTime] as an ISO date string (yyyy-MM-dd) for comparison with shift dates. String _formatDateIso(DateTime date) { return DateFormat('yyyy-MM-dd').format(date); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart index 0a56ae04..15b28f85 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart @@ -155,7 +155,9 @@ class _ShiftDetailsPageState extends State { ), ), ShiftDetailsHeader(shift: displayShift), + const Divider(height: 1, thickness: 0.5), + ShiftStatsRow( estimatedTotal: estimatedTotal, hourlyRate: displayShift.hourlyRate, @@ -164,7 +166,9 @@ class _ShiftDetailsPageState extends State { hourlyRateLabel: i18n.hourly_rate, hoursLabel: i18n.hours, ), + const Divider(height: 1, thickness: 0.5), + ShiftDateTimeSection( date: displayShift.date, endDate: displayShift.endDate, diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart index 4d6458f7..ea594220 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart @@ -8,93 +8,67 @@ class ShiftDetailsHeader extends StatelessWidget { final Shift shift; /// Creates a [ShiftDetailsHeader]. - const ShiftDetailsHeader({ - super.key, - required this.shift, - }); + const ShiftDetailsHeader({super.key, required this.shift}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(UiConstants.space5), - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: UiConstants.space4, - children: [ - Container( - width: 114, - decoration: BoxDecoration( - color: UiColors.primary.withAlpha(20), - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: UiConstants.space4, + children: [ + // Icon + role name + client name + Row( + crossAxisAlignment: CrossAxisAlignment.center, + spacing: UiConstants.space4, + children: [ + Container( + width: 68, + height: 68, + decoration: BoxDecoration( + color: UiColors.primary.withAlpha(20), + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.primary, width: 0.5), ), - border: Border.all(color: UiColors.primary), - ), - child: const Center( - child: Icon( - UiIcons.briefcase, - color: UiColors.primary, - size: 24, + child: const Center( + child: Icon( + UiIcons.briefcase, + color: UiColors.primary, + size: 20, + ), ), ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - spacing: UiConstants.space3, - children: [ - Text( - shift.title, - style: UiTypography.headline1b.textPrimary, - ), - Column( - spacing: UiConstants.space1, - children: [ - // Client name - Row( - spacing: UiConstants.space1, - children: [ - const Icon( - UiIcons.building, - size: 16, - color: UiColors.textSecondary, - ), - Expanded( - child: Text( - shift.clientName, - style: UiTypography.body1m.textSecondary, - ), - ), - ], - ), - - // Location address (if available) - Row( - spacing: UiConstants.space1, - children: [ - const Icon( - UiIcons.mapPin, - size: 16, - color: UiColors.textSecondary, - ), - Expanded( - child: Text( - shift.locationAddress, - style: UiTypography.body2r.textSecondary, - ), - ), - ], - ), - ], - ), - - ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(shift.title, style: UiTypography.headline1b.textPrimary), + Text(shift.clientName, style: UiTypography.body1m.textSecondary), + ], + ), ), - ), - ], - ), + ], + ), + + // Location address + Row( + spacing: UiConstants.space1, + children: [ + const Icon( + UiIcons.mapPin, + size: 16, + color: UiColors.textSecondary, + ), + Expanded( + child: Text( + shift.locationAddress, + style: UiTypography.body2r.textSecondary, + ), + ), + ], + ), + ], ), ); } From 2fc6b3139e0dace689bd7beab1a02bacf1a1e080 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 13 Mar 2026 13:08:03 -0400 Subject: [PATCH 03/33] refactor: reorder import statements for better organization in clock_in_bloc.dart --- .../lib/src/presentation/bloc/clock_in_bloc.dart | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart index 5f5c3650..6c8002e3 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart @@ -2,12 +2,13 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:geolocator/geolocator.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/usecases/get_todays_shift_usecase.dart'; -import '../../domain/usecases/get_attendance_status_usecase.dart'; -import '../../domain/usecases/clock_in_usecase.dart'; -import '../../domain/usecases/clock_out_usecase.dart'; + import '../../domain/arguments/clock_in_arguments.dart'; import '../../domain/arguments/clock_out_arguments.dart'; +import '../../domain/usecases/clock_in_usecase.dart'; +import '../../domain/usecases/clock_out_usecase.dart'; +import '../../domain/usecases/get_attendance_status_usecase.dart'; +import '../../domain/usecases/get_todays_shift_usecase.dart'; import 'clock_in_event.dart'; import 'clock_in_state.dart'; From 7b576c0ed4091850dd7223888ab26fafebf049d6 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 13 Mar 2026 16:01:26 -0400 Subject: [PATCH 04/33] feat: Implement notification and storage services, geofence management, and BLoC for geofence verification - Add NotificationService for handling local notifications. - Introduce StorageService for key-value storage using SharedPreferences. - Create DeviceLocation model to represent geographic locations. - Define LocationPermissionStatus enum for managing location permissions. - Implement BackgroundGeofenceService for periodic geofence checks while clocked in. - Develop GeofenceServiceImpl for geofence proximity verification using LocationService. - Create GeofenceResult model to encapsulate geofence check results. - Define GeofenceServiceInterface for geofence service abstraction. - Implement GeofenceBloc to manage geofence verification and background tracking. - Create events and states for GeofenceBloc to handle various geofence scenarios. - Add GeofenceStatusBanner widget to display geofence verification status in the UI. --- .claude/agents/architecture-reviewer.md | 2 +- .../apps/client/android/app/build.gradle.kts | 5 + .../plugins/GeneratedPluginRegistrant.java | 20 ++ .../ios/Runner/GeneratedPluginRegistrant.m | 28 ++ .../Flutter/GeneratedPluginRegistrant.swift | 6 + .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 2 + .../apps/staff/android/app/build.gradle.kts | 5 + .../android/app/src/main/AndroidManifest.xml | 4 + .../plugins/GeneratedPluginRegistrant.java | 20 +- .../ios/Runner/GeneratedPluginRegistrant.m | 22 +- apps/mobile/apps/staff/ios/Runner/Info.plist | 8 + apps/mobile/apps/staff/lib/main.dart | 18 + .../Flutter/GeneratedPluginRegistrant.swift | 4 + apps/mobile/apps/staff/pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 3 - .../windows/flutter/generated_plugins.cmake | 2 +- apps/mobile/packages/core/lib/core.dart | 4 + .../packages/core/lib/src/core_module.dart | 8 + .../background_task_service.dart | 58 ++++ .../device/location/location_service.dart | 110 ++++++ .../notification/notification_service.dart | 66 ++++ .../device/storage/storage_service.dart | 81 +++++ apps/mobile/packages/core/pubspec.yaml | 4 + .../lib/src/l10n/en.i18n.json | 26 ++ .../lib/src/l10n/es.i18n.json | 26 ++ .../lib/src/utils/error_translator.dart | 14 + .../packages/domain/lib/krow_domain.dart | 4 + .../lib/src/core/models/device_location.dart | 27 ++ .../device/location_permission_status.dart | 17 + .../Flutter/GeneratedPluginRegistrant.swift | 6 + .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 2 + .../services/background_geofence_service.dart | 121 +++++++ .../data/services/geofence_service_impl.dart | 136 ++++++++ .../src/domain/models/geofence_result.dart | 33 ++ .../services/geofence_service_interface.dart | 36 ++ .../src/presentation/bloc/clock_in_bloc.dart | 149 +++----- .../src/presentation/bloc/clock_in_event.dart | 65 ++-- .../src/presentation/bloc/clock_in_state.dart | 55 ++- .../src/presentation/bloc/geofence_bloc.dart | 262 ++++++++++++++ .../src/presentation/bloc/geofence_event.dart | 106 ++++++ .../src/presentation/bloc/geofence_state.dart | 95 +++++ .../presentation/blocs/clock_in_cubit.dart | 155 --------- .../src/presentation/pages/clock_in_page.dart | 18 +- .../widgets/check_in_mode_tab.dart | 2 +- .../widgets/clock_in_action_section.dart | 142 ++++++-- .../presentation/widgets/clock_in_body.dart | 176 ++++++---- .../widgets/geofence_status_banner.dart | 324 ++++++++++++++++++ .../presentation/widgets/nfc_scan_dialog.dart | 4 +- .../widgets/swipe_to_check_in.dart | 7 - .../lib/src/staff_clock_in_module.dart | 32 +- .../features/staff/clock_in/pubspec.yaml | 2 - apps/mobile/pubspec.lock | 180 +++++++--- 54 files changed, 2216 insertions(+), 493 deletions(-) create mode 100644 apps/mobile/packages/core/lib/src/services/device/background_task/background_task_service.dart create mode 100644 apps/mobile/packages/core/lib/src/services/device/location/location_service.dart create mode 100644 apps/mobile/packages/core/lib/src/services/device/notification/notification_service.dart create mode 100644 apps/mobile/packages/core/lib/src/services/device/storage/storage_service.dart create mode 100644 apps/mobile/packages/domain/lib/src/core/models/device_location.dart create mode 100644 apps/mobile/packages/domain/lib/src/core/services/device/location_permission_status.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/data/services/geofence_service_impl.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/domain/models/geofence_result.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/domain/services/geofence_service_interface.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_bloc.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_event.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_state.dart delete mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/blocs/clock_in_cubit.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner.dart diff --git a/.claude/agents/architecture-reviewer.md b/.claude/agents/architecture-reviewer.md index ebbffb75..c0c7b2a4 100644 --- a/.claude/agents/architecture-reviewer.md +++ b/.claude/agents/architecture-reviewer.md @@ -54,7 +54,7 @@ and load any additional skills as needed for specific review challenges. 2. Standalone custom `TextStyle(...)` — must use design system typography 3. Hardcoded spacing values — must use design system spacing constants 4. Direct icon library imports — must use design system icon abstractions -5. Direct `Navigator.push/pop/replace` usage — must use safe navigation extensions +5. Direct `Navigator.push/pop/replace` usage — must use safe navigation extensions from the `apps/mobile/packages/core/lib/src/routing/navigation_extensions.dart`. 6. Missing tests for use cases or repositories 7. Complex BLoC without bloc_test coverage 8. Test coverage below 70% for business logic diff --git a/apps/mobile/apps/client/android/app/build.gradle.kts b/apps/mobile/apps/client/android/app/build.gradle.kts index 837bc911..a6fe31ec 100644 --- a/apps/mobile/apps/client/android/app/build.gradle.kts +++ b/apps/mobile/apps/client/android/app/build.gradle.kts @@ -46,6 +46,7 @@ android { ndkVersion = flutter.ndkVersion compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -122,6 +123,10 @@ afterEvaluate { } } +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} + flutter { source = "../.." } diff --git a/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index e6d40294..bab9899d 100644 --- a/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -35,16 +35,31 @@ public final class GeneratedPluginRegistrant { } catch (Exception e) { Log.e(TAG, "Error registering plugin firebase_core, io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin", e); } + try { + flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin()); } catch (Exception e) { Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e); } + try { + flutterEngine.getPlugins().add(new com.baseflow.geolocator.GeolocatorPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin geolocator_android, com.baseflow.geolocator.GeolocatorPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin()); } catch (Exception e) { Log.e(TAG, "Error registering plugin image_picker_android, io.flutter.plugins.imagepicker.ImagePickerPlugin", e); } + try { + flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin()); } catch (Exception e) { @@ -65,5 +80,10 @@ public final class GeneratedPluginRegistrant { } catch (Exception e) { Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e); } + try { + flutterEngine.getPlugins().add(new dev.fluttercommunity.workmanager.WorkmanagerPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin workmanager_android, dev.fluttercommunity.workmanager.WorkmanagerPlugin", e); + } } } diff --git a/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m b/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m index 241fcf3b..adab234d 100644 --- a/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m +++ b/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m @@ -30,12 +30,30 @@ @import firebase_core; #endif +#if __has_include() +#import +#else +@import flutter_local_notifications; +#endif + +#if __has_include() +#import +#else +@import geolocator_apple; +#endif + #if __has_include() #import #else @import image_picker_ios; #endif +#if __has_include() +#import +#else +@import package_info_plus; +#endif + #if __has_include() #import #else @@ -54,6 +72,12 @@ @import url_launcher_ios; #endif +#if __has_include() +#import +#else +@import workmanager_apple; +#endif + @implementation GeneratedPluginRegistrant + (void)registerWithRegistry:(NSObject*)registry { @@ -61,10 +85,14 @@ [FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]]; [FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]]; [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; + [FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]]; + [GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]]; [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; + [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]]; [RecordIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"RecordIosPlugin"]]; [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; + [WorkmanagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"WorkmanagerPlugin"]]; } @end diff --git a/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift index 1dea22d7..288fbc2c 100644 --- a/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,9 @@ import file_selector_macos import firebase_app_check import firebase_auth import firebase_core +import flutter_local_notifications +import geolocator_apple +import package_info_plus import record_macos import shared_preferences_foundation import url_launcher_macos @@ -20,6 +23,9 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc b/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc index 3c9a7f78..fc95dec8 100644 --- a/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -19,6 +20,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); RecordWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake b/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake index f2ab3101..15f2a4c5 100644 --- a/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake +++ b/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake @@ -6,11 +6,13 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows firebase_auth firebase_core + geolocator_windows record_windows url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/apps/mobile/apps/staff/android/app/build.gradle.kts b/apps/mobile/apps/staff/android/app/build.gradle.kts index 96155fc9..1a350dda 100644 --- a/apps/mobile/apps/staff/android/app/build.gradle.kts +++ b/apps/mobile/apps/staff/android/app/build.gradle.kts @@ -46,6 +46,7 @@ android { ndkVersion = flutter.ndkVersion compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -126,6 +127,10 @@ afterEvaluate { } } +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} + flutter { source = "../.." } diff --git a/apps/mobile/apps/staff/android/app/src/main/AndroidManifest.xml b/apps/mobile/apps/staff/android/app/src/main/AndroidManifest.xml index 9416b135..7e576610 100644 --- a/apps/mobile/apps/staff/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/apps/staff/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,8 @@ + + + + ) +#import +#else +@import flutter_local_notifications; +#endif + #if __has_include() #import #else @@ -48,10 +54,10 @@ @import image_picker_ios; #endif -#if __has_include() -#import +#if __has_include() +#import #else -@import permission_handler_apple; +@import package_info_plus; #endif #if __has_include() @@ -72,6 +78,12 @@ @import url_launcher_ios; #endif +#if __has_include() +#import +#else +@import workmanager_apple; +#endif + @implementation GeneratedPluginRegistrant + (void)registerWithRegistry:(NSObject*)registry { @@ -79,13 +91,15 @@ [FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]]; [FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]]; [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; + [FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]]; [GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]]; [FLTGoogleMapsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTGoogleMapsPlugin"]]; [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; - [PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]]; + [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]]; [RecordIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"RecordIosPlugin"]]; [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; + [WorkmanagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"WorkmanagerPlugin"]]; } @end diff --git a/apps/mobile/apps/staff/ios/Runner/Info.plist b/apps/mobile/apps/staff/ios/Runner/Info.plist index bdc600e2..9bb97fda 100644 --- a/apps/mobile/apps/staff/ios/Runner/Info.plist +++ b/apps/mobile/apps/staff/ios/Runner/Info.plist @@ -45,6 +45,14 @@ UIApplicationSupportsIndirectInputEvents + NSLocationWhenInUseUsageDescription + We need your location to verify you are at your assigned workplace for clock-in. + NSLocationAlwaysAndWhenInUseUsageDescription + We need your location to verify you remain at your assigned workplace during your shift. + NSLocationAlwaysUsageDescription + We need your location to verify you remain at your assigned workplace during your shift. + UIBackgroundModes + location DART_DEFINES $(DART_DEFINES) diff --git a/apps/mobile/apps/staff/lib/main.dart b/apps/mobile/apps/staff/lib/main.dart index a50744c9..19cd106b 100644 --- a/apps/mobile/apps/staff/lib/main.dart +++ b/apps/mobile/apps/staff/lib/main.dart @@ -11,13 +11,31 @@ import 'package:krowwithus_staff/firebase_options.dart'; import 'package:staff_authentication/staff_authentication.dart' as staff_authentication; import 'package:staff_main/staff_main.dart' as staff_main; +import 'package:workmanager/workmanager.dart'; import 'src/widgets/session_listener.dart'; +/// Top-level callback dispatcher for background tasks. +/// +/// Must be a top-level function because workmanager executes it in a separate +/// isolate where the DI container is not available. +@pragma('vm:entry-point') +void callbackDispatcher() { + Workmanager().executeTask((String task, Map? inputData) async { + // Background geofence check placeholder. + // Full implementation will parse inputData for target coordinates + // and perform a proximity check in the background isolate. + return true; + }); +} + void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + // Initialize background task processing for geofence checks + await const BackgroundTaskService().initialize(callbackDispatcher); + // Register global BLoC observer for centralized error logging Bloc.observer = CoreBlocObserver( logEvents: true, diff --git a/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift index e919f640..288fbc2c 100644 --- a/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,7 +10,9 @@ import file_selector_macos import firebase_app_check import firebase_auth import firebase_core +import flutter_local_notifications import geolocator_apple +import package_info_plus import record_macos import shared_preferences_foundation import url_launcher_macos @@ -21,7 +23,9 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/apps/mobile/apps/staff/pubspec.yaml b/apps/mobile/apps/staff/pubspec.yaml index 21c19091..dd289c30 100644 --- a/apps/mobile/apps/staff/pubspec.yaml +++ b/apps/mobile/apps/staff/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: flutter_modular: ^6.3.0 firebase_core: ^4.4.0 flutter_bloc: ^8.1.6 + workmanager: ^0.9.0+3 dev_dependencies: flutter_test: diff --git a/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc b/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc index b6746a97..fc95dec8 100644 --- a/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc @@ -10,7 +10,6 @@ #include #include #include -#include #include #include @@ -23,8 +22,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); GeolocatorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("GeolocatorWindows")); - PermissionHandlerWindowsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); RecordWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake b/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake index 589f702c..15f2a4c5 100644 --- a/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake +++ b/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake @@ -7,12 +7,12 @@ list(APPEND FLUTTER_PLUGIN_LIST firebase_auth firebase_core geolocator_windows - permission_handler_windows record_windows url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/apps/mobile/packages/core/lib/core.dart b/apps/mobile/packages/core/lib/core.dart index e8743adc..f450c6e2 100644 --- a/apps/mobile/packages/core/lib/core.dart +++ b/apps/mobile/packages/core/lib/core.dart @@ -33,3 +33,7 @@ export 'src/services/device/gallery/gallery_service.dart'; export 'src/services/device/file/file_picker_service.dart'; export 'src/services/device/file_upload/device_file_upload_service.dart'; export 'src/services/device/audio/audio_recorder_service.dart'; +export 'src/services/device/location/location_service.dart'; +export 'src/services/device/notification/notification_service.dart'; +export 'src/services/device/storage/storage_service.dart'; +export 'src/services/device/background_task/background_task_service.dart'; diff --git a/apps/mobile/packages/core/lib/src/core_module.dart b/apps/mobile/packages/core/lib/src/core_module.dart index 5c71f6aa..1d2c07ea 100644 --- a/apps/mobile/packages/core/lib/src/core_module.dart +++ b/apps/mobile/packages/core/lib/src/core_module.dart @@ -48,5 +48,13 @@ class CoreModule extends Module { apiUploadService: i.get(), ), ); + + // 6. Register Geofence Device Services + i.addLazySingleton(() => const LocationService()); + i.addLazySingleton(() => NotificationService()); + i.addLazySingleton(() => StorageService()); + i.addLazySingleton( + () => const BackgroundTaskService(), + ); } } diff --git a/apps/mobile/packages/core/lib/src/services/device/background_task/background_task_service.dart b/apps/mobile/packages/core/lib/src/services/device/background_task/background_task_service.dart new file mode 100644 index 00000000..0e753716 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/background_task/background_task_service.dart @@ -0,0 +1,58 @@ +import 'package:krow_domain/krow_domain.dart'; +import 'package:workmanager/workmanager.dart'; + +/// Service that wraps [Workmanager] for scheduling background tasks. +class BackgroundTaskService extends BaseDeviceService { + /// Creates a [BackgroundTaskService] instance. + const BackgroundTaskService(); + + /// Initializes the workmanager with the given [callbackDispatcher]. + Future initialize(Function callbackDispatcher) async { + return action(() async { + await Workmanager().initialize(callbackDispatcher); + }); + } + + /// Registers a periodic background task with the given [frequency]. + Future registerPeriodicTask({ + required String uniqueName, + required String taskName, + Duration frequency = const Duration(minutes: 15), + Map? inputData, + }) async { + return action(() async { + await Workmanager().registerPeriodicTask( + uniqueName, + taskName, + frequency: frequency, + inputData: inputData, + existingWorkPolicy: ExistingPeriodicWorkPolicy.replace, + ); + }); + } + + /// Registers a one-off background task. + Future registerOneOffTask({ + required String uniqueName, + required String taskName, + Map? inputData, + }) async { + return action(() async { + await Workmanager().registerOneOffTask( + uniqueName, + taskName, + inputData: inputData, + ); + }); + } + + /// Cancels a registered task by its [uniqueName]. + Future cancelByUniqueName(String uniqueName) async { + return action(() => Workmanager().cancelByUniqueName(uniqueName)); + } + + /// Cancels all registered background tasks. + Future cancelAll() async { + return action(() => Workmanager().cancelAll()); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/device/location/location_service.dart b/apps/mobile/packages/core/lib/src/services/device/location/location_service.dart new file mode 100644 index 00000000..2b583079 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/location/location_service.dart @@ -0,0 +1,110 @@ +import 'dart:async'; + +import 'package:geolocator/geolocator.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Service that wraps [Geolocator] to provide location access. +/// +/// This is the only file in the core package that imports geolocator. +/// All location access across the app should go through this service. +class LocationService extends BaseDeviceService { + /// Creates a [LocationService] instance. + const LocationService(); + + /// Checks the current permission status and requests permission if needed. + Future checkAndRequestPermission() async { + return action(() async { + final bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) return LocationPermissionStatus.serviceDisabled; + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + + return _mapPermission(permission); + }); + } + + /// Requests upgrade to "Always" permission for background location access. + Future requestAlwaysPermission() async { + return action(() async { + // On Android, requesting permission again after whileInUse prompts + // for Always. + final LocationPermission permission = await Geolocator.requestPermission(); + return _mapPermission(permission); + }); + } + + /// Returns the device's current location. + Future getCurrentLocation() async { + return action(() async { + final Position position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + return _toDeviceLocation(position); + }); + } + + /// Emits location updates as a stream, filtered by [distanceFilter] meters. + Stream watchLocation({int distanceFilter = 10}) { + return Geolocator.getPositionStream( + locationSettings: LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: distanceFilter, + ), + ).map(_toDeviceLocation); + } + + /// Whether device location services are currently enabled. + Future isServiceEnabled() async { + return action(() => Geolocator.isLocationServiceEnabled()); + } + + /// Stream that emits when location service status changes. + /// + /// Emits `true` when enabled, `false` when disabled. + Stream get onServiceStatusChanged { + return Geolocator.getServiceStatusStream().map( + (ServiceStatus status) => status == ServiceStatus.enabled, + ); + } + + /// Opens the app settings page for the user to manually grant permissions. + Future openAppSettings() async { + return action(() => Geolocator.openAppSettings()); + } + + /// Opens the device location settings page. + Future openLocationSettings() async { + return action(() => Geolocator.openLocationSettings()); + } + + /// Maps a [LocationPermission] to a [LocationPermissionStatus]. + LocationPermissionStatus _mapPermission(LocationPermission permission) { + switch (permission) { + case LocationPermission.always: + return LocationPermissionStatus.granted; + case LocationPermission.whileInUse: + return LocationPermissionStatus.whileInUse; + case LocationPermission.denied: + return LocationPermissionStatus.denied; + case LocationPermission.deniedForever: + return LocationPermissionStatus.deniedForever; + case LocationPermission.unableToDetermine: + return LocationPermissionStatus.denied; + } + } + + /// Converts a geolocator [Position] to a [DeviceLocation]. + DeviceLocation _toDeviceLocation(Position position) { + return DeviceLocation( + latitude: position.latitude, + longitude: position.longitude, + accuracy: position.accuracy, + timestamp: position.timestamp, + ); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/device/notification/notification_service.dart b/apps/mobile/packages/core/lib/src/services/device/notification/notification_service.dart new file mode 100644 index 00000000..d54796ab --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/notification/notification_service.dart @@ -0,0 +1,66 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Service that wraps [FlutterLocalNotificationsPlugin] for local notifications. +class NotificationService extends BaseDeviceService { + + /// Creates a [NotificationService] with the given [plugin] instance. + /// + /// If no plugin is provided, a default instance is created. + NotificationService({FlutterLocalNotificationsPlugin? plugin}) + : _plugin = plugin ?? FlutterLocalNotificationsPlugin(); + /// The underlying notification plugin instance. + final FlutterLocalNotificationsPlugin _plugin; + + /// Initializes notification channels and requests permissions. + Future initialize() async { + return action(() async { + const AndroidInitializationSettings androidSettings = AndroidInitializationSettings( + '@mipmap/ic_launcher', + ); + const DarwinInitializationSettings iosSettings = DarwinInitializationSettings( + requestAlertPermission: true, + requestBadgePermission: true, + requestSoundPermission: true, + ); + const InitializationSettings settings = InitializationSettings( + android: androidSettings, + iOS: iosSettings, + ); + await _plugin.initialize(settings: settings); + }); + } + + /// Displays a local notification with the given [title] and [body]. + Future showNotification({ + required String title, + required String body, + int id = 0, + }) async { + return action(() async { + const AndroidNotificationDetails androidDetails = AndroidNotificationDetails( + 'krow_geofence', + 'Geofence Notifications', + channelDescription: 'Notifications for geofence events', + importance: Importance.high, + priority: Priority.high, + ); + const DarwinNotificationDetails iosDetails = DarwinNotificationDetails(); + const NotificationDetails details = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + await _plugin.show(id: id, title: title, body: body, notificationDetails: details); + }); + } + + /// Cancels a specific notification by [id]. + Future cancelNotification(int id) async { + return action(() => _plugin.cancel(id: id)); + } + + /// Cancels all active notifications. + Future cancelAll() async { + return action(() => _plugin.cancelAll()); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/device/storage/storage_service.dart b/apps/mobile/packages/core/lib/src/services/device/storage/storage_service.dart new file mode 100644 index 00000000..5f14f7f5 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/storage/storage_service.dart @@ -0,0 +1,81 @@ +import 'package:krow_domain/krow_domain.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Service that wraps [SharedPreferences] for key-value storage. +class StorageService extends BaseDeviceService { + + /// Creates a [StorageService] instance. + StorageService(); + /// Cached preferences instance. + SharedPreferences? _prefs; + + /// Returns the [SharedPreferences] instance, initializing lazily. + Future get _preferences async { + _prefs ??= await SharedPreferences.getInstance(); + return _prefs!; + } + + /// Retrieves a string value for the given [key]. + Future getString(String key) async { + return action(() async { + final SharedPreferences prefs = await _preferences; + return prefs.getString(key); + }); + } + + /// Stores a string [value] for the given [key]. + Future setString(String key, String value) async { + return action(() async { + final SharedPreferences prefs = await _preferences; + return prefs.setString(key, value); + }); + } + + /// Retrieves a double value for the given [key]. + Future getDouble(String key) async { + return action(() async { + final SharedPreferences prefs = await _preferences; + return prefs.getDouble(key); + }); + } + + /// Stores a double [value] for the given [key]. + Future setDouble(String key, double value) async { + return action(() async { + final SharedPreferences prefs = await _preferences; + return prefs.setDouble(key, value); + }); + } + + /// Retrieves a boolean value for the given [key]. + Future getBool(String key) async { + return action(() async { + final SharedPreferences prefs = await _preferences; + return prefs.getBool(key); + }); + } + + /// Stores a boolean [value] for the given [key]. + Future setBool(String key, bool value) async { + return action(() async { + final SharedPreferences prefs = await _preferences; + return prefs.setBool(key, value); + }); + } + + /// Removes the value for the given [key]. + Future remove(String key) async { + return action(() async { + final SharedPreferences prefs = await _preferences; + return prefs.remove(key); + }); + } + + /// Clears all stored values. + Future clear() async { + return action(() async { + final SharedPreferences prefs = await _preferences; + return prefs.clear(); + }); + } +} diff --git a/apps/mobile/packages/core/pubspec.yaml b/apps/mobile/packages/core/pubspec.yaml index 15f91f58..347e45af 100644 --- a/apps/mobile/packages/core/pubspec.yaml +++ b/apps/mobile/packages/core/pubspec.yaml @@ -27,3 +27,7 @@ dependencies: file_picker: ^8.1.7 record: ^6.2.0 firebase_auth: ^6.1.4 + geolocator: ^14.0.2 + flutter_local_notifications: ^21.0.0 + shared_preferences: ^2.5.4 + workmanager: ^0.9.0+3 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 7178240d..24f8e555 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 @@ -926,6 +926,28 @@ "submit": "Submit", "success_title": "Break Logged!", "close": "Close" + }, + "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.", + "open_settings": "Open Settings", + "grant_permission": "Grant Permission", + "verifying": "Verifying your location...", + "too_far_title": "You're Too Far Away", + "too_far_desc": "You are $distance away. Move within 500m to clock in.", + "verified": "Location Verified", + "not_in_range": "You must be at the workplace to clock in.", + "timeout_title": "Can't Verify Location", + "timeout_desc": "Unable to determine your location. You can still clock in with a note.", + "timeout_note_hint": "Why can't your location be verified?", + "clock_in_greeting_title": "You're Clocked In!", + "clock_in_greeting_body": "Have a great shift. We'll keep track of your location.", + "background_left_title": "You've Left the Workplace", + "background_left_body": "You appear to be more than 500m from your shift location.", + "always_permission_title": "Background Location Needed", + "always_permission_desc": "To verify your location during shifts, please allow location access 'Always'.", + "retry": "Retry" } }, "availability": { @@ -1416,6 +1438,10 @@ "application_not_found": "Your application couldn't be found.", "no_active_shift": "You don't have an active shift to clock out from." }, + "clock_in": { + "location_verification_required": "Please wait for location verification before clocking in.", + "notes_required_for_timeout": "Please add a note explaining why your location can't be verified." + }, "generic": { "unknown": "Something went wrong. Please try again.", "no_connection": "No internet connection. Please check your network and try again.", 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 5fce4a09..858249f1 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 @@ -921,6 +921,28 @@ "submit": "Enviar", "success_title": "\u00a1Descanso registrado!", "close": "Cerrar" + }, + "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.", + "open_settings": "Abrir Configuración", + "grant_permission": "Otorgar Permiso", + "verifying": "Verificando su ubicación...", + "too_far_title": "Está Demasiado Lejos", + "too_far_desc": "Está a $distance de distancia. Acérquese a 500m para registrar entrada.", + "verified": "Ubicación Verificada", + "not_in_range": "Debe estar en el lugar de trabajo para registrar entrada.", + "timeout_title": "No se Puede Verificar la Ubicación", + "timeout_desc": "No se pudo determinar su ubicación. Puede registrar entrada con una nota.", + "timeout_note_hint": "¿Por qué no se puede verificar su ubicación?", + "clock_in_greeting_title": "¡Entrada Registrada!", + "clock_in_greeting_body": "Buen turno. Seguiremos el registro de su ubicación.", + "background_left_title": "Ha Salido del Lugar de Trabajo", + "background_left_body": "Parece que está a más de 500m de la ubicación de su turno.", + "always_permission_title": "Se Necesita Ubicación en Segundo Plano", + "always_permission_desc": "Para verificar su ubicación durante los turnos, permita el acceso a la ubicación 'Siempre'.", + "retry": "Reintentar" } }, "availability": { @@ -1411,6 +1433,10 @@ "application_not_found": "No se pudo encontrar tu solicitud.", "no_active_shift": "No tienes un turno activo para registrar salida." }, + "clock_in": { + "location_verification_required": "Por favor, espera la verificaci\u00f3n de ubicaci\u00f3n antes de registrar entrada.", + "notes_required_for_timeout": "Por favor, agrega una nota explicando por qu\u00e9 no se puede verificar tu ubicaci\u00f3n." + }, "generic": { "unknown": "Algo sali\u00f3 mal. Por favor, intenta de nuevo.", "no_connection": "Sin conexi\u00f3n a internet. Por favor, verifica tu red e intenta de nuevo.", diff --git a/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart b/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart index 5e7df68d..69e4282d 100644 --- a/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart +++ b/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart @@ -35,6 +35,8 @@ String translateErrorKey(String key) { return _translateProfileError(errorType); case 'shift': return _translateShiftError(errorType); + case 'clock_in': + return _translateClockInError(errorType); case 'generic': return _translateGenericError(errorType); default: @@ -127,6 +129,18 @@ String _translateShiftError(String errorType) { } } +/// Translates clock-in error keys to localized strings. +String _translateClockInError(String errorType) { + switch (errorType) { + case 'location_verification_required': + return t.errors.clock_in.location_verification_required; + case 'notes_required_for_timeout': + return t.errors.clock_in.notes_required_for_timeout; + default: + return t.errors.generic.unknown; + } +} + String _translateGenericError(String errorType) { switch (errorType) { case 'unknown': diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index 87b22493..c98147f3 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -14,6 +14,10 @@ export 'src/core/services/api_services/file_visibility.dart'; // Device export 'src/core/services/device/base_device_service.dart'; +export 'src/core/services/device/location_permission_status.dart'; + +// Models +export 'src/core/models/device_location.dart'; // Users & Membership export 'src/entities/users/user.dart'; diff --git a/apps/mobile/packages/domain/lib/src/core/models/device_location.dart b/apps/mobile/packages/domain/lib/src/core/models/device_location.dart new file mode 100644 index 00000000..0f83b3b7 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/core/models/device_location.dart @@ -0,0 +1,27 @@ +import 'package:equatable/equatable.dart'; + +/// Represents a geographic location obtained from the device. +class DeviceLocation extends Equatable { + /// Latitude in degrees. + final double latitude; + + /// Longitude in degrees. + final double longitude; + + /// Estimated horizontal accuracy in meters. + final double accuracy; + + /// Time when this location was determined. + final DateTime timestamp; + + /// Creates a [DeviceLocation] instance. + const DeviceLocation({ + required this.latitude, + required this.longitude, + required this.accuracy, + required this.timestamp, + }); + + @override + List get props => [latitude, longitude, accuracy, timestamp]; +} diff --git a/apps/mobile/packages/domain/lib/src/core/services/device/location_permission_status.dart b/apps/mobile/packages/domain/lib/src/core/services/device/location_permission_status.dart new file mode 100644 index 00000000..e9b5ff97 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/core/services/device/location_permission_status.dart @@ -0,0 +1,17 @@ +/// Represents the current state of location permission granted by the user. +enum LocationPermissionStatus { + /// Full location access granted. + granted, + + /// Location access granted only while the app is in use. + whileInUse, + + /// Location permission was denied by the user. + denied, + + /// Location permission was permanently denied by the user. + deniedForever, + + /// Device location services are disabled. + serviceDisabled, +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift index 3eb92bc4..36b8a0c0 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,9 @@ import file_selector_macos import firebase_app_check import firebase_auth import firebase_core +import flutter_local_notifications +import geolocator_apple +import package_info_plus import record_macos import shared_preferences_foundation @@ -19,6 +22,9 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.cc b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.cc index ec331e03..2406d471 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); RecordWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); } diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugins.cmake b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugins.cmake index 0125068a..0c9f9e28 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugins.cmake +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugins.cmake @@ -6,10 +6,12 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows firebase_auth firebase_core + geolocator_windows record_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart new file mode 100644 index 00000000..d3a8e792 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart @@ -0,0 +1,121 @@ +import 'package:krow_core/core.dart'; + +/// Service that manages periodic background geofence checks while clocked in. +/// +/// Uses core services exclusively -- no direct imports of workmanager, +/// flutter_local_notifications, or shared_preferences. +class BackgroundGeofenceService { + /// The core background task service for scheduling periodic work. + final BackgroundTaskService _backgroundTaskService; + + /// The core notification service for displaying local notifications. + final NotificationService _notificationService; + + /// The core storage service for persisting geofence target data. + final StorageService _storageService; + + /// Storage key for the target latitude. + static const _keyTargetLat = 'geofence_target_lat'; + + /// Storage key for the target longitude. + static const _keyTargetLng = 'geofence_target_lng'; + + /// Storage key for the shift identifier. + static const _keyShiftId = 'geofence_shift_id'; + + /// Storage key for the active tracking flag. + static const _keyTrackingActive = 'geofence_tracking_active'; + + /// Unique task name for the periodic background check. + static const taskUniqueName = 'geofence_background_check'; + + /// Task name identifier for the workmanager callback. + static const taskName = 'geofenceCheck'; + + /// Notification ID for clock-in greeting notifications. + static const _clockInNotificationId = 1; + + /// Notification ID for left-geofence warnings. + static const _leftGeofenceNotificationId = 2; + + /// Creates a [BackgroundGeofenceService] instance. + BackgroundGeofenceService({ + required BackgroundTaskService backgroundTaskService, + required NotificationService notificationService, + required StorageService storageService, + }) : _backgroundTaskService = backgroundTaskService, + _notificationService = notificationService, + _storageService = storageService; + + /// Starts periodic 15-minute background geofence checks. + /// + /// Called after a successful clock-in. Persists the target coordinates + /// so the background isolate can access them. + Future startBackgroundTracking({ + required double targetLat, + required double targetLng, + required String shiftId, + }) async { + await Future.wait([ + _storageService.setDouble(_keyTargetLat, targetLat), + _storageService.setDouble(_keyTargetLng, targetLng), + _storageService.setString(_keyShiftId, shiftId), + _storageService.setBool(_keyTrackingActive, true), + ]); + + await _backgroundTaskService.registerPeriodicTask( + uniqueName: taskUniqueName, + taskName: taskName, + frequency: const Duration(minutes: 15), + inputData: { + 'targetLat': targetLat, + 'targetLng': targetLng, + 'shiftId': shiftId, + }, + ); + } + + /// Stops background geofence checks and clears persisted data. + /// + /// Called after clock-out or when the shift ends. + Future stopBackgroundTracking() async { + await _backgroundTaskService.cancelByUniqueName(taskUniqueName); + + await Future.wait([ + _storageService.remove(_keyTargetLat), + _storageService.remove(_keyTargetLng), + _storageService.remove(_keyShiftId), + _storageService.setBool(_keyTrackingActive, false), + ]); + } + + /// Whether background tracking is currently active. + Future get isTrackingActive async { + final active = await _storageService.getBool(_keyTrackingActive); + return active ?? false; + } + + /// Shows a notification that the worker has left the geofence. + Future showLeftGeofenceNotification({ + required String title, + required String body, + }) async { + await _notificationService.showNotification( + title: title, + body: body, + id: _leftGeofenceNotificationId, + ); + } + + /// Shows a greeting notification upon successful clock-in. + Future showClockInGreetingNotification({ + required String title, + required String body, + }) async { + await _notificationService.showNotification( + title: title, + body: body, + id: _clockInNotificationId, + ); + } +} 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 new file mode 100644 index 00000000..9071bf4c --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/geofence_service_impl.dart @@ -0,0 +1,136 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../../domain/models/geofence_result.dart'; +import '../../domain/services/geofence_service_interface.dart'; + +/// Implementation of [GeofenceServiceInterface] using core [LocationService]. +class GeofenceServiceImpl implements GeofenceServiceInterface { + /// The core location service for device GPS access. + final LocationService _locationService; + + /// When true, always reports the device as within radius. For dev builds. + final bool debugAlwaysInRange; + + /// 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(); + } + + @override + Future requestAlwaysPermission() { + return _locationService.requestAlwaysPermission(); + } + + @override + Stream watchGeofence({ + required double targetLat, + required double targetLng, + double radiusMeters = 500, + }) { + return _locationService.watchLocation(distanceFilter: 10).map( + (location) => _buildResult( + location: location, + targetLat: targetLat, + targetLng: targetLng, + radiusMeters: radiusMeters, + ), + ); + } + + @override + Future checkGeofenceWithTimeout({ + required double targetLat, + required double targetLng, + double radiusMeters = 500, + Duration timeout = const Duration(seconds: 30), + }) async { + try { + final location = + await _locationService.getCurrentLocation().timeout(timeout); + return _buildResult( + location: location, + targetLat: targetLat, + targetLng: targetLng, + radiusMeters: radiusMeters, + ); + } on TimeoutException { + return null; + } + } + + @override + Stream watchServiceStatus() { + return _locationService.onServiceStatusChanged; + } + + @override + Future openAppSettings() async { + await _locationService.openAppSettings(); + } + + @override + Future openLocationSettings() async { + await _locationService.openLocationSettings(); + } + + /// Builds a [GeofenceResult] from a location and target coordinates. + GeofenceResult _buildResult({ + required DeviceLocation location, + required double targetLat, + required double targetLng, + required double radiusMeters, + }) { + final distance = _calculateDistance( + location.latitude, + location.longitude, + targetLat, + targetLng, + ); + + final isWithin = debugAlwaysInRange || distance <= radiusMeters; + final eta = + isWithin ? 0 : (distance / _walkingSpeedMetersPerMinute).round(); + + return GeofenceResult( + distanceMeters: distance, + isWithinRadius: isWithin, + estimatedEtaMinutes: eta, + location: location, + ); + } + + /// 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/domain/models/geofence_result.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/models/geofence_result.dart new file mode 100644 index 00000000..d5185375 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/models/geofence_result.dart @@ -0,0 +1,33 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Result of a geofence proximity check. +class GeofenceResult extends Equatable { + /// Distance from the target location in meters. + final double distanceMeters; + + /// Whether the device is within the allowed geofence radius. + final bool isWithinRadius; + + /// Estimated time of arrival in minutes if outside the radius. + final int estimatedEtaMinutes; + + /// The device location at the time of the check. + final DeviceLocation location; + + /// Creates a [GeofenceResult] instance. + const GeofenceResult({ + required this.distanceMeters, + required this.isWithinRadius, + required this.estimatedEtaMinutes, + required this.location, + }); + + @override + List get props => [ + distanceMeters, + isWithinRadius, + estimatedEtaMinutes, + location, + ]; +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/services/geofence_service_interface.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/services/geofence_service_interface.dart new file mode 100644 index 00000000..099ade09 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/services/geofence_service_interface.dart @@ -0,0 +1,36 @@ +import 'package:krow_domain/krow_domain.dart'; + +import '../models/geofence_result.dart'; + +/// Interface for geofence proximity verification. +abstract class GeofenceServiceInterface { + /// Checks and requests location permission. + Future ensurePermission(); + + /// Requests upgrade to "Always" permission for background access. + Future requestAlwaysPermission(); + + /// Emits geofence results as the device moves relative to a target. + Stream watchGeofence({ + required double targetLat, + required double targetLng, + double radiusMeters = 500, + }); + + /// Checks geofence once with a timeout. Returns null if GPS times out. + Future checkGeofenceWithTimeout({ + required double targetLat, + required double targetLng, + double radiusMeters = 500, + Duration timeout = const Duration(seconds: 30), + }); + + /// Stream of location service status changes (enabled/disabled). + Stream watchServiceStatus(); + + /// Opens the app settings page. + Future openAppSettings(); + + /// Opens the device location settings page. + Future openLocationSettings(); +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart index 6c8002e3..3a87d7f5 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart @@ -1,5 +1,4 @@ import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:geolocator/geolocator.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -12,8 +11,13 @@ import '../../domain/usecases/get_todays_shift_usecase.dart'; import 'clock_in_event.dart'; import 'clock_in_state.dart'; +/// BLoC responsible for clock-in/clock-out operations and shift management. +/// +/// Location and geofence concerns are delegated to [GeofenceBloc]. +/// The UI bridges geofence state into [CheckInRequested] event parameters. class ClockInBloc extends Bloc with BlocErrorHandler { + /// Creates a [ClockInBloc] with the required use cases. ClockInBloc({ required GetTodaysShiftUseCase getTodaysShift, required GetAttendanceStatusUseCase getAttendanceStatus, @@ -30,20 +34,16 @@ class ClockInBloc extends Bloc on(_onCheckIn); on(_onCheckOut); on(_onModeChanged); - on(_onRequestLocationPermission); - on(_onCommuteModeToggled); - on(_onLocationUpdated); add(ClockInPageLoaded()); } + final GetTodaysShiftUseCase _getTodaysShift; final GetAttendanceStatusUseCase _getAttendanceStatus; final ClockInUseCase _clockIn; final ClockOutUseCase _clockOut; - // Mock Venue Location (e.g., Grand Hotel, NYC) - static const double allowedRadiusMeters = 500; - + /// Loads today's shifts and the current attendance status. Future _onLoaded( ClockInPageLoaded event, Emitter emit, @@ -72,10 +72,6 @@ class ClockInBloc extends Bloc selectedShift: selectedShift, attendance: status, )); - - if (selectedShift != null && !status.isCheckedIn) { - add(RequestLocationPermission()); - } }, onError: (String errorKey) => state.copyWith( status: ClockInStatus.failure, @@ -84,106 +80,15 @@ class ClockInBloc extends Bloc ); } - Future _onRequestLocationPermission( - RequestLocationPermission event, - Emitter emit, - ) async { - await handleError( - emit: emit.call, - action: () async { - LocationPermission permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied) { - permission = await Geolocator.requestPermission(); - } - - final bool hasConsent = - permission == LocationPermission.always || - permission == LocationPermission.whileInUse; - - emit(state.copyWith(hasLocationConsent: hasConsent)); - - if (hasConsent) { - await _startLocationUpdates(); - } - }, - onError: (String errorKey) => state.copyWith( - errorMessage: errorKey, - ), - ); - } - - Future _startLocationUpdates() async { - // Note: handleErrorWithResult could be used here too if we want centralized logging/conversion - try { - final Position position = await Geolocator.getCurrentPosition( - desiredAccuracy: LocationAccuracy.high, - ); - - double distance = 0; - bool isVerified = - false; // Require location match by default if shift has location - - if (state.selectedShift != null && - state.selectedShift!.latitude != null && - state.selectedShift!.longitude != null) { - distance = Geolocator.distanceBetween( - position.latitude, - position.longitude, - state.selectedShift!.latitude!, - state.selectedShift!.longitude!, - ); - isVerified = distance <= allowedRadiusMeters; - } else { - isVerified = true; - } - - if (!isClosed) { - add( - LocationUpdated( - position: position, - distance: distance, - isVerified: isVerified, - ), - ); - } - } catch (_) { - // Geolocator errors usually handled via onRequestLocationPermission - } - } - - void _onLocationUpdated( - LocationUpdated event, - Emitter emit, - ) { - emit(state.copyWith( - currentLocation: event.position, - distanceFromVenue: event.distance, - isLocationVerified: event.isVerified, - etaMinutes: - (event.distance / 80).round(), // Rough estimate: 80m/min walking speed - )); - } - - void _onCommuteModeToggled( - CommuteModeToggled event, - Emitter emit, - ) { - emit(state.copyWith(isCommuteModeOn: event.isEnabled)); - if (event.isEnabled) { - add(RequestLocationPermission()); - } - } - + /// Updates the currently selected shift. void _onShiftSelected( ShiftSelected event, Emitter emit, ) { emit(state.copyWith(selectedShift: event.shift)); - if (!state.attendance.isCheckedIn) { - _startLocationUpdates(); - } } + /// Updates the selected date for shift viewing. void _onDateSelected( DateSelected event, Emitter emit, @@ -191,6 +96,7 @@ class ClockInBloc extends Bloc emit(state.copyWith(selectedDate: event.date)); } + /// Updates the check-in interaction mode. void _onModeChanged( CheckInModeChanged event, Emitter emit, @@ -198,10 +104,44 @@ class ClockInBloc extends Bloc emit(state.copyWith(checkInMode: event.mode)); } + /// Handles a clock-in request. + /// + /// Geofence state is passed via event parameters from the UI layer: + /// - If the shift has a venue (lat/lng) and location is neither verified + /// nor timed out, the clock-in is rejected. + /// - If the location timed out, notes are required to proceed. + /// - Otherwise the clock-in proceeds normally. Future _onCheckIn( CheckInRequested event, Emitter emit, ) async { + final Shift? shift = state.selectedShift; + final bool shiftHasLocation = + shift != null && shift.latitude != null && shift.longitude != null; + + // If the shift requires location verification but geofence has not + // confirmed proximity and has not timed out, reject the attempt. + if (shiftHasLocation && + !event.isLocationVerified && + !event.isLocationTimedOut) { + emit(state.copyWith( + status: ClockInStatus.failure, + errorMessage: 'errors.clock_in.location_verification_required', + )); + return; + } + + // When location timed out, require the user to provide notes explaining + // why they are clocking in without verified proximity. + if (event.isLocationTimedOut && + (event.notes == null || event.notes!.trim().isEmpty)) { + emit(state.copyWith( + status: ClockInStatus.failure, + errorMessage: 'errors.clock_in.notes_required_for_timeout', + )); + return; + } + emit(state.copyWith(status: ClockInStatus.actionInProgress)); await handleError( emit: emit.call, @@ -221,6 +161,7 @@ class ClockInBloc extends Bloc ); } + /// Handles a clock-out request. Future _onCheckOut( CheckOutRequested event, Emitter emit, diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart index 85dd1614..39545d9f 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart @@ -1,7 +1,7 @@ import 'package:equatable/equatable.dart'; -import 'package:geolocator/geolocator.dart'; import 'package:krow_domain/krow_domain.dart'; +/// Base class for all clock-in related events. abstract class ClockInEvent extends Equatable { const ClockInEvent(); @@ -9,72 +9,81 @@ abstract class ClockInEvent extends Equatable { List get props => []; } +/// Emitted when the clock-in page is first loaded. class ClockInPageLoaded extends ClockInEvent {} +/// Emitted when the user selects a shift from the list. class ShiftSelected extends ClockInEvent { const ShiftSelected(this.shift); + + /// The shift the user selected. final Shift shift; @override List get props => [shift]; } +/// Emitted when the user picks a different date. class DateSelected extends ClockInEvent { - const DateSelected(this.date); + + /// The newly selected date. final DateTime date; @override List get props => [date]; } +/// Emitted when the user requests to clock in. +/// +/// [isLocationVerified] and [isLocationTimedOut] are provided by the UI layer +/// from the GeofenceBloc state, bridging the two BLoCs. class CheckInRequested extends ClockInEvent { + const CheckInRequested({ + required this.shiftId, + this.notes, + this.isLocationVerified = false, + this.isLocationTimedOut = false, + }); - const CheckInRequested({required this.shiftId, this.notes}); + /// The ID of the shift to clock into. final String shiftId; + + /// Optional notes provided by the user. final String? notes; + /// Whether the geofence verification passed (user is within radius). + final bool isLocationVerified; + + /// Whether the geofence verification timed out (GPS unavailable). + final bool isLocationTimedOut; + @override - List get props => [shiftId, notes]; + List get props => + [shiftId, notes, isLocationVerified, isLocationTimedOut]; } +/// Emitted when the user requests to clock out. class CheckOutRequested extends ClockInEvent { - const CheckOutRequested({this.notes, this.breakTimeMinutes}); + + /// Optional notes provided by the user. final String? notes; + + /// Break time taken during the shift, in minutes. final int? breakTimeMinutes; @override List get props => [notes, breakTimeMinutes]; } +/// Emitted when the user changes the check-in mode (e.g. swipe vs tap). class CheckInModeChanged extends ClockInEvent { - const CheckInModeChanged(this.mode); + + /// The new check-in mode identifier. final String mode; @override List get props => [mode]; } - -class CommuteModeToggled extends ClockInEvent { - - const CommuteModeToggled(this.isEnabled); - final bool isEnabled; - - @override - List get props => [isEnabled]; -} - -class RequestLocationPermission extends ClockInEvent {} - -class LocationUpdated extends ClockInEvent { - - const LocationUpdated({required this.position, required this.distance, required this.isVerified}); - final Position position; - final double distance; - final bool isVerified; - - @override - List get props => [position, distance, isVerified]; -} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_state.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_state.dart index 2474b519..3e69fd50 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_state.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_state.dart @@ -1,12 +1,15 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:geolocator/geolocator.dart'; - +/// Represents the possible statuses of the clock-in page. enum ClockInStatus { initial, loading, success, failure, actionInProgress } +/// State for the [ClockInBloc]. +/// +/// Contains today's shifts, the selected shift, attendance status, +/// and clock-in UI configuration. Location/geofence concerns are +/// managed separately by [GeofenceBloc]. class ClockInState extends Equatable { - const ClockInState({ this.status = ClockInStatus.initial, this.todayShifts = const [], @@ -15,28 +18,30 @@ class ClockInState extends Equatable { required this.selectedDate, this.checkInMode = 'swipe', this.errorMessage, - this.currentLocation, - this.distanceFromVenue, - this.isLocationVerified = false, - this.isCommuteModeOn = false, - this.hasLocationConsent = false, - this.etaMinutes, }); + + /// Current page status. final ClockInStatus status; + + /// List of shifts scheduled for the selected date. final List todayShifts; + + /// The shift currently selected by the user. final Shift? selectedShift; + + /// Current attendance/check-in status from the backend. final AttendanceStatus attendance; + + /// The date the user is viewing shifts for. final DateTime selectedDate; + + /// The current check-in interaction mode (e.g. 'swipe'). final String checkInMode; + + /// Error message key for displaying failures. final String? errorMessage; - final Position? currentLocation; - final double? distanceFromVenue; - final bool isLocationVerified; - final bool isCommuteModeOn; - final bool hasLocationConsent; - final int? etaMinutes; - + /// Creates a copy of this state with the given fields replaced. ClockInState copyWith({ ClockInStatus? status, List? todayShifts, @@ -45,12 +50,6 @@ class ClockInState extends Equatable { DateTime? selectedDate, String? checkInMode, String? errorMessage, - Position? currentLocation, - double? distanceFromVenue, - bool? isLocationVerified, - bool? isCommuteModeOn, - bool? hasLocationConsent, - int? etaMinutes, }) { return ClockInState( status: status ?? this.status, @@ -60,12 +59,6 @@ class ClockInState extends Equatable { selectedDate: selectedDate ?? this.selectedDate, checkInMode: checkInMode ?? this.checkInMode, errorMessage: errorMessage, - currentLocation: currentLocation ?? this.currentLocation, - distanceFromVenue: distanceFromVenue ?? this.distanceFromVenue, - isLocationVerified: isLocationVerified ?? this.isLocationVerified, - isCommuteModeOn: isCommuteModeOn ?? this.isCommuteModeOn, - hasLocationConsent: hasLocationConsent ?? this.hasLocationConsent, - etaMinutes: etaMinutes ?? this.etaMinutes, ); } @@ -78,11 +71,5 @@ class ClockInState extends Equatable { selectedDate, checkInMode, errorMessage, - currentLocation, - distanceFromVenue, - isLocationVerified, - isCommuteModeOn, - hasLocationConsent, - etaMinutes, ]; } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_bloc.dart new file mode 100644 index 00000000..f9f171ab --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_bloc.dart @@ -0,0 +1,262 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../../data/services/background_geofence_service.dart'; +import '../../domain/models/geofence_result.dart'; +import '../../domain/services/geofence_service_interface.dart'; +import 'geofence_event.dart'; +import 'geofence_state.dart'; + +/// BLoC that manages geofence verification and background tracking. +/// +/// Handles foreground location stream monitoring, GPS timeout fallback, +/// and background periodic checks while clocked in. +class GeofenceBloc extends Bloc + with + BlocErrorHandler, + SafeBloc { + + /// Creates a [GeofenceBloc] instance. + GeofenceBloc({ + required GeofenceServiceInterface geofenceService, + required BackgroundGeofenceService backgroundGeofenceService, + }) : _geofenceService = geofenceService, + _backgroundGeofenceService = backgroundGeofenceService, + super(const GeofenceState.initial()) { + on(_onStarted); + on(_onResultUpdated); + on(_onTimeout); + on(_onServiceStatusChanged); + on(_onRetry); + on(_onBackgroundTrackingStarted); + on(_onBackgroundTrackingStopped); + on(_onStopped); + } + /// The geofence service for foreground proximity checks. + final GeofenceServiceInterface _geofenceService; + + /// The background service for periodic tracking while clocked in. + final BackgroundGeofenceService _backgroundGeofenceService; + + /// Active subscription to the foreground geofence location stream. + StreamSubscription? _geofenceSubscription; + + /// Active subscription to the location service status stream. + StreamSubscription? _serviceStatusSubscription; + + /// Handles the [GeofenceStarted] event by requesting permission, performing + /// an initial geofence check, and starting the foreground location stream. + Future _onStarted( + GeofenceStarted event, + Emitter emit, + ) async { + emit(state.copyWith( + isVerifying: true, + targetLat: event.targetLat, + targetLng: event.targetLng, + )); + + await handleError( + emit: emit.call, + action: () async { + // Check permission first. + final permission = await _geofenceService.ensurePermission(); + emit(state.copyWith(permissionStatus: permission)); + + if (permission == LocationPermissionStatus.denied || + permission == LocationPermissionStatus.deniedForever || + permission == LocationPermissionStatus.serviceDisabled) { + emit(state.copyWith( + isVerifying: false, + isLocationServiceEnabled: + permission != LocationPermissionStatus.serviceDisabled, + )); + return; + } + + // Start monitoring location service status changes. + await _serviceStatusSubscription?.cancel(); + _serviceStatusSubscription = + _geofenceService.watchServiceStatus().listen((isEnabled) { + add(GeofenceServiceStatusChanged(isEnabled)); + }); + + // Get initial position with a 30s timeout. + final result = await _geofenceService.checkGeofenceWithTimeout( + targetLat: event.targetLat, + targetLng: event.targetLng, + ); + + if (result == null) { + add(const GeofenceTimeoutReached()); + } else { + add(GeofenceResultUpdated(result)); + } + + // Start continuous foreground location stream. + await _geofenceSubscription?.cancel(); + _geofenceSubscription = _geofenceService + .watchGeofence( + targetLat: event.targetLat, + targetLng: event.targetLng, + ) + .listen( + (result) => add(GeofenceResultUpdated(result)), + ); + }, + onError: (String errorKey) => state.copyWith( + isVerifying: false, + ), + ); + } + + /// Handles the [GeofenceResultUpdated] event by updating the state with + /// the latest location and distance data. + void _onResultUpdated( + GeofenceResultUpdated event, + Emitter emit, + ) { + emit(state.copyWith( + isVerifying: false, + isLocationTimedOut: false, + currentLocation: event.result.location, + distanceFromTarget: event.result.distanceMeters, + isLocationVerified: event.result.isWithinRadius, + isLocationServiceEnabled: true, + )); + } + + /// Handles the [GeofenceTimeoutReached] event by marking the state as + /// timed out. + void _onTimeout( + GeofenceTimeoutReached event, + Emitter emit, + ) { + emit(state.copyWith( + isVerifying: false, + isLocationTimedOut: true, + )); + } + + /// Handles the [GeofenceServiceStatusChanged] event. If services are + /// re-enabled after a timeout, automatically retries the check. + Future _onServiceStatusChanged( + GeofenceServiceStatusChanged event, + Emitter emit, + ) async { + emit(state.copyWith(isLocationServiceEnabled: event.isEnabled)); + + // If service re-enabled and we were timed out, retry automatically. + if (event.isEnabled && state.isLocationTimedOut) { + add(const GeofenceRetryRequested()); + } + } + + /// Handles the [GeofenceRetryRequested] event by re-checking the geofence + /// with the stored target coordinates. + Future _onRetry( + GeofenceRetryRequested event, + Emitter emit, + ) async { + if (state.targetLat == null || state.targetLng == null) return; + + emit(state.copyWith( + isVerifying: true, + isLocationTimedOut: false, + )); + + await handleError( + emit: emit.call, + action: () async { + final result = await _geofenceService.checkGeofenceWithTimeout( + targetLat: state.targetLat!, + targetLng: state.targetLng!, + ); + + if (result == null) { + add(const GeofenceTimeoutReached()); + } else { + add(GeofenceResultUpdated(result)); + } + }, + onError: (String errorKey) => state.copyWith( + isVerifying: false, + ), + ); + } + + /// Handles the [BackgroundTrackingStarted] event by requesting "Always" + /// permission and starting periodic background checks. + Future _onBackgroundTrackingStarted( + BackgroundTrackingStarted event, + Emitter emit, + ) async { + await handleError( + emit: emit.call, + action: () async { + // Request upgrade to "Always" permission for background tracking. + final permission = await _geofenceService.requestAlwaysPermission(); + emit(state.copyWith(permissionStatus: permission)); + + // Start background tracking regardless (degrades gracefully). + await _backgroundGeofenceService.startBackgroundTracking( + targetLat: event.targetLat, + targetLng: event.targetLng, + shiftId: event.shiftId, + ); + + // Show greeting notification using localized strings from the UI. + await _backgroundGeofenceService.showClockInGreetingNotification( + title: event.greetingTitle, + body: event.greetingBody, + ); + + emit(state.copyWith(isBackgroundTrackingActive: true)); + }, + onError: (String errorKey) => state.copyWith( + isBackgroundTrackingActive: false, + ), + ); + } + + /// Handles the [BackgroundTrackingStopped] event by stopping background + /// tracking. + Future _onBackgroundTrackingStopped( + BackgroundTrackingStopped event, + Emitter emit, + ) async { + await handleError( + emit: emit.call, + action: () async { + await _backgroundGeofenceService.stopBackgroundTracking(); + emit(state.copyWith(isBackgroundTrackingActive: false)); + }, + onError: (String errorKey) => state.copyWith( + isBackgroundTrackingActive: false, + ), + ); + } + + /// Handles the [GeofenceStopped] event by cancelling all subscriptions + /// and resetting the state. + Future _onStopped( + GeofenceStopped event, + Emitter emit, + ) async { + await _geofenceSubscription?.cancel(); + _geofenceSubscription = null; + await _serviceStatusSubscription?.cancel(); + _serviceStatusSubscription = null; + emit(const GeofenceState.initial()); + } + + @override + Future close() { + _geofenceSubscription?.cancel(); + _serviceStatusSubscription?.cancel(); + return super.close(); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_event.dart new file mode 100644 index 00000000..f4c68d50 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_event.dart @@ -0,0 +1,106 @@ +import 'package:equatable/equatable.dart'; + +import '../../domain/models/geofence_result.dart'; + +/// Base event for the [GeofenceBloc]. +abstract class GeofenceEvent extends Equatable { + /// Creates a [GeofenceEvent]. + const GeofenceEvent(); + + @override + List get props => []; +} + +/// Starts foreground geofence verification for a target location. +class GeofenceStarted extends GeofenceEvent { + /// Target latitude of the shift location. + final double targetLat; + + /// Target longitude of the shift location. + final double targetLng; + + /// Creates a [GeofenceStarted] event. + const GeofenceStarted({required this.targetLat, required this.targetLng}); + + @override + List get props => [targetLat, targetLng]; +} + +/// Emitted when a new geofence result is received from the location stream. +class GeofenceResultUpdated extends GeofenceEvent { + /// The latest geofence check result. + final GeofenceResult result; + + /// Creates a [GeofenceResultUpdated] event. + const GeofenceResultUpdated(this.result); + + @override + List get props => [result]; +} + +/// Emitted when the GPS timeout (30s) is reached without a location fix. +class GeofenceTimeoutReached extends GeofenceEvent { + /// Creates a [GeofenceTimeoutReached] event. + const GeofenceTimeoutReached(); +} + +/// Emitted when the device location service status changes. +class GeofenceServiceStatusChanged extends GeofenceEvent { + /// Whether location services are now enabled. + final bool isEnabled; + + /// Creates a [GeofenceServiceStatusChanged] event. + const GeofenceServiceStatusChanged(this.isEnabled); + + @override + List get props => [isEnabled]; +} + +/// User manually requests a geofence re-check.clock_in_body.dart +class GeofenceRetryRequested extends GeofenceEvent { + /// Creates a [GeofenceRetryRequested] event. + const GeofenceRetryRequested(); +} + +/// Starts background tracking after successful clock-in. +class BackgroundTrackingStarted extends GeofenceEvent { + /// The shift ID being tracked. + final String shiftId; + + /// Target latitude of the shift location. + final double targetLat; + + /// Target longitude of the shift location. + final double targetLng; + + /// Localized greeting notification title passed from the UI layer. + final String greetingTitle; + + /// Localized greeting notification body passed from the UI layer. + final String greetingBody; + + /// Creates a [BackgroundTrackingStarted] event. + const BackgroundTrackingStarted({ + required this.shiftId, + required this.targetLat, + required this.targetLng, + required this.greetingTitle, + required this.greetingBody, + }); + + @override + List get props => + [shiftId, targetLat, targetLng, greetingTitle, greetingBody]; +} + +/// Stops background tracking after clock-out. +class BackgroundTrackingStopped extends GeofenceEvent { + /// Creates a [BackgroundTrackingStopped] event. + const BackgroundTrackingStopped(); +} + +/// Stops all geofence monitoring (foreground and background). +class GeofenceStopped extends GeofenceEvent { + /// Creates a [GeofenceStopped] event. + const GeofenceStopped(); +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_state.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_state.dart new file mode 100644 index 00000000..ff343569 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_state.dart @@ -0,0 +1,95 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// State for the [GeofenceBloc]. +class GeofenceState extends Equatable { + + /// Creates a [GeofenceState] instance. + const GeofenceState({ + this.permissionStatus, + this.isLocationServiceEnabled = true, + this.currentLocation, + this.distanceFromTarget, + this.isLocationVerified = false, + this.isLocationTimedOut = false, + this.isVerifying = false, + this.isBackgroundTrackingActive = false, + this.targetLat, + this.targetLng, + }); + /// Current location permission status. + final LocationPermissionStatus? permissionStatus; + + /// Whether device location services are enabled. + final bool isLocationServiceEnabled; + + /// The device's current location, if available. + final DeviceLocation? currentLocation; + + /// Distance from the target location in meters. + final double? distanceFromTarget; + + /// Whether the device is within the 500m geofence radius. + final bool isLocationVerified; + + /// Whether GPS timed out trying to get a fix. + final bool isLocationTimedOut; + + /// Whether the BLoC is actively verifying location. + final bool isVerifying; + + /// Whether background tracking is active. + final bool isBackgroundTrackingActive; + + /// Target latitude being monitored. + final double? targetLat; + + /// Target longitude being monitored. + final double? targetLng; + + /// Initial state before any geofence operations. + const GeofenceState.initial() : this(); + + /// Creates a copy with the given fields replaced. + GeofenceState copyWith({ + LocationPermissionStatus? permissionStatus, + bool? isLocationServiceEnabled, + DeviceLocation? currentLocation, + double? distanceFromTarget, + bool? isLocationVerified, + bool? isLocationTimedOut, + bool? isVerifying, + bool? isBackgroundTrackingActive, + double? targetLat, + double? targetLng, + }) { + return GeofenceState( + permissionStatus: permissionStatus ?? this.permissionStatus, + isLocationServiceEnabled: + isLocationServiceEnabled ?? this.isLocationServiceEnabled, + currentLocation: currentLocation ?? this.currentLocation, + distanceFromTarget: distanceFromTarget ?? this.distanceFromTarget, + isLocationVerified: isLocationVerified ?? this.isLocationVerified, + isLocationTimedOut: isLocationTimedOut ?? this.isLocationTimedOut, + isVerifying: isVerifying ?? this.isVerifying, + isBackgroundTrackingActive: + isBackgroundTrackingActive ?? this.isBackgroundTrackingActive, + targetLat: targetLat ?? this.targetLat, + targetLng: targetLng ?? this.targetLng, + ); + } + + @override + List get props => [ + permissionStatus, + isLocationServiceEnabled, + currentLocation, + distanceFromTarget, + isLocationVerified, + isLocationTimedOut, + isVerifying, + isBackgroundTrackingActive, + targetLat, + targetLng, + ]; +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/blocs/clock_in_cubit.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/blocs/clock_in_cubit.dart deleted file mode 100644 index 01067185..00000000 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/blocs/clock_in_cubit.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:equatable/equatable.dart'; -import 'package:geolocator/geolocator.dart'; - -// --- State --- -class ClockInState extends Equatable { - - const ClockInState({ - this.isLoading = false, - this.isLocationVerified = false, - this.error, - this.currentLocation, - this.distanceFromVenue, - this.isClockedIn = false, - this.clockInTime, - }); - final bool isLoading; - final bool isLocationVerified; - final String? error; - final Position? currentLocation; - final double? distanceFromVenue; - final bool isClockedIn; - final DateTime? clockInTime; - - ClockInState copyWith({ - bool? isLoading, - bool? isLocationVerified, - String? error, - Position? currentLocation, - double? distanceFromVenue, - bool? isClockedIn, - DateTime? clockInTime, - }) { - return ClockInState( - isLoading: isLoading ?? this.isLoading, - isLocationVerified: isLocationVerified ?? this.isLocationVerified, - error: error, // Clear error if not provided - currentLocation: currentLocation ?? this.currentLocation, - distanceFromVenue: distanceFromVenue ?? this.distanceFromVenue, - isClockedIn: isClockedIn ?? this.isClockedIn, - clockInTime: clockInTime ?? this.clockInTime, - ); - } - - @override - List get props => [ - isLoading, - isLocationVerified, - error, - currentLocation, - distanceFromVenue, - isClockedIn, - clockInTime, - ]; -} - -// --- Cubit --- -class ClockInCubit extends Cubit { // 500m radius - - ClockInCubit() : super(const ClockInState()); - // Mock Venue Location (e.g., Grand Hotel, NYC) - static const double venueLat = 40.7128; - static const double venueLng = -74.0060; - static const double allowedRadiusMeters = 500; - - Future checkLocationPermission() async { - emit(state.copyWith(isLoading: true, error: null)); - try { - LocationPermission permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied) { - permission = await Geolocator.requestPermission(); - if (permission == LocationPermission.denied) { - emit(state.copyWith( - isLoading: false, - error: 'Location permissions are denied', - )); - return; - } - } - - if (permission == LocationPermission.deniedForever) { - emit(state.copyWith( - isLoading: false, - error: 'Location permissions are permanently denied, we cannot request permissions.', - )); - return; - } - - await _getCurrentLocation(); - } catch (e) { - emit(state.copyWith(isLoading: false, error: e.toString())); - } - } - - Future _getCurrentLocation() async { - try { - final Position position = await Geolocator.getCurrentPosition( - desiredAccuracy: LocationAccuracy.high, - ); - - final double distance = Geolocator.distanceBetween( - position.latitude, - position.longitude, - venueLat, - venueLng, - ); - - final bool isWithinRadius = distance <= allowedRadiusMeters; - - emit(state.copyWith( - isLoading: false, - currentLocation: position, - distanceFromVenue: distance, - isLocationVerified: isWithinRadius, - error: isWithinRadius ? null : 'You are ${distance.toStringAsFixed(0)}m away. You must be within ${allowedRadiusMeters}m.', - )); - } catch (e) { - emit(state.copyWith(isLoading: false, error: 'Failed to get location: $e')); - } - } - - Future clockIn() async { - if (state.currentLocation == null) { - await checkLocationPermission(); - if (state.currentLocation == null) return; - } - - emit(state.copyWith(isLoading: true)); - - await Future.delayed(const Duration(seconds: 2)); - - emit(state.copyWith( - isLoading: false, - isClockedIn: true, - clockInTime: DateTime.now(), - )); - } - - Future clockOut() async { - if (state.currentLocation == null) { - await checkLocationPermission(); - if (state.currentLocation == null) return; - } - - emit(state.copyWith(isLoading: true)); - - await Future.delayed(const Duration(seconds: 2)); - - emit(state.copyWith( - isLoading: false, - isClockedIn: false, - clockInTime: null, - )); - } -} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart index 511179ad..250494b0 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart @@ -6,14 +6,15 @@ import 'package:flutter_modular/flutter_modular.dart'; import '../bloc/clock_in_bloc.dart'; import '../bloc/clock_in_state.dart'; +import '../bloc/geofence_bloc.dart'; import '../widgets/clock_in_body.dart'; import '../widgets/clock_in_page_skeleton/clock_in_page_skeleton.dart'; /// Top-level page for the staff clock-in feature. /// -/// Acts as a thin shell that provides the [ClockInBloc] and delegates -/// rendering to [ClockInBody] (loaded state) or [ClockInPageSkeleton] -/// (loading state). Error snackbars are handled via [BlocListener]. +/// Provides [ClockInBloc] and [GeofenceBloc], then delegates rendering to +/// [ClockInBody] (loaded) or [ClockInPageSkeleton] (loading). Error +/// snackbars are handled via [BlocListener]. class ClockInPage extends StatelessWidget { /// Creates the clock-in page. const ClockInPage({super.key}); @@ -24,8 +25,15 @@ class ClockInPage extends StatelessWidget { context, ).staff.clock_in; - return BlocProvider.value( - value: Modular.get(), + return MultiBlocProvider( + providers: >[ + BlocProvider.value( + value: Modular.get(), + ), + BlocProvider.value( + value: Modular.get(), + ), + ], child: BlocListener( listenWhen: (ClockInState previous, ClockInState current) => current.status == ClockInStatus.failure && diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/check_in_mode_tab.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/check_in_mode_tab.dart index 054e15b8..44f237d4 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/check_in_mode_tab.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/check_in_mode_tab.dart @@ -37,7 +37,7 @@ class CheckInModeTab extends StatelessWidget { return Expanded( child: GestureDetector( onTap: () => - context.read().add(CheckInModeChanged(value)), + ReadContext(context).read().add(CheckInModeChanged(value)), child: Container( padding: const EdgeInsets.symmetric(vertical: UiConstants.space2), decoration: BoxDecoration( 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 892f4502..5cf56ed7 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 @@ -1,11 +1,20 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import '../bloc/clock_in_bloc.dart'; import '../bloc/clock_in_event.dart'; +import '../bloc/clock_in_state.dart'; +import '../bloc/geofence_bloc.dart'; +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 'lunch_break_modal.dart'; import 'nfc_scan_dialog.dart'; import 'no_shifts_banner.dart'; @@ -15,7 +24,8 @@ import 'swipe_to_check_in.dart'; /// Orchestrates which action widget is displayed based on the current state. /// /// Decides between the swipe-to-check-in slider, the early-arrival banner, -/// the shift-completed banner, or the no-shifts placeholder. +/// the shift-completed banner, or the no-shifts placeholder. Also shows the +/// [GeofenceStatusBanner] and manages background tracking lifecycle. class ClockInActionSection extends StatelessWidget { /// Creates the action section. const ClockInActionSection({ @@ -44,6 +54,37 @@ class ClockInActionSection extends StatelessWidget { @override Widget build(BuildContext context) { + return MultiBlocListener( + listeners: >[ + // Start background tracking after successful check-in. + BlocListener( + listenWhen: (ClockInState previous, ClockInState current) => + previous.status == ClockInStatus.actionInProgress && + current.status == ClockInStatus.success && + current.attendance.isCheckedIn && + !previous.attendance.isCheckedIn, + listener: (BuildContext context, ClockInState state) { + _startBackgroundTracking(context, state); + }, + ), + // Stop background tracking after clock-out. + BlocListener( + listenWhen: (ClockInState previous, ClockInState current) => + previous.attendance.isCheckedIn && + !current.attendance.isCheckedIn, + listener: (BuildContext context, ClockInState _) { + ReadContext(context) + .read() + .add(const BackgroundTrackingStopped()); + }, + ), + ], + child: _buildContent(context), + ); + } + + /// Builds the main content column with geofence banner and action widget. + Widget _buildContent(BuildContext context) { if (selectedShift != null && checkOutTime == null) { return _buildActiveShiftAction(context); } @@ -58,36 +99,74 @@ class ClockInActionSection extends StatelessWidget { /// Builds the action widget for an active (not completed) shift. Widget _buildActiveShiftAction(BuildContext context) { if (!isCheckedIn && !ClockInHelpers.isCheckInAllowed(selectedShift!)) { - return EarlyCheckInBanner( - availabilityTime: ClockInHelpers.getCheckInAvailabilityTime( - selectedShift!, - context, - ), + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const GeofenceStatusBanner(), + const SizedBox(height: UiConstants.space3), + EarlyCheckInBanner( + availabilityTime: ClockInHelpers.getCheckInAvailabilityTime( + selectedShift!, + context, + ), + ), + ], ); } - return SwipeToCheckIn( - isCheckedIn: isCheckedIn, - mode: checkInMode, - isDisabled: isCheckedIn, - isLoading: isActionInProgress, - onCheckIn: () => _handleCheckIn(context), - onCheckOut: () => _handleCheckOut(context), + return BlocBuilder( + builder: (BuildContext context, GeofenceState geofenceState) { + final bool hasCoordinates = selectedShift?.latitude != null && + selectedShift?.longitude != null; + + // Disable swipe when the shift has coordinates and the user is + // not verified and the timeout has not been reached. + final bool isGeofenceBlocking = hasCoordinates && + !geofenceState.isLocationVerified && + !geofenceState.isLocationTimedOut; + + return Column( + mainAxisSize: MainAxisSize.min, + spacing: UiConstants.space4, + children: [ + // Geofence status banner is shown even when not blocking to provide feedback + const GeofenceStatusBanner(), + SwipeToCheckIn( + isCheckedIn: isCheckedIn, + mode: checkInMode, + isDisabled: isCheckedIn || isGeofenceBlocking, + isLoading: isActionInProgress, + onCheckIn: () => _handleCheckIn(context), + onCheckOut: () => _handleCheckOut(context), + ), + ], + ); + }, ); } - /// Triggers the check-in flow, showing an NFC dialog when needed. + /// Triggers the check-in flow, reading geofence state for location data. Future _handleCheckIn(BuildContext context) async { + final GeofenceState geofenceState = ReadContext(context).read().state; + if (checkInMode == 'nfc') { final bool scanned = await showNfcScanDialog(context); if (scanned && context.mounted) { - context.read().add( - CheckInRequested(shiftId: selectedShift!.id), + ReadContext(context).read().add( + CheckInRequested( + shiftId: selectedShift!.id, + isLocationVerified: geofenceState.isLocationVerified, + isLocationTimedOut: geofenceState.isLocationTimedOut, + ), ); } } else { - context.read().add( - CheckInRequested(shiftId: selectedShift!.id), + ReadContext(context).read().add( + CheckInRequested( + shiftId: selectedShift!.id, + isLocationVerified: geofenceState.isLocationVerified, + isLocationTimedOut: geofenceState.isLocationTimedOut, + ), ); } } @@ -98,10 +177,33 @@ class ClockInActionSection extends StatelessWidget { context: context, builder: (BuildContext dialogContext) => LunchBreakDialog( onComplete: () { - Navigator.of(dialogContext).pop(); - context.read().add(const CheckOutRequested()); + Modular.to.popSafe(); + ReadContext(context).read().add(const CheckOutRequested()); }, ), ); } + + /// Dispatches [BackgroundTrackingStarted] if the geofence has target + /// coordinates after a successful check-in. + void _startBackgroundTracking(BuildContext context, ClockInState state) { + final GeofenceState geofenceState = ReadContext(context).read().state; + + if (geofenceState.targetLat != null && + geofenceState.targetLng != null && + state.attendance.activeShiftId != null) { + final TranslationsStaffClockInGeofenceEn geofenceI18n = + Translations.of(context).staff.clock_in.geofence; + + ReadContext(context).read().add( + BackgroundTrackingStarted( + shiftId: state.attendance.activeShiftId!, + targetLat: geofenceState.targetLat!, + targetLng: geofenceState.targetLng!, + greetingTitle: geofenceI18n.clock_in_greeting_title, + greetingBody: geofenceI18n.clock_in_greeting_body, + ), + ); + } + } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart index 58653815..54611a96 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart @@ -8,6 +8,8 @@ import 'package:krow_domain/krow_domain.dart'; import '../bloc/clock_in_bloc.dart'; import '../bloc/clock_in_event.dart'; import '../bloc/clock_in_state.dart'; +import '../bloc/geofence_bloc.dart'; +import '../bloc/geofence_event.dart'; import 'checked_in_banner.dart'; import 'clock_in_action_section.dart'; import 'date_selector.dart'; @@ -17,89 +19,129 @@ import 'shift_card_list.dart'; /// /// Composes the date selector, activity header, shift cards, action section, /// and the checked-in status banner into a single scrollable column. -class ClockInBody extends StatelessWidget { +/// Triggers geofence verification on mount and on shift selection changes. +class ClockInBody extends StatefulWidget { /// Creates the clock-in body. const ClockInBody({super.key}); + @override + State createState() => _ClockInBodyState(); +} + +class _ClockInBodyState extends State { + @override + void initState() { + super.initState(); + // Sync geofence on initial mount if a shift is already selected. + WidgetsBinding.instance.addPostFrameCallback((_) { + final Shift? selectedShift = + ReadContext(context).read().state.selectedShift; + _syncGeofence(context, selectedShift); + }); + } + @override Widget build(BuildContext context) { final TranslationsStaffClockInEn i18n = Translations.of( context, ).staff.clock_in; - return SingleChildScrollView( - padding: const EdgeInsets.only( - bottom: UiConstants.space24, - top: UiConstants.space6, - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), - child: BlocBuilder( - builder: (BuildContext context, ClockInState state) { - final List todayShifts = state.todayShifts; - final Shift? selectedShift = state.selectedShift; - final String? activeShiftId = state.attendance.activeShiftId; - final bool isActiveSelected = - selectedShift != null && selectedShift.id == activeShiftId; - final DateTime? checkInTime = isActiveSelected - ? state.attendance.checkInTime - : null; - final DateTime? checkOutTime = isActiveSelected - ? state.attendance.checkOutTime - : null; - final bool isCheckedIn = - state.attendance.isCheckedIn && isActiveSelected; + return BlocListener( + listenWhen: (ClockInState previous, ClockInState current) => + previous.selectedShift != current.selectedShift, + listener: (BuildContext context, ClockInState state) { + _syncGeofence(context, state.selectedShift); + }, + child: SingleChildScrollView( + padding: const EdgeInsets.only( + bottom: UiConstants.space24, + top: UiConstants.space6, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), + child: BlocBuilder( + builder: (BuildContext context, ClockInState state) { + final List todayShifts = state.todayShifts; + final Shift? selectedShift = state.selectedShift; + final String? activeShiftId = state.attendance.activeShiftId; + final bool isActiveSelected = + selectedShift != null && selectedShift.id == activeShiftId; + final DateTime? checkInTime = + isActiveSelected ? state.attendance.checkInTime : null; + final DateTime? checkOutTime = + isActiveSelected ? state.attendance.checkOutTime : null; + final bool isCheckedIn = + state.attendance.isCheckedIn && isActiveSelected; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // date selector - DateSelector( - selectedDate: state.selectedDate, - onSelect: (DateTime date) => - context.read().add(DateSelected(date)), - shiftDates: [ - DateFormat('yyyy-MM-dd').format(DateTime.now()), - ], - ), - const SizedBox(height: UiConstants.space5), - Text( - i18n.your_activity, - textAlign: TextAlign.start, - style: UiTypography.headline4m, - ), - const SizedBox(height: UiConstants.space4), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // date selector + DateSelector( + selectedDate: state.selectedDate, + onSelect: (DateTime date) => + ReadContext(context).read().add(DateSelected(date)), + shiftDates: [ + DateFormat('yyyy-MM-dd').format(DateTime.now()), + ], + ), + const SizedBox(height: UiConstants.space5), + Text( + i18n.your_activity, + textAlign: TextAlign.start, + style: UiTypography.headline4m, + ), + const SizedBox(height: UiConstants.space4), - // today's shifts and actions - if (todayShifts.isNotEmpty) - ShiftCardList( - shifts: todayShifts, - selectedShiftId: selectedShift?.id, - onShiftSelected: (Shift shift) => - context.read().add(ShiftSelected(shift)), + // today's shifts and actions + if (todayShifts.isNotEmpty) + ShiftCardList( + shifts: todayShifts, + selectedShiftId: selectedShift?.id, + onShiftSelected: (Shift shift) => ReadContext(context) + .read() + .add(ShiftSelected(shift)), + ), + + // action section (check-in/out buttons) + ClockInActionSection( + selectedShift: selectedShift, + isCheckedIn: isCheckedIn, + checkOutTime: checkOutTime, + checkInMode: state.checkInMode, + isActionInProgress: + state.status == ClockInStatus.actionInProgress, ), - // action section (check-in/out buttons) - ClockInActionSection( - selectedShift: selectedShift, - isCheckedIn: isCheckedIn, - checkOutTime: checkOutTime, - checkInMode: state.checkInMode, - isActionInProgress: - state.status == ClockInStatus.actionInProgress, - ), - - // checked-in banner (only if currently checked in to the selected shift) - if (isCheckedIn && checkInTime != null) ...[ - const SizedBox(height: UiConstants.space3), - CheckedInBanner(checkInTime: checkInTime), + // checked-in banner (only when checked in to the selected shift) + if (isCheckedIn && checkInTime != null) ...[ + const SizedBox(height: UiConstants.space3), + CheckedInBanner(checkInTime: checkInTime), + ], + const SizedBox(height: UiConstants.space4), ], - const SizedBox(height: UiConstants.space4), - ], - ); - }, + ); + }, + ), ), ), ); } + + /// Dispatches [GeofenceStarted] or [GeofenceStopped] based on whether + /// the selected shift has coordinates. + void _syncGeofence(BuildContext context, Shift? shift) { + final GeofenceBloc geofenceBloc = ReadContext(context).read(); + + if (shift != null && shift.latitude != null && shift.longitude != null) { + geofenceBloc.add( + GeofenceStarted( + targetLat: shift.latitude!, + targetLng: shift.longitude!, + ), + ); + } else { + geofenceBloc.add(const GeofenceStopped()); + } + } } 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 new file mode 100644 index 00000000..8b422880 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner.dart @@ -0,0 +1,324 @@ +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/nfc_scan_dialog.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/nfc_scan_dialog.dart index bbf24b05..3ba0e995 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/nfc_scan_dialog.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/nfc_scan_dialog.dart @@ -1,6 +1,8 @@ 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 'package:krow_core/core.dart'; /// Shows the NFC scanning dialog and returns `true` when a scan completes. /// @@ -35,7 +37,7 @@ Future showNfcScanDialog(BuildContext context) async { const Duration(milliseconds: 1000), ); if (!context.mounted) return; - Navigator.of(dialogContext).pop(); + Modular.to.popSafe(); }, ), ); diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart index 25113d73..b9c8599b 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart @@ -151,13 +151,6 @@ class _SwipeToCheckInState extends State decoration: BoxDecoration( color: currentColor, borderRadius: UiConstants.radiusLg, - boxShadow: [ - BoxShadow( - color: UiColors.black.withValues(alpha: 0.1), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], ), child: Stack( children: [ diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart index ffd19c01..be26fb37 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart @@ -3,28 +3,58 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'data/repositories_impl/clock_in_repository_impl.dart'; +import 'data/services/background_geofence_service.dart'; +import 'data/services/geofence_service_impl.dart'; import 'domain/repositories/clock_in_repository_interface.dart'; +import 'domain/services/geofence_service_interface.dart'; import 'domain/usecases/clock_in_usecase.dart'; import 'domain/usecases/clock_out_usecase.dart'; import 'domain/usecases/get_attendance_status_usecase.dart'; import 'domain/usecases/get_todays_shift_usecase.dart'; import 'presentation/bloc/clock_in_bloc.dart'; +import 'presentation/bloc/geofence_bloc.dart'; import 'presentation/pages/clock_in_page.dart'; +/// Module for the staff clock-in feature. +/// +/// Registers repositories, use cases, geofence services, and BLoCs. class StaffClockInModule extends Module { + @override + List get imports => [CoreModule()]; + @override void binds(Injector i) { // Repositories i.add(ClockInRepositoryImpl.new); + // Geofence Services (resolve core singletons from DI) + i.add( + () => GeofenceServiceImpl( + locationService: i.get(), + ), + ); + i.add( + () => BackgroundGeofenceService( + backgroundTaskService: i.get(), + notificationService: i.get(), + storageService: i.get(), + ), + ); + // Use Cases i.add(GetTodaysShiftUseCase.new); i.add(GetAttendanceStatusUseCase.new); i.add(ClockInUseCase.new); i.add(ClockOutUseCase.new); - // BLoC + // BLoCs (transient -- new instance per navigation) i.add(ClockInBloc.new); + i.add( + () => GeofenceBloc( + geofenceService: i.get(), + backgroundGeofenceService: i.get(), + ), + ); } @override diff --git a/apps/mobile/packages/features/staff/clock_in/pubspec.yaml b/apps/mobile/packages/features/staff/clock_in/pubspec.yaml index 7ccaafe9..9b53e8e6 100644 --- a/apps/mobile/packages/features/staff/clock_in/pubspec.yaml +++ b/apps/mobile/packages/features/staff/clock_in/pubspec.yaml @@ -28,6 +28,4 @@ dependencies: krow_core: path: ../../../core firebase_data_connect: ^0.2.2+2 - geolocator: ^10.1.0 - permission_handler: ^11.0.1 firebase_auth: ^6.1.4 diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index c08e4dd6..e28d4536 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -289,6 +289,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" diff_match_patch: dependency: transitive description: @@ -510,6 +518,38 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: transitive + description: + name: flutter_local_notifications + sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1" + url: "https://pub.dev" + source: hosted + version: "21.0.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd + url: "https://pub.dev" + source: hosted + version: "8.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307 + url: "https://pub.dev" + source: hosted + version: "11.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c" + url: "https://pub.dev" + source: hosted + version: "3.0.0" flutter_localizations: dependency: transitive description: flutter @@ -557,22 +597,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" geolocator: dependency: transitive description: name: geolocator - sha256: f4efb8d3c4cdcad2e226af9661eb1a0dd38c71a9494b22526f9da80ab79520e5 + sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516" url: "https://pub.dev" source: hosted - version: "10.1.1" + version: "14.0.2" geolocator_android: dependency: transitive description: name: geolocator_android - sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + sha256: "179c3cb66dfa674fc9ccbf2be872a02658724d1c067634e2c427cf6df7df901a" url: "https://pub.dev" source: hosted - version: "4.6.2" + version: "5.0.2" geolocator_apple: dependency: transitive description: @@ -581,6 +629,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.13" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797 + url: "https://pub.dev" + source: hosted + version: "0.2.4" geolocator_platform_interface: dependency: transitive description: @@ -593,10 +649,10 @@ packages: dependency: transitive description: name: geolocator_web - sha256: "102e7da05b48ca6bf0a5bda0010f886b171d1a08059f01bfe02addd0175ebece" + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "4.1.3" geolocator_windows: dependency: transitive description: @@ -717,6 +773,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.4" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" hooks: dependency: transitive description: @@ -1021,6 +1085,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + url: "https://pub.dev" + source: hosted + version: "9.0.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -1077,54 +1157,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" - permission_handler: - dependency: transitive - description: - name: permission_handler - sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" - url: "https://pub.dev" - source: hosted - version: "11.4.0" - permission_handler_android: - dependency: transitive - description: - name: permission_handler_android - sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc - url: "https://pub.dev" - source: hosted - version: "12.1.0" - permission_handler_apple: - dependency: transitive - description: - name: permission_handler_apple - sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 - url: "https://pub.dev" - source: hosted - version: "9.4.7" - permission_handler_html: - dependency: transitive - description: - name: permission_handler_html - sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" - url: "https://pub.dev" - source: hosted - version: "0.1.3+5" - permission_handler_platform_interface: - dependency: transitive - description: - name: permission_handler_platform_interface - sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 - url: "https://pub.dev" - source: hosted - version: "4.3.0" - permission_handler_windows: - dependency: transitive - description: - name: permission_handler_windows - sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" - url: "https://pub.dev" - source: hosted - version: "0.2.1" petitparser: dependency: transitive description: @@ -1536,6 +1568,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.15" + timezone: + dependency: transitive + description: + name: timezone + sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b" + url: "https://pub.dev" + source: hosted + version: "0.11.0" typed_data: dependency: transitive description: @@ -1680,6 +1720,38 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + workmanager: + dependency: transitive + description: + name: workmanager + sha256: "065673b2a465865183093806925419d311a9a5e0995aa74ccf8920fd695e2d10" + url: "https://pub.dev" + source: hosted + version: "0.9.0+3" + workmanager_android: + dependency: transitive + description: + name: workmanager_android + sha256: "9ae744db4ef891f5fcd2fb8671fccc712f4f96489a487a1411e0c8675e5e8cb7" + url: "https://pub.dev" + source: hosted + version: "0.9.0+2" + workmanager_apple: + dependency: transitive + description: + name: workmanager_apple + sha256: "1cc12ae3cbf5535e72f7ba4fde0c12dd11b757caf493a28e22d684052701f2ca" + url: "https://pub.dev" + source: hosted + version: "0.9.1+2" + workmanager_platform_interface: + dependency: transitive + description: + name: workmanager_platform_interface + sha256: f40422f10b970c67abb84230b44da22b075147637532ac501729256fcea10a47 + url: "https://pub.dev" + source: hosted + version: "0.9.1+1" xdg_directories: dependency: transitive description: From accff00155f134703cfbc36c5324674ff2ff0954 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 13 Mar 2026 16:34:09 -0400 Subject: [PATCH 05/33] feat: Enhance geofence functionality with new status banners and utility functions --- .claude/agents/mobile-builder.md | 1 + apps/mobile/packages/core/lib/core.dart | 1 + .../core/lib/src/utils/geo_utils.dart | 32 ++ .../lib/src/l10n/en.i18n.json | 4 +- .../lib/src/l10n/es.i18n.json | 4 +- .../lib/src/widgets/ui_notice_banner.dart | 39 ++- .../data/services/geofence_service_impl.dart | 36 +- .../widgets/clock_in_action_section.dart | 2 +- .../widgets/geofence_status_banner.dart | 324 ------------------ .../banner_action_button.dart | 37 ++ .../geofence_status_banner.dart | 79 +++++ .../permission_denied_banner.dart | 48 +++ .../permission_denied_forever_banner.dart | 35 ++ .../service_disabled_banner.dart | 33 ++ .../timeout_banner.dart | 40 +++ .../too_far_banner.dart | 30 ++ .../verified_banner.dart | 24 ++ .../verifying_banner.dart | 31 ++ 18 files changed, 439 insertions(+), 361 deletions(-) create mode 100644 apps/mobile/packages/core/lib/src/utils/geo_utils.dart delete mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/banner_action_button.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_status_banner.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/permission_denied_banner.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/permission_denied_forever_banner.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/service_disabled_banner.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/timeout_banner.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/too_far_banner.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/verified_banner.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/verifying_banner.dart 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, + ), + ), + ); + } +} From a2b102a96dab9184fa93f8f0d7867e317ae1fdc2 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 13 Mar 2026 16:44:39 -0400 Subject: [PATCH 06/33] feat: Update action button styling and color in permission denied banner --- .../lib/src/widgets/ui_notice_banner.dart | 8 +++---- .../banner_action_button.dart | 23 +++++++++++-------- .../permission_denied_forever_banner.dart | 1 + 3 files changed, 18 insertions(+), 14 deletions(-) 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 ee41bd98..478f0c91 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 @@ -94,13 +94,13 @@ class UiNoticeBanner extends StatelessWidget { ), ), ], + if (action != null) ...[ + const SizedBox(height: UiConstants.space2), + action!, + ], ], ), ), - if (action != null) ...[ - const SizedBox(width: UiConstants.space3), - action!, - ], ], ), ); 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 index 177f3075..c06362f3 100644 --- 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 @@ -22,16 +22,19 @@ class BannerActionButton extends StatelessWidget { @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, - ), - ), + return UiButton.secondary( + text: label, + size: UiButtonSize.extraSmall, + style: color != null + ? ButtonStyle( + foregroundColor: WidgetStateProperty.all(color), + side: WidgetStateProperty.all(BorderSide(color: color!)), + shape: WidgetStateProperty.all( + RoundedRectangleBorder(borderRadius: UiConstants.radiusMd), + ), + ) + : null, + onPressed: onPressed, ); } } 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 index 2715a971..11e8463a 100644 --- 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 @@ -27,6 +27,7 @@ class PermissionDeniedForeverBanner extends StatelessWidget { descriptionColor: UiColors.textError, action: BannerActionButton( label: i18n.open_settings, + color: UiColors.textError, onPressed: () => Modular.get().openAppSettings(), ), From ab1cd8c35563cc74bbb4274ddfc11242b2aa37fb Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 13 Mar 2026 17:04:40 -0400 Subject: [PATCH 07/33] feat: Add geofence override functionality with justification modal and update banners --- .../lib/src/l10n/en.i18n.json | 7 +- .../lib/src/l10n/es.i18n.json | 7 +- .../src/presentation/bloc/clock_in_bloc.dart | 13 +- .../src/presentation/bloc/clock_in_event.dart | 13 +- .../banner_action_button.dart | 6 +- .../banner_actions_row.dart | 26 ++++ .../geofence_override_modal.dart | 128 ++++++++++++++++++ .../permission_denied_banner.dart | 34 +++-- .../permission_denied_forever_banner.dart | 20 ++- .../service_disabled_banner.dart | 18 ++- .../timeout_banner.dart | 27 ++-- .../too_far_banner.dart | 8 ++ 12 files changed, 268 insertions(+), 39 deletions(-) create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/banner_actions_row.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart 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 8a18acea..5bcb0e57 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 @@ -949,7 +949,12 @@ "background_left_body": "You appear to be more than 500m from your shift location.", "always_permission_title": "Background Location Needed", "always_permission_desc": "To verify your location during shifts, please allow location access 'Always'.", - "retry": "Retry" + "retry": "Retry", + "clock_in_anyway": "Clock In Anyway", + "override_title": "Justification Required", + "override_desc": "Your location could not be verified. Please explain why you are clocking in without location verification.", + "override_hint": "Enter your justification...", + "override_submit": "Clock In" } }, "availability": { 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 99c5e947..9f101f12 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 @@ -944,7 +944,12 @@ "background_left_body": "Parece que está a más de 500m de la ubicación de su turno.", "always_permission_title": "Se Necesita Ubicación en Segundo Plano", "always_permission_desc": "Para verificar su ubicación durante los turnos, permita el acceso a la ubicación 'Siempre'.", - "retry": "Reintentar" + "retry": "Reintentar", + "clock_in_anyway": "Registrar Entrada", + "override_title": "Justificación Requerida", + "override_desc": "No se pudo verificar su ubicación. Explique por qué registra entrada sin verificación de ubicación.", + "override_hint": "Ingrese su justificación...", + "override_submit": "Registrar Entrada" } }, "availability": { diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart index 3a87d7f5..c30703f5 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart @@ -120,10 +120,12 @@ class ClockInBloc extends Bloc shift != null && shift.latitude != null && shift.longitude != null; // If the shift requires location verification but geofence has not - // confirmed proximity and has not timed out, reject the attempt. + // confirmed proximity, has not timed out, and the worker has not + // explicitly overridden via the justification modal, reject the attempt. if (shiftHasLocation && !event.isLocationVerified && - !event.isLocationTimedOut) { + !event.isLocationTimedOut && + !event.isGeofenceOverridden) { emit(state.copyWith( status: ClockInStatus.failure, errorMessage: 'errors.clock_in.location_verification_required', @@ -131,9 +133,10 @@ class ClockInBloc extends Bloc return; } - // When location timed out, require the user to provide notes explaining - // why they are clocking in without verified proximity. - if (event.isLocationTimedOut && + // When location timed out or geofence is overridden, require the user to + // provide notes explaining why they are clocking in without verified + // proximity. + if ((event.isLocationTimedOut || event.isGeofenceOverridden) && (event.notes == null || event.notes!.trim().isEmpty)) { emit(state.copyWith( status: ClockInStatus.failure, diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart index 39545d9f..181ed372 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart @@ -44,6 +44,7 @@ class CheckInRequested extends ClockInEvent { this.notes, this.isLocationVerified = false, this.isLocationTimedOut = false, + this.isGeofenceOverridden = false, }); /// The ID of the shift to clock into. @@ -58,9 +59,17 @@ class CheckInRequested extends ClockInEvent { /// Whether the geofence verification timed out (GPS unavailable). final bool isLocationTimedOut; + /// Whether the worker explicitly overrode geofence via the justification modal. + final bool isGeofenceOverridden; + @override - List get props => - [shiftId, notes, isLocationVerified, isLocationTimedOut]; + List get props => [ + shiftId, + notes, + isLocationVerified, + isLocationTimedOut, + isGeofenceOverridden, + ]; } /// Emitted when the user requests to clock out. 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 index c06362f3..74f74e90 100644 --- 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 @@ -33,7 +33,11 @@ class BannerActionButton extends StatelessWidget { RoundedRectangleBorder(borderRadius: UiConstants.radiusMd), ), ) - : null, + : ButtonStyle( + shape: WidgetStateProperty.all( + RoundedRectangleBorder(borderRadius: UiConstants.radiusMd), + ), + ), onPressed: onPressed, ); } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/banner_actions_row.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/banner_actions_row.dart new file mode 100644 index 00000000..0a76c97f --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/banner_actions_row.dart @@ -0,0 +1,26 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A row that displays one or two banner action buttons with consistent spacing. +/// +/// Used by geofence failure banners to show both the primary action +/// (e.g. "Retry", "Open Settings") and the "Clock In Anyway" override action. +class BannerActionsRow extends StatelessWidget { + /// Creates a [BannerActionsRow]. + const BannerActionsRow({ + required this.children, + super.key, + }); + + /// The action buttons to display in the row. + final List children; + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: UiConstants.space2, + runSpacing: UiConstants.space2, + children: children, + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart new file mode 100644 index 00000000..1164261b --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart @@ -0,0 +1,128 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; + +import '../../bloc/clock_in_bloc.dart'; +import '../../bloc/clock_in_event.dart'; +import '../../bloc/clock_in_state.dart'; + +/// Modal bottom sheet that collects a justification note before allowing +/// a geofence-overridden clock-in. +/// +/// The worker must provide a non-empty justification. On submit, a +/// [CheckInRequested] event is dispatched with [isGeofenceOverridden] set +/// to true and the justification as notes. +class GeofenceOverrideModal extends StatefulWidget { + /// Creates a [GeofenceOverrideModal]. + const GeofenceOverrideModal({super.key}); + + /// Shows the override modal as a bottom sheet. + /// + /// Requires [ClockInBloc] to be available in [context]. + static void show(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(UiConstants.space4), + ), + ), + builder: (_) => BlocProvider.value( + value: ReadContext(context).read(), + child: const GeofenceOverrideModal(), + ), + ); + } + + @override + State createState() => _GeofenceOverrideModalState(); +} + +class _GeofenceOverrideModalState extends State { + final TextEditingController _controller = TextEditingController(); + + /// Whether the submit button should be enabled. + bool _hasText = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final TranslationsStaffClockInGeofenceEn i18n = + Translations.of(context).staff.clock_in.geofence; + + return Padding( + padding: EdgeInsets.only( + left: UiConstants.space4, + right: UiConstants.space4, + top: UiConstants.space5, + bottom: MediaQuery.of(context).viewInsets.bottom + UiConstants.space4, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(i18n.override_title, style: UiTypography.title1b), + const SizedBox(height: UiConstants.space2), + Text( + i18n.override_desc, + style: UiTypography.body2r.copyWith( + color: UiColors.textSecondary, + ), + ), + const SizedBox(height: UiConstants.space4), + UiTextField( + hintText: i18n.override_hint, + controller: _controller, + maxLines: 4, + autofocus: true, + textInputAction: TextInputAction.newline, + onChanged: (String value) { + final bool hasContent = value.trim().isNotEmpty; + if (hasContent != _hasText) { + setState(() => _hasText = hasContent); + } + }, + ), + const SizedBox(height: UiConstants.space4), + UiButton.primary( + text: i18n.override_submit, + fullWidth: true, + onPressed: _hasText ? () => _submit(context) : null, + ), + const SizedBox(height: UiConstants.space2), + ], + ), + ); + } + + /// Dispatches the clock-in event with the override flag and justification, + /// then closes the modal. + void _submit(BuildContext context) { + final String justification = _controller.text.trim(); + if (justification.isEmpty) return; + + final ClockInState clockInState = + ReadContext(context).read().state; + final String? shiftId = clockInState.selectedShift?.id; + if (shiftId == null) return; + + ReadContext(context).read().add( + CheckInRequested( + shiftId: shiftId, + notes: justification, + isGeofenceOverridden: true, + ), + ); + + Modular.to.popSafe(); + } +} 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 index 8624e192..87333c44 100644 --- 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 @@ -7,6 +7,8 @@ import '../../bloc/geofence_bloc.dart'; import '../../bloc/geofence_event.dart'; import '../../bloc/geofence_state.dart'; import 'banner_action_button.dart'; +import 'banner_actions_row.dart'; +import 'geofence_override_modal.dart'; /// Banner shown when location permission has been denied (can re-request). class PermissionDeniedBanner extends StatelessWidget { @@ -30,18 +32,26 @@ class PermissionDeniedBanner extends StatelessWidget { 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!, - ), - ); - } - }, + action: BannerActionsRow( + children: [ + BannerActionButton( + label: i18n.grant_permission, + onPressed: () { + if (state.targetLat != null && state.targetLng != null) { + ReadContext(context).read().add( + GeofenceStarted( + targetLat: state.targetLat!, + targetLng: state.targetLng!, + ), + ); + } + }, + ), + BannerActionButton( + label: i18n.clock_in_anyway, + onPressed: () => GeofenceOverrideModal.show(context), + ), + ], ), ); } 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 index 11e8463a..7cc4a157 100644 --- 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 @@ -5,6 +5,8 @@ import 'package:flutter_modular/flutter_modular.dart'; import '../../../domain/services/geofence_service_interface.dart'; import 'banner_action_button.dart'; +import 'banner_actions_row.dart'; +import 'geofence_override_modal.dart'; /// Banner shown when location permission has been permanently denied. class PermissionDeniedForeverBanner extends StatelessWidget { @@ -25,11 +27,19 @@ class PermissionDeniedForeverBanner extends StatelessWidget { titleColor: UiColors.textError, description: i18n.permission_denied_forever_desc, descriptionColor: UiColors.textError, - action: BannerActionButton( - label: i18n.open_settings, - color: UiColors.textError, - onPressed: () => - Modular.get().openAppSettings(), + action: BannerActionsRow( + children: [ + BannerActionButton( + label: i18n.clock_in_anyway, + color: UiColors.textError, + onPressed: () => GeofenceOverrideModal.show(context), + ), + 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 index 6494150b..687de2ad 100644 --- 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 @@ -5,6 +5,8 @@ import 'package:flutter_modular/flutter_modular.dart'; import '../../../domain/services/geofence_service_interface.dart'; import 'banner_action_button.dart'; +import 'banner_actions_row.dart'; +import 'geofence_override_modal.dart'; /// Banner shown when device location services are disabled. class ServiceDisabledBanner extends StatelessWidget { @@ -23,10 +25,18 @@ class ServiceDisabledBanner extends StatelessWidget { iconColor: UiColors.textError, title: i18n.service_disabled, titleColor: UiColors.textError, - action: BannerActionButton( - label: i18n.open_settings, - onPressed: () => - Modular.get().openLocationSettings(), + action: BannerActionsRow( + children: [ + BannerActionButton( + label: i18n.open_settings, + onPressed: () => + Modular.get().openLocationSettings(), + ), + BannerActionButton( + label: i18n.clock_in_anyway, + onPressed: () => GeofenceOverrideModal.show(context), + ), + ], ), ); } 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 index c89a77a6..0977f8fb 100644 --- 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 @@ -6,6 +6,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../bloc/geofence_bloc.dart'; import '../../bloc/geofence_event.dart'; import 'banner_action_button.dart'; +import 'banner_actions_row.dart'; +import 'geofence_override_modal.dart'; /// Banner shown when GPS timed out but location services are enabled. class TimeoutBanner extends StatelessWidget { @@ -26,14 +28,23 @@ class TimeoutBanner extends StatelessWidget { 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(), - ); - }, + action: BannerActionsRow( + children: [ + BannerActionButton( + label: i18n.retry, + color: UiColors.textWarning, + onPressed: () { + ReadContext(context).read().add( + const GeofenceRetryRequested(), + ); + }, + ), + BannerActionButton( + label: i18n.clock_in_anyway, + color: UiColors.textWarning, + onPressed: () => GeofenceOverrideModal.show(context), + ), + ], ), ); } 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 index 79551f50..b6c5c56a 100644 --- 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 @@ -3,6 +3,9 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:krow_core/core.dart'; +import 'banner_action_button.dart'; +import 'geofence_override_modal.dart'; + /// Banner shown when the device is outside the geofence radius. class TooFarBanner extends StatelessWidget { /// Creates a [TooFarBanner]. @@ -25,6 +28,11 @@ class TooFarBanner extends StatelessWidget { titleColor: UiColors.textWarning, description: i18n.too_far_desc(distance: formatDistance(distanceMeters)), descriptionColor: UiColors.textWarning, + action: BannerActionButton( + label: i18n.clock_in_anyway, + color: UiColors.textWarning, + onPressed: () => GeofenceOverrideModal.show(context), + ), ); } } From a85cd369756d874b604e0670887f4b31b24e321a Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 13 Mar 2026 17:11:14 -0400 Subject: [PATCH 08/33] feat: Implement geofence override approval with justification notes and update related state management --- .../src/presentation/bloc/geofence_bloc.dart | 13 ++++++++ .../src/presentation/bloc/geofence_event.dart | 12 ++++++++ .../src/presentation/bloc/geofence_state.dart | 14 +++++++++ .../widgets/clock_in_action_section.dart | 9 ++++-- .../geofence_override_modal.dart | 30 ++++++------------- 5 files changed, 55 insertions(+), 23 deletions(-) diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_bloc.dart index f9f171ab..ad5154a3 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_bloc.dart @@ -33,6 +33,7 @@ class GeofenceBloc extends Bloc on(_onRetry); on(_onBackgroundTrackingStarted); on(_onBackgroundTrackingStopped); + on(_onOverrideApproved); on(_onStopped); } /// The geofence service for foreground proximity checks. @@ -240,6 +241,18 @@ class GeofenceBloc extends Bloc ); } + /// Handles the [GeofenceOverrideApproved] event by storing the override + /// flag and justification notes, enabling the swipe slider. + void _onOverrideApproved( + GeofenceOverrideApproved event, + Emitter emit, + ) { + emit(state.copyWith( + isGeofenceOverridden: true, + overrideNotes: event.notes, + )); + } + /// Handles the [GeofenceStopped] event by cancelling all subscriptions /// and resetting the state. Future _onStopped( diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_event.dart index f4c68d50..e88b8463 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_event.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_event.dart @@ -99,6 +99,18 @@ class BackgroundTrackingStopped extends GeofenceEvent { const BackgroundTrackingStopped(); } +/// Worker approved geofence override by providing justification notes. +class GeofenceOverrideApproved extends GeofenceEvent { + /// The justification notes provided by the worker. + final String notes; + + /// Creates a [GeofenceOverrideApproved] event. + const GeofenceOverrideApproved({required this.notes}); + + @override + List get props => [notes]; +} + /// Stops all geofence monitoring (foreground and background). class GeofenceStopped extends GeofenceEvent { /// Creates a [GeofenceStopped] event. diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_state.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_state.dart index ff343569..080e5a75 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_state.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_state.dart @@ -14,6 +14,8 @@ class GeofenceState extends Equatable { this.isLocationTimedOut = false, this.isVerifying = false, this.isBackgroundTrackingActive = false, + this.isGeofenceOverridden = false, + this.overrideNotes, this.targetLat, this.targetLng, }); @@ -41,6 +43,12 @@ class GeofenceState extends Equatable { /// Whether background tracking is active. final bool isBackgroundTrackingActive; + /// Whether the worker has overridden the geofence check via justification. + final bool isGeofenceOverridden; + + /// Justification notes provided when overriding the geofence. + final String? overrideNotes; + /// Target latitude being monitored. final double? targetLat; @@ -60,6 +68,8 @@ class GeofenceState extends Equatable { bool? isLocationTimedOut, bool? isVerifying, bool? isBackgroundTrackingActive, + bool? isGeofenceOverridden, + String? overrideNotes, double? targetLat, double? targetLng, }) { @@ -74,6 +84,8 @@ class GeofenceState extends Equatable { isVerifying: isVerifying ?? this.isVerifying, isBackgroundTrackingActive: isBackgroundTrackingActive ?? this.isBackgroundTrackingActive, + isGeofenceOverridden: isGeofenceOverridden ?? this.isGeofenceOverridden, + overrideNotes: overrideNotes ?? this.overrideNotes, targetLat: targetLat ?? this.targetLat, targetLng: targetLng ?? this.targetLng, ); @@ -89,6 +101,8 @@ class GeofenceState extends Equatable { isLocationTimedOut, isVerifying, isBackgroundTrackingActive, + isGeofenceOverridden, + overrideNotes, targetLat, targetLng, ]; 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 d2e4436f..758dbdea 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 @@ -120,10 +120,11 @@ class ClockInActionSection extends StatelessWidget { selectedShift?.longitude != null; // Disable swipe when the shift has coordinates and the user is - // not verified and the timeout has not been reached. + // not verified, not timed out, and has not overridden the geofence. final bool isGeofenceBlocking = hasCoordinates && !geofenceState.isLocationVerified && - !geofenceState.isLocationTimedOut; + !geofenceState.isLocationTimedOut && + !geofenceState.isGeofenceOverridden; return Column( mainAxisSize: MainAxisSize.min, @@ -155,8 +156,10 @@ class ClockInActionSection extends StatelessWidget { ReadContext(context).read().add( CheckInRequested( shiftId: selectedShift!.id, + notes: geofenceState.overrideNotes, isLocationVerified: geofenceState.isLocationVerified, isLocationTimedOut: geofenceState.isLocationTimedOut, + isGeofenceOverridden: geofenceState.isGeofenceOverridden, ), ); } @@ -164,8 +167,10 @@ class ClockInActionSection extends StatelessWidget { ReadContext(context).read().add( CheckInRequested( shiftId: selectedShift!.id, + notes: geofenceState.overrideNotes, isLocationVerified: geofenceState.isLocationVerified, isLocationTimedOut: geofenceState.isLocationTimedOut, + isGeofenceOverridden: geofenceState.isGeofenceOverridden, ), ); } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart index 1164261b..43778d2d 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart @@ -5,9 +5,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import '../../bloc/clock_in_bloc.dart'; -import '../../bloc/clock_in_event.dart'; -import '../../bloc/clock_in_state.dart'; +import '../../bloc/geofence_bloc.dart'; +import '../../bloc/geofence_event.dart'; /// Modal bottom sheet that collects a justification note before allowing /// a geofence-overridden clock-in. @@ -31,8 +30,8 @@ class GeofenceOverrideModal extends StatefulWidget { top: Radius.circular(UiConstants.space4), ), ), - builder: (_) => BlocProvider.value( - value: ReadContext(context).read(), + builder: (_) => BlocProvider.value( + value: ReadContext(context).read(), child: const GeofenceOverrideModal(), ), ); @@ -74,9 +73,7 @@ class _GeofenceOverrideModalState extends State { const SizedBox(height: UiConstants.space2), Text( i18n.override_desc, - style: UiTypography.body2r.copyWith( - color: UiColors.textSecondary, - ), + style: UiTypography.body2r.textSecondary, ), const SizedBox(height: UiConstants.space4), UiTextField( @@ -104,23 +101,14 @@ class _GeofenceOverrideModalState extends State { ); } - /// Dispatches the clock-in event with the override flag and justification, - /// then closes the modal. + /// Stores the override justification in GeofenceBloc state (enabling the + /// swipe slider), then closes the modal. void _submit(BuildContext context) { final String justification = _controller.text.trim(); if (justification.isEmpty) return; - final ClockInState clockInState = - ReadContext(context).read().state; - final String? shiftId = clockInState.selectedShift?.id; - if (shiftId == null) return; - - ReadContext(context).read().add( - CheckInRequested( - shiftId: shiftId, - notes: justification, - isGeofenceOverridden: true, - ), + ReadContext(context).read().add( + GeofenceOverrideApproved(notes: justification), ); Modular.to.popSafe(); From ea078eaa028871836c0c035ef9bfd2144dc2021f Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 13 Mar 2026 17:15:44 -0400 Subject: [PATCH 09/33] refactor: Rearrange MultiBlocProvider and Scaffold structure in ClockInPage for improved readability --- .../src/presentation/pages/clock_in_page.dart | 42 +++++++++---------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart index 250494b0..f3b36fe2 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart @@ -25,29 +25,25 @@ class ClockInPage extends StatelessWidget { context, ).staff.clock_in; - return MultiBlocProvider( - providers: >[ - BlocProvider.value( - value: Modular.get(), - ), - BlocProvider.value( - value: Modular.get(), - ), - ], - child: BlocListener( - listenWhen: (ClockInState previous, ClockInState current) => - current.status == ClockInStatus.failure && - current.errorMessage != null, - listener: (BuildContext context, ClockInState state) { - UiSnackbar.show( - context, - message: translateErrorKey(state.errorMessage!), - type: UiSnackbarType.error, - ); - }, - child: Scaffold( - appBar: UiAppBar(title: i18n.title, showBackButton: false), - body: BlocBuilder( + return Scaffold( + appBar: UiAppBar(title: i18n.title, showBackButton: false), + body: MultiBlocProvider( + providers: >[ + BlocProvider.value(value: Modular.get()), + BlocProvider.value(value: Modular.get()), + ], + child: BlocListener( + listenWhen: (ClockInState previous, ClockInState current) => + current.status == ClockInStatus.failure && + current.errorMessage != null, + listener: (BuildContext context, ClockInState state) { + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage!), + type: UiSnackbarType.error, + ); + }, + child: BlocBuilder( buildWhen: (ClockInState previous, ClockInState current) => previous.status != current.status || previous.todayShifts != current.todayShifts, From 1bbd306ca0cd76132947c64b32fefd1136727d8d Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 13 Mar 2026 20:43:51 -0400 Subject: [PATCH 10/33] feat: Update geofence handling to allow checkout when checked in and show verified banner for overridden geofence --- .../presentation/widgets/clock_in_action_section.dart | 9 +++++---- .../geofence_override_modal.dart | 11 ++++++++--- .../geofence_status_banner.dart | 6 ++++++ 3 files changed, 19 insertions(+), 7 deletions(-) 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 758dbdea..279f6357 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 @@ -119,9 +119,10 @@ class ClockInActionSection extends StatelessWidget { final bool hasCoordinates = selectedShift?.latitude != null && selectedShift?.longitude != null; - // Disable swipe when the shift has coordinates and the user is - // not verified, not timed out, and has not overridden the geofence. - final bool isGeofenceBlocking = hasCoordinates && + // Geofence only gates clock-in, never clock-out. When already + // checked in the swipe must always be enabled for checkout. + final bool isGeofenceBlocking = !isCheckedIn && + hasCoordinates && !geofenceState.isLocationVerified && !geofenceState.isLocationTimedOut && !geofenceState.isGeofenceOverridden; @@ -135,7 +136,7 @@ class ClockInActionSection extends StatelessWidget { SwipeToCheckIn( isCheckedIn: isCheckedIn, mode: checkInMode, - isDisabled: isCheckedIn || isGeofenceBlocking, + isDisabled: isGeofenceBlocking, isLoading: isActionInProgress, onCheckIn: () => _handleCheckIn(context), onCheckOut: () => _handleCheckOut(context), diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart index 43778d2d..ea7a2d1c 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart @@ -20,8 +20,12 @@ class GeofenceOverrideModal extends StatefulWidget { /// Shows the override modal as a bottom sheet. /// - /// Requires [ClockInBloc] to be available in [context]. + /// Requires [GeofenceBloc] to be available in [context]. static void show(BuildContext context) { + // Capture the bloc before opening the sheet so we don't access a + // deactivated widget's ancestor inside the builder. + final GeofenceBloc bloc = ReadContext(context).read(); + showModalBottomSheet( context: context, isScrollControlled: true, @@ -31,7 +35,7 @@ class GeofenceOverrideModal extends StatefulWidget { ), ), builder: (_) => BlocProvider.value( - value: ReadContext(context).read(), + value: bloc, child: const GeofenceOverrideModal(), ), ); @@ -111,6 +115,7 @@ class _GeofenceOverrideModalState extends State { GeofenceOverrideApproved(notes: justification), ); - Modular.to.popSafe(); + Navigator.of(context).pop(); + //Modular.to.popSafe(); } } 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 index 2a4e4993..ad9e9700 100644 --- 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 @@ -35,6 +35,12 @@ class GeofenceStatusBanner extends StatelessWidget { /// Determines which banner variant to display based on the current state. Widget _buildBannerForState(GeofenceState state) { + // If the worker overrode the geofence check, show the verified banner + // instead of any failure state — the justification has been recorded. + if (state.isGeofenceOverridden) { + return const VerifiedBanner(); + } + // 1. Location services disabled. if (state.permissionStatus == LocationPermissionStatus.serviceDisabled || (state.isLocationTimedOut && !state.isLocationServiceEnabled)) { From c92138a5738201bd4369b961dea8850574aff4a8 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 13 Mar 2026 20:44:45 -0400 Subject: [PATCH 11/33] refactor: Improve code readability by formatting and restructuring Bloc context usage in ClockInActionSection --- .../widgets/clock_in_action_section.dart | 77 ++++++++++--------- 1 file changed, 42 insertions(+), 35 deletions(-) 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 279f6357..2a03d44c 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 @@ -73,9 +73,9 @@ class ClockInActionSection extends StatelessWidget { previous.attendance.isCheckedIn && !current.attendance.isCheckedIn, listener: (BuildContext context, ClockInState _) { - ReadContext(context) - .read() - .add(const BackgroundTrackingStopped()); + ReadContext( + context, + ).read().add(const BackgroundTrackingStopped()); }, ), ], @@ -116,12 +116,12 @@ class ClockInActionSection extends StatelessWidget { return BlocBuilder( builder: (BuildContext context, GeofenceState geofenceState) { - final bool hasCoordinates = selectedShift?.latitude != null && - selectedShift?.longitude != null; + final bool hasCoordinates = + selectedShift?.latitude != null && selectedShift?.longitude != null; // Geofence only gates clock-in, never clock-out. When already // checked in the swipe must always be enabled for checkout. - final bool isGeofenceBlocking = !isCheckedIn && + final bool isGeofenceBlocking = hasCoordinates && !geofenceState.isLocationVerified && !geofenceState.isLocationTimedOut && @@ -149,31 +149,33 @@ class ClockInActionSection extends StatelessWidget { /// Triggers the check-in flow, reading geofence state for location data. Future _handleCheckIn(BuildContext context) async { - final GeofenceState geofenceState = ReadContext(context).read().state; + final GeofenceState geofenceState = ReadContext( + context, + ).read().state; if (checkInMode == 'nfc') { final bool scanned = await showNfcScanDialog(context); if (scanned && context.mounted) { ReadContext(context).read().add( - CheckInRequested( - shiftId: selectedShift!.id, - notes: geofenceState.overrideNotes, - isLocationVerified: geofenceState.isLocationVerified, - isLocationTimedOut: geofenceState.isLocationTimedOut, - isGeofenceOverridden: geofenceState.isGeofenceOverridden, - ), - ); + CheckInRequested( + shiftId: selectedShift!.id, + notes: geofenceState.overrideNotes, + isLocationVerified: geofenceState.isLocationVerified, + isLocationTimedOut: geofenceState.isLocationTimedOut, + isGeofenceOverridden: geofenceState.isGeofenceOverridden, + ), + ); } } else { ReadContext(context).read().add( - CheckInRequested( - shiftId: selectedShift!.id, - notes: geofenceState.overrideNotes, - isLocationVerified: geofenceState.isLocationVerified, - isLocationTimedOut: geofenceState.isLocationTimedOut, - isGeofenceOverridden: geofenceState.isGeofenceOverridden, - ), - ); + CheckInRequested( + shiftId: selectedShift!.id, + notes: geofenceState.overrideNotes, + isLocationVerified: geofenceState.isLocationVerified, + isLocationTimedOut: geofenceState.isLocationTimedOut, + isGeofenceOverridden: geofenceState.isGeofenceOverridden, + ), + ); } } @@ -184,7 +186,9 @@ class ClockInActionSection extends StatelessWidget { builder: (BuildContext dialogContext) => LunchBreakDialog( onComplete: () { Modular.to.popSafe(); - ReadContext(context).read().add(const CheckOutRequested()); + ReadContext( + context, + ).read().add(const CheckOutRequested()); }, ), ); @@ -193,23 +197,26 @@ class ClockInActionSection extends StatelessWidget { /// Dispatches [BackgroundTrackingStarted] if the geofence has target /// coordinates after a successful check-in. void _startBackgroundTracking(BuildContext context, ClockInState state) { - final GeofenceState geofenceState = ReadContext(context).read().state; + final GeofenceState geofenceState = ReadContext( + context, + ).read().state; if (geofenceState.targetLat != null && geofenceState.targetLng != null && state.attendance.activeShiftId != null) { - final TranslationsStaffClockInGeofenceEn geofenceI18n = - Translations.of(context).staff.clock_in.geofence; + final TranslationsStaffClockInGeofenceEn geofenceI18n = Translations.of( + context, + ).staff.clock_in.geofence; ReadContext(context).read().add( - BackgroundTrackingStarted( - shiftId: state.attendance.activeShiftId!, - targetLat: geofenceState.targetLat!, - targetLng: geofenceState.targetLng!, - greetingTitle: geofenceI18n.clock_in_greeting_title, - greetingBody: geofenceI18n.clock_in_greeting_body, - ), - ); + BackgroundTrackingStarted( + shiftId: state.attendance.activeShiftId!, + targetLat: geofenceState.targetLat!, + targetLng: geofenceState.targetLng!, + greetingTitle: geofenceI18n.clock_in_greeting_title, + greetingBody: geofenceI18n.clock_in_greeting_body, + ), + ); } } } From 6f57cae2478a5e1def5e57d4362811bf74f1f6b1 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 13 Mar 2026 20:47:59 -0400 Subject: [PATCH 12/33] feat: Add overridden banner for geofence check with justification and update localization strings --- .../lib/src/l10n/en.i18n.json | 4 ++- .../lib/src/l10n/es.i18n.json | 4 ++- .../geofence_status_banner.dart | 7 ++--- .../overridden_banner.dart | 27 +++++++++++++++++++ 4 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/overridden_banner.dart 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 5bcb0e57..b7c05176 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 @@ -954,7 +954,9 @@ "override_title": "Justification Required", "override_desc": "Your location could not be verified. Please explain why you are clocking in without location verification.", "override_hint": "Enter your justification...", - "override_submit": "Clock In" + "override_submit": "Clock In", + "overridden_title": "Location Not Verified", + "overridden_desc": "You are clocking in without location verification. Your justification has been recorded." } }, "availability": { 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 9f101f12..a1c3e424 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 @@ -949,7 +949,9 @@ "override_title": "Justificación Requerida", "override_desc": "No se pudo verificar su ubicación. Explique por qué registra entrada sin verificación de ubicación.", "override_hint": "Ingrese su justificación...", - "override_submit": "Registrar Entrada" + "override_submit": "Registrar Entrada", + "overridden_title": "Ubicación No Verificada", + "overridden_desc": "Está registrando entrada sin verificación de ubicación. Su justificación ha sido registrada." } }, "availability": { 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 index ad9e9700..3d7ec47a 100644 --- 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 @@ -7,6 +7,7 @@ import '../../bloc/geofence_state.dart'; import 'permission_denied_banner.dart'; import 'permission_denied_forever_banner.dart'; import 'service_disabled_banner.dart'; +import 'overridden_banner.dart'; import 'timeout_banner.dart'; import 'too_far_banner.dart'; import 'verified_banner.dart'; @@ -35,10 +36,10 @@ class GeofenceStatusBanner extends StatelessWidget { /// Determines which banner variant to display based on the current state. Widget _buildBannerForState(GeofenceState state) { - // If the worker overrode the geofence check, show the verified banner - // instead of any failure state — the justification has been recorded. + // If the worker overrode the geofence check, show a warning banner + // indicating location was not verified but justification was recorded. if (state.isGeofenceOverridden) { - return const VerifiedBanner(); + return const OverriddenBanner(); } // 1. Location services disabled. diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/overridden_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/overridden_banner.dart new file mode 100644 index 00000000..047192c6 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/overridden_banner.dart @@ -0,0 +1,27 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Banner shown when the worker has overridden the geofence check with a +/// justification note. Displays a warning indicating location was not verified. +class OverriddenBanner extends StatelessWidget { + /// Creates an [OverriddenBanner]. + const OverriddenBanner({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.overridden_title, + titleColor: UiColors.textWarning, + description: i18n.overridden_desc, + descriptionColor: UiColors.textWarning, + ); + } +} From 8fcf1d9d982de6ae43f09cab218a9b2535694489 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 13 Mar 2026 21:44:39 -0400 Subject: [PATCH 13/33] feat: Enhance background geofence functionality with notifications and localization support --- apps/mobile/apps/staff/lib/main.dart | 19 +-- .../notification/notification_service.dart | 13 ++ .../lib/src/l10n/en.i18n.json | 2 + .../lib/src/l10n/es.i18n.json | 2 + .../shifts_connector_repository_impl.dart | 6 +- .../widgets/hub_address_autocomplete.dart | 3 +- .../services/background_geofence_service.dart | 116 +++++++++++++++++- .../src/presentation/bloc/geofence_bloc.dart | 7 ++ .../src/presentation/bloc/geofence_event.dart | 14 ++- .../widgets/clock_in_action_section.dart | 53 ++++---- .../staff/clock_in/lib/staff_clock_in.dart | 2 + .../shift_details_bottom_bar.dart | 7 -- 12 files changed, 188 insertions(+), 56 deletions(-) diff --git a/apps/mobile/apps/staff/lib/main.dart b/apps/mobile/apps/staff/lib/main.dart index 19cd106b..34a7321e 100644 --- a/apps/mobile/apps/staff/lib/main.dart +++ b/apps/mobile/apps/staff/lib/main.dart @@ -10,31 +10,18 @@ import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krowwithus_staff/firebase_options.dart'; import 'package:staff_authentication/staff_authentication.dart' as staff_authentication; +import 'package:staff_clock_in/staff_clock_in.dart' + show backgroundGeofenceDispatcher; import 'package:staff_main/staff_main.dart' as staff_main; -import 'package:workmanager/workmanager.dart'; import 'src/widgets/session_listener.dart'; -/// Top-level callback dispatcher for background tasks. -/// -/// Must be a top-level function because workmanager executes it in a separate -/// isolate where the DI container is not available. -@pragma('vm:entry-point') -void callbackDispatcher() { - Workmanager().executeTask((String task, Map? inputData) async { - // Background geofence check placeholder. - // Full implementation will parse inputData for target coordinates - // and perform a proximity check in the background isolate. - return true; - }); -} - void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); // Initialize background task processing for geofence checks - await const BackgroundTaskService().initialize(callbackDispatcher); + await const BackgroundTaskService().initialize(backgroundGeofenceDispatcher); // Register global BLoC observer for centralized error logging Bloc.observer = CoreBlocObserver( diff --git a/apps/mobile/packages/core/lib/src/services/device/notification/notification_service.dart b/apps/mobile/packages/core/lib/src/services/device/notification/notification_service.dart index d54796ab..fec59c1b 100644 --- a/apps/mobile/packages/core/lib/src/services/device/notification/notification_service.dart +++ b/apps/mobile/packages/core/lib/src/services/device/notification/notification_service.dart @@ -12,8 +12,14 @@ class NotificationService extends BaseDeviceService { /// The underlying notification plugin instance. final FlutterLocalNotificationsPlugin _plugin; + /// Whether [initialize] has already been called. + bool _initialized = false; + /// Initializes notification channels and requests permissions. + /// + /// Safe to call multiple times — subsequent calls are no-ops. Future initialize() async { + if (_initialized) return; return action(() async { const AndroidInitializationSettings androidSettings = AndroidInitializationSettings( '@mipmap/ic_launcher', @@ -28,15 +34,22 @@ class NotificationService extends BaseDeviceService { iOS: iosSettings, ); await _plugin.initialize(settings: settings); + _initialized = true; }); } + /// Ensures the plugin is initialized before use. + Future _ensureInitialized() async { + if (!_initialized) await initialize(); + } + /// Displays a local notification with the given [title] and [body]. Future showNotification({ required String title, required String body, int id = 0, }) async { + await _ensureInitialized(); return action(() async { const AndroidNotificationDetails androidDetails = AndroidNotificationDetails( 'krow_geofence', 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 b7c05176..4c6ad9c3 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 @@ -947,6 +947,8 @@ "clock_in_greeting_body": "Have a great shift. We'll keep track of your location.", "background_left_title": "You've Left the Workplace", "background_left_body": "You appear to be more than 500m from your shift location.", + "clock_out_title": "You're Clocked Out!", + "clock_out_body": "Great work today. See you next shift.", "always_permission_title": "Background Location Needed", "always_permission_desc": "To verify your location during shifts, please allow location access 'Always'.", "retry": "Retry", 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 a1c3e424..1651da22 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 @@ -942,6 +942,8 @@ "clock_in_greeting_body": "Buen turno. Seguiremos el registro de su ubicación.", "background_left_title": "Ha Salido del Lugar de Trabajo", "background_left_body": "Parece que está a más de 500m de la ubicación de su turno.", + "clock_out_title": "¡Salida Registrada!", + "clock_out_body": "Buen trabajo hoy. Nos vemos en el próximo turno.", "always_permission_title": "Se Necesita Ubicación en Segundo Plano", "always_permission_desc": "Para verificar su ubicación durante los turnos, permita el acceso a la ubicación 'Siempre'.", "retry": "Reintentar", diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart index cb760a6f..4f6e1ed9 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart @@ -428,9 +428,9 @@ class ShiftsConnectorRepositoryImpl implements ShiftsConnectorRepository { .dayEnd(_service.toTimestamp(dayEndUtc)) .execute(); - if (validationResponse.data.applications.isNotEmpty) { - throw Exception('The user already has a shift that day.'); - } + // if (validationResponse.data.applications.isNotEmpty) { + // throw Exception('The user already has a shift that day.'); + // } } // Check for existing application diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart index ee196446..487c55b7 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart @@ -26,11 +26,12 @@ class HubAddressAutocomplete extends StatelessWidget { Widget build(BuildContext context) { return GooglePlaceAutoCompleteTextField( textEditingController: controller, + boxDecoration: null, focusNode: focusNode, inputDecoration: decoration ?? const InputDecoration(), googleAPIKey: AppConfig.googleMapsApiKey, debounceTime: 500, - countries: HubsConstants.supportedCountries, + //countries: HubsConstants.supportedCountries, isLatLngRequired: true, getPlaceDetailWithLatLng: (Prediction prediction) { onSelected?.call(prediction); diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart index d3a8e792..eaa83136 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart @@ -1,9 +1,97 @@ +import 'package:flutter/foundation.dart'; import 'package:krow_core/core.dart'; +import 'package:workmanager/workmanager.dart'; + +/// Top-level callback dispatcher for background geofence tasks. +/// +/// Must be a top-level function because workmanager executes it in a separate +/// isolate where the DI container is not available. Core services are +/// instantiated directly since they are simple wrappers. +/// +/// Note: [Workmanager.executeTask] is kept because [BackgroundTaskService] does +/// not expose an equivalent callback-registration API. The `workmanager` import +/// is retained solely for this entry-point pattern. +@pragma('vm:entry-point') +void backgroundGeofenceDispatcher() { + Workmanager().executeTask( + (String task, Map? inputData) async { + debugPrint('[BackgroundGeofence] Task triggered: $task'); + debugPrint('[BackgroundGeofence] Input data: $inputData'); + debugPrint( + '[BackgroundGeofence] Timestamp: ${DateTime.now().toIso8601String()}', + ); + + final double? targetLat = inputData?['targetLat'] as double?; + final double? targetLng = inputData?['targetLng'] as double?; + final String? shiftId = inputData?['shiftId'] as String?; + + debugPrint( + '[BackgroundGeofence] Target: lat=$targetLat, lng=$targetLng, ' + 'shiftId=$shiftId', + ); + + if (targetLat == null || targetLng == null) { + debugPrint( + '[BackgroundGeofence] Missing target coordinates, skipping check', + ); + return true; + } + + try { + const LocationService locationService = LocationService(); + final location = await locationService.getCurrentLocation(); + debugPrint( + '[BackgroundGeofence] Current position: ' + 'lat=${location.latitude}, lng=${location.longitude}', + ); + + final double distance = calculateDistance( + location.latitude, + location.longitude, + targetLat, + targetLng, + ); + debugPrint( + '[BackgroundGeofence] Distance from target: ${distance.round()}m', + ); + + if (distance > BackgroundGeofenceService.geofenceRadiusMeters) { + debugPrint( + '[BackgroundGeofence] Worker is outside geofence ' + '(${distance.round()}m > ' + '${BackgroundGeofenceService.geofenceRadiusMeters.round()}m), ' + 'showing notification', + ); + + final NotificationService notificationService = + NotificationService(); + await notificationService.showNotification( + id: BackgroundGeofenceService.leftGeofenceNotificationId, + title: "You've Left the Workplace", + body: + 'You appear to be more than 500m from your shift location.', + ); + } else { + debugPrint( + '[BackgroundGeofence] Worker is within geofence ' + '(${distance.round()}m <= ' + '${BackgroundGeofenceService.geofenceRadiusMeters.round()}m)', + ); + } + } catch (e) { + debugPrint('[BackgroundGeofence] Error during background check: $e'); + } + + debugPrint('[BackgroundGeofence] Background check completed'); + return true; + }, + ); +} /// Service that manages periodic background geofence checks while clocked in. /// -/// Uses core services exclusively -- no direct imports of workmanager, -/// flutter_local_notifications, or shared_preferences. +/// Uses core services for foreground operations. The background isolate logic +/// lives in the top-level [backgroundGeofenceDispatcher] function above. class BackgroundGeofenceService { /// The core background task service for scheduling periodic work. final BackgroundTaskService _backgroundTaskService; @@ -36,7 +124,13 @@ class BackgroundGeofenceService { static const _clockInNotificationId = 1; /// Notification ID for left-geofence warnings. - static const _leftGeofenceNotificationId = 2; + static const int leftGeofenceNotificationId = 2; + + /// Geofence radius in meters. + static const double geofenceRadiusMeters = 500; + + /// Notification ID for clock-out notifications. + static const _clockOutNotificationId = 3; /// Creates a [BackgroundGeofenceService] instance. BackgroundGeofenceService({ @@ -66,7 +160,7 @@ class BackgroundGeofenceService { await _backgroundTaskService.registerPeriodicTask( uniqueName: taskUniqueName, taskName: taskName, - frequency: const Duration(minutes: 15), + frequency: const Duration(seconds: 10), inputData: { 'targetLat': targetLat, 'targetLng': targetLng, @@ -103,7 +197,7 @@ class BackgroundGeofenceService { await _notificationService.showNotification( title: title, body: body, - id: _leftGeofenceNotificationId, + id: leftGeofenceNotificationId, ); } @@ -118,4 +212,16 @@ class BackgroundGeofenceService { id: _clockInNotificationId, ); } + + /// Shows a notification upon successful clock-out. + Future showClockOutNotification({ + required String title, + required String body, + }) async { + await _notificationService.showNotification( + title: title, + body: body, + id: _clockOutNotificationId, + ); + } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_bloc.dart index ad5154a3..afb48987 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_bloc.dart @@ -233,6 +233,13 @@ class GeofenceBloc extends Bloc emit: emit.call, action: () async { await _backgroundGeofenceService.stopBackgroundTracking(); + + // Show clock-out notification using localized strings from the UI. + await _backgroundGeofenceService.showClockOutNotification( + title: event.clockOutTitle, + body: event.clockOutBody, + ); + emit(state.copyWith(isBackgroundTrackingActive: false)); }, onError: (String errorKey) => state.copyWith( diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_event.dart index e88b8463..65454979 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_event.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_event.dart @@ -95,8 +95,20 @@ class BackgroundTrackingStarted extends GeofenceEvent { /// Stops background tracking after clock-out. class BackgroundTrackingStopped extends GeofenceEvent { + /// Localized clock-out notification title passed from the UI layer. + final String clockOutTitle; + + /// Localized clock-out notification body passed from the UI layer. + final String clockOutBody; + /// Creates a [BackgroundTrackingStopped] event. - const BackgroundTrackingStopped(); + const BackgroundTrackingStopped({ + required this.clockOutTitle, + required this.clockOutBody, + }); + + @override + List get props => [clockOutTitle, clockOutBody]; } /// Worker approved geofence override by providing justification notes. 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 2a03d44c..c23ae13c 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 @@ -58,11 +58,13 @@ class ClockInActionSection extends StatelessWidget { listeners: >[ // Start background tracking after successful check-in. BlocListener( - listenWhen: (ClockInState previous, ClockInState current) => - previous.status == ClockInStatus.actionInProgress && - current.status == ClockInStatus.success && - current.attendance.isCheckedIn && - !previous.attendance.isCheckedIn, + listenWhen: (ClockInState previous, ClockInState current) { + return previous.status == ClockInStatus.actionInProgress && + current.status == ClockInStatus.success && + current.attendance.isCheckedIn && + !previous.attendance.isCheckedIn; + }, + listener: (BuildContext context, ClockInState state) { _startBackgroundTracking(context, state); }, @@ -73,9 +75,14 @@ class ClockInActionSection extends StatelessWidget { previous.attendance.isCheckedIn && !current.attendance.isCheckedIn, listener: (BuildContext context, ClockInState _) { - ReadContext( - context, - ).read().add(const BackgroundTrackingStopped()); + final TranslationsStaffClockInGeofenceEn geofenceI18n = + Translations.of(context).staff.clock_in.geofence; + ReadContext(context).read().add( + BackgroundTrackingStopped( + clockOutTitle: geofenceI18n.clock_out_title, + clockOutBody: geofenceI18n.clock_out_body, + ), + ); }, ), ], @@ -98,21 +105,21 @@ class ClockInActionSection extends StatelessWidget { /// Builds the action widget for an active (not completed) shift. Widget _buildActiveShiftAction(BuildContext context) { - if (!isCheckedIn && !ClockInHelpers.isCheckInAllowed(selectedShift!)) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - const GeofenceStatusBanner(), - const SizedBox(height: UiConstants.space3), - EarlyCheckInBanner( - availabilityTime: ClockInHelpers.getCheckInAvailabilityTime( - selectedShift!, - context, - ), - ), - ], - ); - } + // if (!isCheckedIn && !ClockInHelpers.isCheckInAllowed(selectedShift!)) { + // return Column( + // mainAxisSize: MainAxisSize.min, + // children: [ + // const GeofenceStatusBanner(), + // const SizedBox(height: UiConstants.space3), + // EarlyCheckInBanner( + // availabilityTime: ClockInHelpers.getCheckInAvailabilityTime( + // selectedShift!, + // context, + // ), + // ), + // ], + // ); + // } return BlocBuilder( builder: (BuildContext context, GeofenceState geofenceState) { diff --git a/apps/mobile/packages/features/staff/clock_in/lib/staff_clock_in.dart b/apps/mobile/packages/features/staff/clock_in/lib/staff_clock_in.dart index 60e7610d..016f1414 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/staff_clock_in.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/staff_clock_in.dart @@ -1,4 +1,6 @@ library; +export 'src/data/services/background_geofence_service.dart' + show backgroundGeofenceDispatcher; export 'src/staff_clock_in_module.dart'; export 'src/presentation/pages/clock_in_page.dart'; diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart index ccfeae3b..4ad8cba7 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart @@ -43,13 +43,6 @@ class ShiftDetailsBottomBar extends StatelessWidget { decoration: BoxDecoration( color: UiColors.white, border: Border(top: BorderSide(color: UiColors.border)), - boxShadow: [ - BoxShadow( - color: UiColors.popupShadow.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, -4), - ), - ], ), child: _buildButtons(status, i18n, context), ); From 2889098f9f81560ce3054cdb56c323998d649171 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 14 Mar 2026 00:45:18 -0400 Subject: [PATCH 14/33] refactor: Replace debugPrint with print statements in background geofence dispatcher for logging --- .../services/background_geofence_service.dart | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart index eaa83136..63a7a408 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart @@ -1,4 +1,4 @@ -import 'package:flutter/foundation.dart'; +// ignore_for_file: avoid_print import 'package:krow_core/core.dart'; import 'package:workmanager/workmanager.dart'; @@ -15,9 +15,9 @@ import 'package:workmanager/workmanager.dart'; void backgroundGeofenceDispatcher() { Workmanager().executeTask( (String task, Map? inputData) async { - debugPrint('[BackgroundGeofence] Task triggered: $task'); - debugPrint('[BackgroundGeofence] Input data: $inputData'); - debugPrint( + print('[BackgroundGeofence] Task triggered: $task'); + print('[BackgroundGeofence] Input data: $inputData'); + print( '[BackgroundGeofence] Timestamp: ${DateTime.now().toIso8601String()}', ); @@ -25,13 +25,13 @@ void backgroundGeofenceDispatcher() { final double? targetLng = inputData?['targetLng'] as double?; final String? shiftId = inputData?['shiftId'] as String?; - debugPrint( + print( '[BackgroundGeofence] Target: lat=$targetLat, lng=$targetLng, ' 'shiftId=$shiftId', ); if (targetLat == null || targetLng == null) { - debugPrint( + print( '[BackgroundGeofence] Missing target coordinates, skipping check', ); return true; @@ -40,7 +40,7 @@ void backgroundGeofenceDispatcher() { try { const LocationService locationService = LocationService(); final location = await locationService.getCurrentLocation(); - debugPrint( + print( '[BackgroundGeofence] Current position: ' 'lat=${location.latitude}, lng=${location.longitude}', ); @@ -51,12 +51,12 @@ void backgroundGeofenceDispatcher() { targetLat, targetLng, ); - debugPrint( + print( '[BackgroundGeofence] Distance from target: ${distance.round()}m', ); if (distance > BackgroundGeofenceService.geofenceRadiusMeters) { - debugPrint( + print( '[BackgroundGeofence] Worker is outside geofence ' '(${distance.round()}m > ' '${BackgroundGeofenceService.geofenceRadiusMeters.round()}m), ' @@ -72,17 +72,17 @@ void backgroundGeofenceDispatcher() { 'You appear to be more than 500m from your shift location.', ); } else { - debugPrint( + print( '[BackgroundGeofence] Worker is within geofence ' '(${distance.round()}m <= ' '${BackgroundGeofenceService.geofenceRadiusMeters.round()}m)', ); } } catch (e) { - debugPrint('[BackgroundGeofence] Error during background check: $e'); + print('[BackgroundGeofence] Error during background check: $e'); } - debugPrint('[BackgroundGeofence] Background check completed'); + print('[BackgroundGeofence] Background check completed'); return true; }, ); From 08a4326da4d6d11d2c11d50c57a89ecafc9f3cb1 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 14 Mar 2026 00:59:39 -0400 Subject: [PATCH 15/33] feat: Add task execution callback registration to BackgroundTaskService and update geofence dispatcher to use it --- .../background_task_service.dart | 12 +++++ .../services/background_geofence_service.dart | 48 +++++++++---------- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/apps/mobile/packages/core/lib/src/services/device/background_task/background_task_service.dart b/apps/mobile/packages/core/lib/src/services/device/background_task/background_task_service.dart index 0e753716..d47602f5 100644 --- a/apps/mobile/packages/core/lib/src/services/device/background_task/background_task_service.dart +++ b/apps/mobile/packages/core/lib/src/services/device/background_task/background_task_service.dart @@ -55,4 +55,16 @@ class BackgroundTaskService extends BaseDeviceService { Future cancelAll() async { return action(() => Workmanager().cancelAll()); } + + /// Registers the task execution callback for the background isolate. + /// + /// Must be called inside the top-level callback dispatcher function. + /// The [callback] receives the task name and optional input data, and + /// must return `true` on success or `false` on failure. + void executeTask( + Future Function(String task, Map? inputData) + callback, + ) { + Workmanager().executeTask(callback); + } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart index 63a7a408..ec2d9fe2 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart @@ -1,6 +1,6 @@ // ignore_for_file: avoid_print import 'package:krow_core/core.dart'; -import 'package:workmanager/workmanager.dart'; +import 'package:krow_domain/src/core/models/device_location.dart'; /// Top-level callback dispatcher for background geofence tasks. /// @@ -13,7 +13,7 @@ import 'package:workmanager/workmanager.dart'; /// is retained solely for this entry-point pattern. @pragma('vm:entry-point') void backgroundGeofenceDispatcher() { - Workmanager().executeTask( + const BackgroundTaskService().executeTask( (String task, Map? inputData) async { print('[BackgroundGeofence] Task triggered: $task'); print('[BackgroundGeofence] Input data: $inputData'); @@ -39,7 +39,7 @@ void backgroundGeofenceDispatcher() { try { const LocationService locationService = LocationService(); - final location = await locationService.getCurrentLocation(); + final DeviceLocation location = await locationService.getCurrentLocation(); print( '[BackgroundGeofence] Current position: ' 'lat=${location.latitude}, lng=${location.longitude}', @@ -93,6 +93,15 @@ void backgroundGeofenceDispatcher() { /// Uses core services for foreground operations. The background isolate logic /// lives in the top-level [backgroundGeofenceDispatcher] function above. class BackgroundGeofenceService { + + /// Creates a [BackgroundGeofenceService] instance. + BackgroundGeofenceService({ + required BackgroundTaskService backgroundTaskService, + required NotificationService notificationService, + required StorageService storageService, + }) : _backgroundTaskService = backgroundTaskService, + _notificationService = notificationService, + _storageService = storageService; /// The core background task service for scheduling periodic work. final BackgroundTaskService _backgroundTaskService; @@ -103,25 +112,25 @@ class BackgroundGeofenceService { final StorageService _storageService; /// Storage key for the target latitude. - static const _keyTargetLat = 'geofence_target_lat'; + static const String _keyTargetLat = 'geofence_target_lat'; /// Storage key for the target longitude. - static const _keyTargetLng = 'geofence_target_lng'; + static const String _keyTargetLng = 'geofence_target_lng'; /// Storage key for the shift identifier. - static const _keyShiftId = 'geofence_shift_id'; + static const String _keyShiftId = 'geofence_shift_id'; /// Storage key for the active tracking flag. - static const _keyTrackingActive = 'geofence_tracking_active'; + static const String _keyTrackingActive = 'geofence_tracking_active'; /// Unique task name for the periodic background check. - static const taskUniqueName = 'geofence_background_check'; + static const String taskUniqueName = 'geofence_background_check'; /// Task name identifier for the workmanager callback. - static const taskName = 'geofenceCheck'; + static const String taskName = 'geofenceCheck'; /// Notification ID for clock-in greeting notifications. - static const _clockInNotificationId = 1; + static const int _clockInNotificationId = 1; /// Notification ID for left-geofence warnings. static const int leftGeofenceNotificationId = 2; @@ -130,16 +139,7 @@ class BackgroundGeofenceService { static const double geofenceRadiusMeters = 500; /// Notification ID for clock-out notifications. - static const _clockOutNotificationId = 3; - - /// Creates a [BackgroundGeofenceService] instance. - BackgroundGeofenceService({ - required BackgroundTaskService backgroundTaskService, - required NotificationService notificationService, - required StorageService storageService, - }) : _backgroundTaskService = backgroundTaskService, - _notificationService = notificationService, - _storageService = storageService; + static const int _clockOutNotificationId = 3; /// Starts periodic 15-minute background geofence checks. /// @@ -150,7 +150,7 @@ class BackgroundGeofenceService { required double targetLng, required String shiftId, }) async { - await Future.wait([ + await Future.wait(>[ _storageService.setDouble(_keyTargetLat, targetLat), _storageService.setDouble(_keyTargetLng, targetLng), _storageService.setString(_keyShiftId, shiftId), @@ -161,7 +161,7 @@ class BackgroundGeofenceService { uniqueName: taskUniqueName, taskName: taskName, frequency: const Duration(seconds: 10), - inputData: { + inputData: { 'targetLat': targetLat, 'targetLng': targetLng, 'shiftId': shiftId, @@ -175,7 +175,7 @@ class BackgroundGeofenceService { Future stopBackgroundTracking() async { await _backgroundTaskService.cancelByUniqueName(taskUniqueName); - await Future.wait([ + await Future.wait(>[ _storageService.remove(_keyTargetLat), _storageService.remove(_keyTargetLng), _storageService.remove(_keyShiftId), @@ -185,7 +185,7 @@ class BackgroundGeofenceService { /// Whether background tracking is currently active. Future get isTrackingActive async { - final active = await _storageService.getBool(_keyTrackingActive); + final bool? active = await _storageService.getBool(_keyTrackingActive); return active ?? false; } From 3478436227d423baff33469b50052cd91ce3015c Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 14 Mar 2026 01:23:27 -0400 Subject: [PATCH 16/33] refactor(clock_in): reorganize geofence and clock-in BLoC structure - Deleted old geofence_event.dart and geofence_state.dart files. - Created new geofence_event.dart and geofence_state.dart files under a dedicated geofence directory. - Moved clock_in_bloc.dart, clock_in_event.dart, and clock_in_state.dart to a new clock_in directory for better organization. - Updated import paths throughout the codebase to reflect the new structure. - Implemented new ClockInBloc and GeofenceBloc classes to handle clock-in and geofence functionalities respectively. - Enhanced event and state management for clock-in operations, including handling location verification and geofence checks. --- .../bloc/{ => clock_in}/clock_in_bloc.dart | 12 ++++++------ .../bloc/{ => clock_in}/clock_in_event.dart | 0 .../bloc/{ => clock_in}/clock_in_state.dart | 0 .../bloc/{ => geofence}/geofence_bloc.dart | 6 +++--- .../bloc/{ => geofence}/geofence_event.dart | 2 +- .../bloc/{ => geofence}/geofence_state.dart | 0 .../lib/src/presentation/pages/clock_in_page.dart | 6 +++--- .../src/presentation/widgets/check_in_mode_tab.dart | 4 ++-- .../widgets/clock_in_action_section.dart | 12 ++++++------ .../lib/src/presentation/widgets/clock_in_body.dart | 10 +++++----- .../geofence_override_modal.dart | 4 ++-- .../geofence_status_banner.dart | 4 ++-- .../permission_denied_banner.dart | 6 +++--- .../geofence_status_banner/timeout_banner.dart | 4 ++-- .../clock_in/lib/src/staff_clock_in_module.dart | 4 ++-- 15 files changed, 37 insertions(+), 37 deletions(-) rename apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/{ => clock_in}/clock_in_bloc.dart (94%) rename apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/{ => clock_in}/clock_in_event.dart (100%) rename apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/{ => clock_in}/clock_in_state.dart (100%) rename apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/{ => geofence}/geofence_bloc.dart (97%) rename apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/{ => geofence}/geofence_event.dart (98%) rename apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/{ => geofence}/geofence_state.dart (100%) diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart similarity index 94% rename from apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart rename to apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart index c30703f5..f3bf5a7f 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart @@ -2,12 +2,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/arguments/clock_in_arguments.dart'; -import '../../domain/arguments/clock_out_arguments.dart'; -import '../../domain/usecases/clock_in_usecase.dart'; -import '../../domain/usecases/clock_out_usecase.dart'; -import '../../domain/usecases/get_attendance_status_usecase.dart'; -import '../../domain/usecases/get_todays_shift_usecase.dart'; +import '../../../domain/arguments/clock_in_arguments.dart'; +import '../../../domain/arguments/clock_out_arguments.dart'; +import '../../../domain/usecases/clock_in_usecase.dart'; +import '../../../domain/usecases/clock_out_usecase.dart'; +import '../../../domain/usecases/get_attendance_status_usecase.dart'; +import '../../../domain/usecases/get_todays_shift_usecase.dart'; import 'clock_in_event.dart'; import 'clock_in_state.dart'; diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart similarity index 100% rename from apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_event.dart rename to apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_state.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_state.dart similarity index 100% rename from apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in_state.dart rename to apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_state.dart diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart similarity index 97% rename from apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_bloc.dart rename to apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart index afb48987..1472d3a2 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart @@ -4,9 +4,9 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../data/services/background_geofence_service.dart'; -import '../../domain/models/geofence_result.dart'; -import '../../domain/services/geofence_service_interface.dart'; +import '../../../data/services/background_geofence_service.dart'; +import '../../../domain/models/geofence_result.dart'; +import '../../../domain/services/geofence_service_interface.dart'; import 'geofence_event.dart'; import 'geofence_state.dart'; diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart similarity index 98% rename from apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_event.dart rename to apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart index 65454979..1b1c219b 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_event.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart @@ -1,6 +1,6 @@ import 'package:equatable/equatable.dart'; -import '../../domain/models/geofence_result.dart'; +import '../../../domain/models/geofence_result.dart'; /// Base event for the [GeofenceBloc]. abstract class GeofenceEvent extends Equatable { diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_state.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_state.dart similarity index 100% rename from apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence_state.dart rename to apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_state.dart diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart index f3b36fe2..7b07af80 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart @@ -4,9 +4,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import '../bloc/clock_in_bloc.dart'; -import '../bloc/clock_in_state.dart'; -import '../bloc/geofence_bloc.dart'; +import '../bloc/clock_in/clock_in_bloc.dart'; +import '../bloc/clock_in/clock_in_state.dart'; +import '../bloc/geofence/geofence_bloc.dart'; import '../widgets/clock_in_body.dart'; import '../widgets/clock_in_page_skeleton/clock_in_page_skeleton.dart'; diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/check_in_mode_tab.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/check_in_mode_tab.dart index 44f237d4..5eedf057 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/check_in_mode_tab.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/check_in_mode_tab.dart @@ -2,8 +2,8 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../bloc/clock_in_bloc.dart'; -import '../bloc/clock_in_event.dart'; +import '../bloc/clock_in/clock_in_bloc.dart'; +import '../bloc/clock_in/clock_in_event.dart'; /// A single selectable tab within a check-in mode toggle strip. /// 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 c23ae13c..54173ba9 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 @@ -6,12 +6,12 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../bloc/clock_in_bloc.dart'; -import '../bloc/clock_in_event.dart'; -import '../bloc/clock_in_state.dart'; -import '../bloc/geofence_bloc.dart'; -import '../bloc/geofence_event.dart'; -import '../bloc/geofence_state.dart'; +import '../bloc/clock_in/clock_in_bloc.dart'; +import '../bloc/clock_in/clock_in_event.dart'; +import '../bloc/clock_in/clock_in_state.dart'; +import '../bloc/geofence/geofence_bloc.dart'; +import '../bloc/geofence/geofence_event.dart'; +import '../bloc/geofence/geofence_state.dart'; import 'clock_in_helpers.dart'; import 'early_check_in_banner.dart'; import 'geofence_status_banner/geofence_status_banner.dart'; diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart index 54611a96..8b0032f4 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart @@ -5,11 +5,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../bloc/clock_in_bloc.dart'; -import '../bloc/clock_in_event.dart'; -import '../bloc/clock_in_state.dart'; -import '../bloc/geofence_bloc.dart'; -import '../bloc/geofence_event.dart'; +import '../bloc/clock_in/clock_in_bloc.dart'; +import '../bloc/clock_in/clock_in_event.dart'; +import '../bloc/clock_in/clock_in_state.dart'; +import '../bloc/geofence/geofence_bloc.dart'; +import '../bloc/geofence/geofence_event.dart'; import 'checked_in_banner.dart'; import 'clock_in_action_section.dart'; import 'date_selector.dart'; diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart index ea7a2d1c..56072000 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart @@ -5,8 +5,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import '../../bloc/geofence_bloc.dart'; -import '../../bloc/geofence_event.dart'; +import '../../bloc/geofence/geofence_bloc.dart'; +import '../../bloc/geofence/geofence_event.dart'; /// Modal bottom sheet that collects a justification note before allowing /// a geofence-overridden clock-in. 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 index 3d7ec47a..115bb840 100644 --- 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 @@ -2,8 +2,8 @@ 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 '../../bloc/geofence/geofence_bloc.dart'; +import '../../bloc/geofence/geofence_state.dart'; import 'permission_denied_banner.dart'; import 'permission_denied_forever_banner.dart'; import 'service_disabled_banner.dart'; 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 index 87333c44..898031f7 100644 --- 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 @@ -3,9 +3,9 @@ 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 '../../bloc/geofence/geofence_bloc.dart'; +import '../../bloc/geofence/geofence_event.dart'; +import '../../bloc/geofence/geofence_state.dart'; import 'banner_action_button.dart'; import 'banner_actions_row.dart'; import 'geofence_override_modal.dart'; 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 index 0977f8fb..7f7edaab 100644 --- 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 @@ -3,8 +3,8 @@ 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/geofence_bloc.dart'; +import '../../bloc/geofence/geofence_event.dart'; import 'banner_action_button.dart'; import 'banner_actions_row.dart'; import 'geofence_override_modal.dart'; diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart index be26fb37..d509a2b7 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart @@ -11,8 +11,8 @@ import 'domain/usecases/clock_in_usecase.dart'; import 'domain/usecases/clock_out_usecase.dart'; import 'domain/usecases/get_attendance_status_usecase.dart'; import 'domain/usecases/get_todays_shift_usecase.dart'; -import 'presentation/bloc/clock_in_bloc.dart'; -import 'presentation/bloc/geofence_bloc.dart'; +import 'presentation/bloc/clock_in/clock_in_bloc.dart'; +import 'presentation/bloc/geofence/geofence_bloc.dart'; import 'presentation/pages/clock_in_page.dart'; /// Module for the staff clock-in feature. From e3f7e1ac3e2d5c25c716bf3b8260864fb1ce2f20 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 14 Mar 2026 14:30:28 -0400 Subject: [PATCH 17/33] fix(clock_in): restore early check-in banner logic for active shifts --- .../widgets/clock_in_action_section.dart | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) 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 54173ba9..292afac0 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 @@ -105,21 +105,21 @@ class ClockInActionSection extends StatelessWidget { /// Builds the action widget for an active (not completed) shift. Widget _buildActiveShiftAction(BuildContext context) { - // if (!isCheckedIn && !ClockInHelpers.isCheckInAllowed(selectedShift!)) { - // return Column( - // mainAxisSize: MainAxisSize.min, - // children: [ - // const GeofenceStatusBanner(), - // const SizedBox(height: UiConstants.space3), - // EarlyCheckInBanner( - // availabilityTime: ClockInHelpers.getCheckInAvailabilityTime( - // selectedShift!, - // context, - // ), - // ), - // ], - // ); - // } + if (!isCheckedIn && !ClockInHelpers.isCheckInAllowed(selectedShift!)) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const GeofenceStatusBanner(), + const SizedBox(height: UiConstants.space3), + EarlyCheckInBanner( + availabilityTime: ClockInHelpers.getCheckInAvailabilityTime( + selectedShift!, + context, + ), + ), + ], + ); + } return BlocBuilder( builder: (BuildContext context, GeofenceState geofenceState) { From 28a219bbea90c79c8c527fa704a8a7e2291eea08 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 14 Mar 2026 19:58:43 -0400 Subject: [PATCH 18/33] Refactor clock-in feature: Introduce validation pipeline and interaction strategies - Added a validation pipeline for clock-in actions, including geofence, time window, and override notes validators. - Created a context class for passing validation data and results. - Implemented check-in interaction strategies for swipe and NFC methods, encapsulating their UI and behavior. - Removed redundant utility functions and centralized time formatting in a new utility file. - Enhanced the notification service to handle clock-in and clock-out notifications separately. - Updated the ClockInBloc to utilize the new validation and notification services. - Cleaned up the ClockInActionSection widget to use the new interaction strategies and removed unnecessary listeners. --- .claude/agents/mobile-builder.md | 17 +- apps/mobile/packages/core/lib/core.dart | 1 + .../core/lib/src/utils/time_utils.dart | 30 +++ apps/mobile/packages/core/pubspec.yaml | 1 + .../services/background_geofence_service.dart | 56 +----- .../clock_in_notification_service.dart | 61 ++++++ .../data/services/geofence_service_impl.dart | 10 +- .../src/domain/models/geofence_result.dart | 18 +- .../clock_in_validation_context.dart | 55 ++++++ .../clock_in_validation_result.dart | 24 +++ .../domain/validators/clock_in_validator.dart | 11 ++ .../composite_clock_in_validator.dart | 27 +++ .../domain/validators/geofence_validator.dart | 35 ++++ .../validators/override_notes_validator.dart | 35 ++++ .../validators/time_window_validator.dart | 77 ++++++++ .../bloc/clock_in/clock_in_bloc.dart | 118 +++++++++--- .../bloc/clock_in/clock_in_event.dart | 48 +++-- .../bloc/geofence/geofence_bloc.dart | 22 ++- .../bloc/geofence/geofence_event.dart | 54 +++--- .../bloc/geofence/geofence_state.dart | 8 +- .../src/presentation/pages/clock_in_page.dart | 8 +- .../strategies/check_in_interaction.dart | 22 +++ .../strategies/nfc_check_in_interaction.dart | 118 ++++++++++++ .../swipe_check_in_interaction.dart | 30 +++ .../widgets/clock_in_action_section.dart | 178 +++++++----------- .../widgets/clock_in_helpers.dart | 78 -------- .../src/presentation/widgets/shift_card.dart | 4 +- .../widgets/swipe_to_check_in.dart | 69 ++----- .../lib/src/staff_clock_in_module.dart | 43 ++++- 29 files changed, 864 insertions(+), 394 deletions(-) create mode 100644 apps/mobile/packages/core/lib/src/utils/time_utils.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/data/services/clock_in_notification_service.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validation_context.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validation_result.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validator.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/composite_clock_in_validator.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/geofence_validator.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/override_notes_validator.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/time_window_validator.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/check_in_interaction.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/nfc_check_in_interaction.dart create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/swipe_check_in_interaction.dart delete mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_helpers.dart diff --git a/.claude/agents/mobile-builder.md b/.claude/agents/mobile-builder.md index 7ada36c8..cd55655b 100644 --- a/.claude/agents/mobile-builder.md +++ b/.claude/agents/mobile-builder.md @@ -56,6 +56,15 @@ If any of these files are missing or unreadable, notify the user before proceedi - Use `UiColors`, `UiTypography`, `UiIcons`, `UiConstants` for all design values - Use `core_localization` for user-facing strings - Add concise `///` doc comments to every class, method, and field. Keep them short (1-2 lines) — just enough for another developer to understand the purpose without reading the implementation. +- **Always specify explicit types** on every local variable, loop variable, and lambda parameter — never use `final x = ...` or `var x = ...` without the type. Example: `final String name = getName();` not `final name = getName();`. This is enforced by the `always_specify_types` lint rule. +- **Always place constructors before fields and methods** in class declarations. The correct order is: constructor → fields → methods. This is enforced by the `sort_constructors_first` lint rule. Example: + ```dart + class MyClass { + const MyClass({required this.name}); + final String name; + void doSomething() {} + } + ``` ## Standard Workflow @@ -121,13 +130,19 @@ features/ entities/ # Pure Dart classes repositories/ # Abstract interfaces usecases/ # Business logic lives HERE + validators/ # Composable validation pipeline (optional) domain.dart # Barrel file data/ models/ # With fromJson/toJson repositories/ # Concrete implementations data.dart # Barrel file presentation/ - bloc/ # Events, states, BLoC + bloc/ + feature_bloc/ # Each BLoC in its own subfolder + feature_bloc.dart + feature_event.dart + feature_state.dart + strategies/ # Strategy pattern implementations (optional) screens/ # Full pages widgets/ # Reusable components presentation.dart # Barrel file diff --git a/apps/mobile/packages/core/lib/core.dart b/apps/mobile/packages/core/lib/core.dart index 5e29efb5..600ff74f 100644 --- a/apps/mobile/packages/core/lib/core.dart +++ b/apps/mobile/packages/core/lib/core.dart @@ -6,6 +6,7 @@ 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/utils/time_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/time_utils.dart b/apps/mobile/packages/core/lib/src/utils/time_utils.dart new file mode 100644 index 00000000..7340753c --- /dev/null +++ b/apps/mobile/packages/core/lib/src/utils/time_utils.dart @@ -0,0 +1,30 @@ +import 'package:intl/intl.dart'; + +/// Formats a time string (ISO 8601 or HH:mm) into 12-hour format +/// (e.g. "9:00 AM"). +/// +/// Returns the original string unchanged if parsing fails. +String formatTime(String timeStr) { + if (timeStr.isEmpty) return ''; + try { + final DateTime dt = DateTime.parse(timeStr); + return DateFormat('h:mm a').format(dt); + } catch (_) { + try { + final List parts = timeStr.split(':'); + if (parts.length >= 2) { + final DateTime dt = DateTime( + 2022, + 1, + 1, + int.parse(parts[0]), + int.parse(parts[1]), + ); + return DateFormat('h:mm a').format(dt); + } + return timeStr; + } catch (_) { + return timeStr; + } + } +} diff --git a/apps/mobile/packages/core/pubspec.yaml b/apps/mobile/packages/core/pubspec.yaml index 347e45af..f40200eb 100644 --- a/apps/mobile/packages/core/pubspec.yaml +++ b/apps/mobile/packages/core/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: design_system: path: ../design_system + intl: ^0.20.0 flutter_bloc: ^8.1.0 equatable: ^2.0.8 flutter_modular: ^6.4.1 diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart index ec2d9fe2..108b12f6 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart @@ -90,24 +90,21 @@ void backgroundGeofenceDispatcher() { /// Service that manages periodic background geofence checks while clocked in. /// -/// Uses core services for foreground operations. The background isolate logic -/// lives in the top-level [backgroundGeofenceDispatcher] function above. +/// Handles scheduling and cancelling background tasks only. Notification +/// delivery is handled by [ClockInNotificationService]. The background isolate +/// logic lives in the top-level [backgroundGeofenceDispatcher] function above. class BackgroundGeofenceService { /// Creates a [BackgroundGeofenceService] instance. BackgroundGeofenceService({ required BackgroundTaskService backgroundTaskService, - required NotificationService notificationService, required StorageService storageService, }) : _backgroundTaskService = backgroundTaskService, - _notificationService = notificationService, _storageService = storageService; + /// The core background task service for scheduling periodic work. final BackgroundTaskService _backgroundTaskService; - /// The core notification service for displaying local notifications. - final NotificationService _notificationService; - /// The core storage service for persisting geofence target data. final StorageService _storageService; @@ -129,18 +126,15 @@ class BackgroundGeofenceService { /// Task name identifier for the workmanager callback. static const String taskName = 'geofenceCheck'; - /// Notification ID for clock-in greeting notifications. - static const int _clockInNotificationId = 1; - /// Notification ID for left-geofence warnings. + /// + /// Kept here because the top-level [backgroundGeofenceDispatcher] references + /// it directly (background isolate has no DI access). static const int leftGeofenceNotificationId = 2; /// Geofence radius in meters. static const double geofenceRadiusMeters = 500; - /// Notification ID for clock-out notifications. - static const int _clockOutNotificationId = 3; - /// Starts periodic 15-minute background geofence checks. /// /// Called after a successful clock-in. Persists the target coordinates @@ -188,40 +182,4 @@ class BackgroundGeofenceService { final bool? active = await _storageService.getBool(_keyTrackingActive); return active ?? false; } - - /// Shows a notification that the worker has left the geofence. - Future showLeftGeofenceNotification({ - required String title, - required String body, - }) async { - await _notificationService.showNotification( - title: title, - body: body, - id: leftGeofenceNotificationId, - ); - } - - /// Shows a greeting notification upon successful clock-in. - Future showClockInGreetingNotification({ - required String title, - required String body, - }) async { - await _notificationService.showNotification( - title: title, - body: body, - id: _clockInNotificationId, - ); - } - - /// Shows a notification upon successful clock-out. - Future showClockOutNotification({ - required String title, - required String body, - }) async { - await _notificationService.showNotification( - title: title, - body: body, - id: _clockOutNotificationId, - ); - } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/clock_in_notification_service.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/clock_in_notification_service.dart new file mode 100644 index 00000000..17b5f0a6 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/clock_in_notification_service.dart @@ -0,0 +1,61 @@ +import 'package:krow_core/core.dart'; + +/// Service responsible for displaying clock-in related local notifications. +/// +/// Encapsulates notification logic extracted from [BackgroundGeofenceService] +/// so that geofence tracking and user-facing notifications have separate +/// responsibilities. +class ClockInNotificationService { + /// Creates a [ClockInNotificationService] instance. + const ClockInNotificationService({ + required NotificationService notificationService, + }) : _notificationService = notificationService; + + /// The underlying core notification service. + final NotificationService _notificationService; + + /// Notification ID for clock-in greeting notifications. + static const int _clockInNotificationId = 1; + + /// Notification ID for left-geofence warnings. + static const int leftGeofenceNotificationId = 2; + + /// Notification ID for clock-out notifications. + static const int _clockOutNotificationId = 3; + + /// Shows a greeting notification after successful clock-in. + Future showClockInGreeting({ + required String title, + required String body, + }) async { + await _notificationService.showNotification( + title: title, + body: body, + id: _clockInNotificationId, + ); + } + + /// Shows a notification when the worker clocks out. + Future showClockOutNotification({ + required String title, + required String body, + }) async { + await _notificationService.showNotification( + title: title, + body: body, + id: _clockOutNotificationId, + ); + } + + /// Shows a notification when the worker leaves the geofence. + Future showLeftGeofenceNotification({ + required String title, + required String body, + }) async { + await _notificationService.showNotification( + title: title, + body: body, + id: leftGeofenceNotificationId, + ); + } +} 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 4a76b07b..cc4d00d6 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 @@ -40,7 +40,7 @@ class GeofenceServiceImpl implements GeofenceServiceInterface { double radiusMeters = 500, }) { return _locationService.watchLocation(distanceFilter: 10).map( - (location) => _buildResult( + (DeviceLocation location) => _buildResult( location: location, targetLat: targetLat, targetLng: targetLng, @@ -57,7 +57,7 @@ class GeofenceServiceImpl implements GeofenceServiceInterface { Duration timeout = const Duration(seconds: 30), }) async { try { - final location = + final DeviceLocation location = await _locationService.getCurrentLocation().timeout(timeout); return _buildResult( location: location, @@ -92,15 +92,15 @@ class GeofenceServiceImpl implements GeofenceServiceInterface { required double targetLng, required double radiusMeters, }) { - final distance = calculateDistance( + final double distance = calculateDistance( location.latitude, location.longitude, targetLat, targetLng, ); - final isWithin = debugAlwaysInRange || distance <= radiusMeters; - final eta = + final bool isWithin = debugAlwaysInRange || distance <= radiusMeters; + final int eta = isWithin ? 0 : (distance / _walkingSpeedMetersPerMinute).round(); return GeofenceResult( diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/models/geofence_result.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/models/geofence_result.dart index d5185375..95043929 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/models/geofence_result.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/models/geofence_result.dart @@ -3,6 +3,14 @@ import 'package:krow_domain/krow_domain.dart'; /// Result of a geofence proximity check. class GeofenceResult extends Equatable { + /// Creates a [GeofenceResult] instance. + const GeofenceResult({ + required this.distanceMeters, + required this.isWithinRadius, + required this.estimatedEtaMinutes, + required this.location, + }); + /// Distance from the target location in meters. final double distanceMeters; @@ -15,16 +23,8 @@ class GeofenceResult extends Equatable { /// The device location at the time of the check. final DeviceLocation location; - /// Creates a [GeofenceResult] instance. - const GeofenceResult({ - required this.distanceMeters, - required this.isWithinRadius, - required this.estimatedEtaMinutes, - required this.location, - }); - @override - List get props => [ + List get props => [ distanceMeters, isWithinRadius, estimatedEtaMinutes, diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validation_context.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validation_context.dart new file mode 100644 index 00000000..6a071e58 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validation_context.dart @@ -0,0 +1,55 @@ +import 'package:equatable/equatable.dart'; + +/// Immutable input context carrying all data needed for clock-in validation. +/// +/// Constructed by the presentation layer and passed through the validation +/// pipeline so that each validator can inspect the fields it cares about. +class ClockInValidationContext extends Equatable { + /// Creates a [ClockInValidationContext]. + const ClockInValidationContext({ + required this.isCheckingIn, + this.shiftStartTime, + this.shiftEndTime, + this.hasCoordinates = false, + this.isLocationVerified = false, + this.isLocationTimedOut = false, + this.isGeofenceOverridden = false, + this.overrideNotes, + }); + + /// Whether this is a clock-in attempt (`true`) or clock-out (`false`). + final bool isCheckingIn; + + /// The scheduled start time of the shift, if known. + final DateTime? shiftStartTime; + + /// The scheduled end time of the shift, if known. + final DateTime? shiftEndTime; + + /// Whether the shift's venue has latitude/longitude coordinates. + final bool hasCoordinates; + + /// Whether the device location has been verified against the geofence. + final bool isLocationVerified; + + /// Whether the location check timed out before verification completed. + final bool isLocationTimedOut; + + /// Whether the worker explicitly overrode the geofence via justification. + final bool isGeofenceOverridden; + + /// Optional notes provided when overriding or timing out. + final String? overrideNotes; + + @override + List get props => [ + isCheckingIn, + shiftStartTime, + shiftEndTime, + hasCoordinates, + isLocationVerified, + isLocationTimedOut, + isGeofenceOverridden, + overrideNotes, + ]; +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validation_result.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validation_result.dart new file mode 100644 index 00000000..59a03ac2 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validation_result.dart @@ -0,0 +1,24 @@ +import 'package:equatable/equatable.dart'; + +/// The outcome of a single validation step in the clock-in pipeline. +/// +/// Use the named constructors [ClockInValidationResult.valid] and +/// [ClockInValidationResult.invalid] to create instances. +class ClockInValidationResult extends Equatable { + /// Creates a passing validation result. + const ClockInValidationResult.valid() + : isValid = true, + errorKey = null; + + /// Creates a failing validation result with the given [errorKey]. + const ClockInValidationResult.invalid(this.errorKey) : isValid = false; + + /// Whether the validation passed. + final bool isValid; + + /// A localization key describing the validation failure, or `null` if valid. + final String? errorKey; + + @override + List get props => [isValid, errorKey]; +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validator.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validator.dart new file mode 100644 index 00000000..8b818524 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validator.dart @@ -0,0 +1,11 @@ +import 'clock_in_validation_context.dart'; +import 'clock_in_validation_result.dart'; + +/// Abstract interface for a single step in the clock-in validation pipeline. +/// +/// Implementations inspect the [ClockInValidationContext] and return a +/// [ClockInValidationResult] indicating whether the check passed or failed. +abstract class ClockInValidator { + /// Validates the given [context] and returns the result. + ClockInValidationResult validate(ClockInValidationContext context); +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/composite_clock_in_validator.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/composite_clock_in_validator.dart new file mode 100644 index 00000000..3521ee7a --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/composite_clock_in_validator.dart @@ -0,0 +1,27 @@ +import 'clock_in_validation_context.dart'; +import 'clock_in_validation_result.dart'; +import 'clock_in_validator.dart'; + +/// Runs a list of [ClockInValidator]s in order, short-circuiting on first failure. +/// +/// This implements the composite pattern to chain multiple validation rules +/// into a single pipeline. Validators are executed sequentially and the first +/// failing result is returned immediately. +class CompositeClockInValidator implements ClockInValidator { + + /// Creates a [CompositeClockInValidator] with the given [validators]. + const CompositeClockInValidator(this.validators); + /// The ordered list of validators to execute. + final List validators; + + /// Runs each validator in order. Returns the first failing result, + /// or [ClockInValidationResult.valid] if all pass. + @override + ClockInValidationResult validate(ClockInValidationContext context) { + for (final ClockInValidator validator in validators) { + final ClockInValidationResult result = validator.validate(context); + if (!result.isValid) return result; + } + return const ClockInValidationResult.valid(); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/geofence_validator.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/geofence_validator.dart new file mode 100644 index 00000000..1f6c3c80 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/geofence_validator.dart @@ -0,0 +1,35 @@ +import 'clock_in_validation_context.dart'; +import 'clock_in_validation_result.dart'; +import 'clock_in_validator.dart'; + +/// Validates that geofence requirements are satisfied before clock-in. +/// +/// Only applies when checking in to a shift that has venue coordinates. +/// If the shift has no coordinates or this is a clock-out, validation passes. +/// +/// Logic extracted from [ClockInBloc._onCheckIn]: +/// - If the shift requires location verification but the geofence has not +/// confirmed proximity, has not timed out, and the worker has not +/// explicitly overridden via the justification modal, the attempt is rejected. +class GeofenceValidator implements ClockInValidator { + /// Creates a [GeofenceValidator]. + const GeofenceValidator(); + + /// Returns invalid when clocking in to a location-based shift without + /// verified location, timeout, or explicit override. + @override + ClockInValidationResult validate(ClockInValidationContext context) { + // Only applies to clock-in for shifts with coordinates. + if (!context.isCheckingIn || !context.hasCoordinates) { + return const ClockInValidationResult.valid(); + } + + if (!context.isLocationVerified && + !context.isLocationTimedOut && + !context.isGeofenceOverridden) { + return const ClockInValidationResult.invalid('geofence_not_verified'); + } + + return const ClockInValidationResult.valid(); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/override_notes_validator.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/override_notes_validator.dart new file mode 100644 index 00000000..a425e53d --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/override_notes_validator.dart @@ -0,0 +1,35 @@ +import 'clock_in_validation_context.dart'; +import 'clock_in_validation_result.dart'; +import 'clock_in_validator.dart'; + +/// Validates that override notes are provided when required. +/// +/// When the location check timed out or the geofence was explicitly overridden, +/// the worker must supply non-empty notes explaining why they are clocking in +/// without verified proximity. +/// +/// Logic extracted from [ClockInBloc._onCheckIn] notes check. +class OverrideNotesValidator implements ClockInValidator { + /// Creates an [OverrideNotesValidator]. + const OverrideNotesValidator(); + + /// Returns invalid if notes are required but missing or empty. + @override + ClockInValidationResult validate(ClockInValidationContext context) { + // Only applies to clock-in attempts. + if (!context.isCheckingIn) { + return const ClockInValidationResult.valid(); + } + + final bool notesRequired = + context.isLocationTimedOut || context.isGeofenceOverridden; + + if (notesRequired && + (context.overrideNotes == null || + context.overrideNotes!.trim().isEmpty)) { + return const ClockInValidationResult.invalid('notes_required'); + } + + return const ClockInValidationResult.valid(); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/time_window_validator.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/time_window_validator.dart new file mode 100644 index 00000000..4fcac299 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/time_window_validator.dart @@ -0,0 +1,77 @@ +import 'package:intl/intl.dart'; + +import 'clock_in_validation_context.dart'; +import 'clock_in_validation_result.dart'; +import 'clock_in_validator.dart'; + +/// Validates that the current time falls within the allowed window. +/// +/// - For clock-in: the current time must be at most 15 minutes before the +/// shift start time. +/// - For clock-out: the current time must be at most 15 minutes before the +/// shift end time. +/// - If the relevant shift time is `null`, validation passes (don't block +/// when the time is unknown). +class TimeWindowValidator implements ClockInValidator { + /// Creates a [TimeWindowValidator]. + const TimeWindowValidator(); + + /// The number of minutes before the shift time that the action is allowed. + static const int _earlyWindowMinutes = 15; + + /// Returns invalid if the current time is too early for the action. + @override + ClockInValidationResult validate(ClockInValidationContext context) { + if (context.isCheckingIn) { + return _validateClockIn(context); + } + return _validateClockOut(context); + } + + /// Validates the clock-in time window against [shiftStartTime]. + ClockInValidationResult _validateClockIn(ClockInValidationContext context) { + final DateTime? shiftStart = context.shiftStartTime; + if (shiftStart == null) { + return const ClockInValidationResult.valid(); + } + + final DateTime windowStart = shiftStart.subtract( + const Duration(minutes: _earlyWindowMinutes), + ); + + if (DateTime.now().isBefore(windowStart)) { + return const ClockInValidationResult.invalid('too_early_clock_in'); + } + + return const ClockInValidationResult.valid(); + } + + /// Validates the clock-out time window against [shiftEndTime]. + ClockInValidationResult _validateClockOut(ClockInValidationContext context) { + final DateTime? shiftEnd = context.shiftEndTime; + if (shiftEnd == null) { + return const ClockInValidationResult.valid(); + } + + final DateTime windowStart = shiftEnd.subtract( + const Duration(minutes: _earlyWindowMinutes), + ); + + if (DateTime.now().isBefore(windowStart)) { + return const ClockInValidationResult.invalid('too_early_clock_out'); + } + + return const ClockInValidationResult.valid(); + } + + /// Returns the formatted earliest allowed time for the given [shiftTime]. + /// + /// The result is a 12-hour string such as "8:45 AM". Presentation code + /// can call this directly without depending on Flutter's [BuildContext]. + static String getAvailabilityTime(DateTime shiftTime) { + final DateTime windowStart = shiftTime.subtract( + const Duration(minutes: _earlyWindowMinutes), + ); + return DateFormat('h:mm a').format(windowStart); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart index f3bf5a7f..d2db4a44 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart @@ -8,25 +8,39 @@ import '../../../domain/usecases/clock_in_usecase.dart'; import '../../../domain/usecases/clock_out_usecase.dart'; import '../../../domain/usecases/get_attendance_status_usecase.dart'; import '../../../domain/usecases/get_todays_shift_usecase.dart'; +import '../../../domain/validators/clock_in_validation_context.dart'; +import '../../../domain/validators/clock_in_validation_result.dart'; +import '../../../domain/validators/composite_clock_in_validator.dart'; +import '../geofence/geofence_bloc.dart'; +import '../geofence/geofence_event.dart'; +import '../geofence/geofence_state.dart'; import 'clock_in_event.dart'; import 'clock_in_state.dart'; /// BLoC responsible for clock-in/clock-out operations and shift management. /// -/// Location and geofence concerns are delegated to [GeofenceBloc]. -/// The UI bridges geofence state into [CheckInRequested] event parameters. +/// Reads [GeofenceBloc] state directly to evaluate geofence conditions, +/// removing the need for the UI to bridge geofence fields into events. +/// Validation is delegated to [CompositeClockInValidator]. +/// Background tracking lifecycle is managed here after successful +/// clock-in/clock-out, rather than in the UI layer. class ClockInBloc extends Bloc with BlocErrorHandler { - /// Creates a [ClockInBloc] with the required use cases. + /// Creates a [ClockInBloc] with the required use cases, geofence BLoC, + /// and validator. ClockInBloc({ required GetTodaysShiftUseCase getTodaysShift, required GetAttendanceStatusUseCase getAttendanceStatus, required ClockInUseCase clockIn, required ClockOutUseCase clockOut, + required GeofenceBloc geofenceBloc, + required CompositeClockInValidator validator, }) : _getTodaysShift = getTodaysShift, _getAttendanceStatus = getAttendanceStatus, _clockIn = clockIn, _clockOut = clockOut, + _geofenceBloc = geofenceBloc, + _validator = validator, super(ClockInState(selectedDate: DateTime.now())) { on(_onLoaded); on(_onShiftSelected); @@ -43,6 +57,12 @@ class ClockInBloc extends Bloc final ClockInUseCase _clockIn; final ClockOutUseCase _clockOut; + /// Reference to [GeofenceBloc] for reading geofence state directly. + final GeofenceBloc _geofenceBloc; + + /// Composite validator for clock-in preconditions. + final CompositeClockInValidator _validator; + /// Loads today's shifts and the current attendance status. Future _onLoaded( ClockInPageLoaded event, @@ -106,41 +126,38 @@ class ClockInBloc extends Bloc /// Handles a clock-in request. /// - /// Geofence state is passed via event parameters from the UI layer: - /// - If the shift has a venue (lat/lng) and location is neither verified - /// nor timed out, the clock-in is rejected. - /// - If the location timed out, notes are required to proceed. - /// - Otherwise the clock-in proceeds normally. + /// Reads geofence state directly from [_geofenceBloc] and builds a + /// [ClockInValidationContext] to run through the [_validator] pipeline. + /// On success, dispatches [BackgroundTrackingStarted] to [_geofenceBloc]. Future _onCheckIn( CheckInRequested event, Emitter emit, ) async { final Shift? shift = state.selectedShift; - final bool shiftHasLocation = + final GeofenceState geofenceState = _geofenceBloc.state; + + final bool hasCoordinates = shift != null && shift.latitude != null && shift.longitude != null; - // If the shift requires location verification but geofence has not - // confirmed proximity, has not timed out, and the worker has not - // explicitly overridden via the justification modal, reject the attempt. - if (shiftHasLocation && - !event.isLocationVerified && - !event.isLocationTimedOut && - !event.isGeofenceOverridden) { - emit(state.copyWith( - status: ClockInStatus.failure, - errorMessage: 'errors.clock_in.location_verification_required', - )); - return; - } + // Build validation context from combined BLoC states. + final ClockInValidationContext validationContext = ClockInValidationContext( + isCheckingIn: true, + shiftStartTime: _tryParseDateTime(shift?.startTime), + shiftEndTime: _tryParseDateTime(shift?.endTime), + hasCoordinates: hasCoordinates, + isLocationVerified: geofenceState.isLocationVerified, + isLocationTimedOut: geofenceState.isLocationTimedOut, + isGeofenceOverridden: geofenceState.isGeofenceOverridden, + overrideNotes: event.notes, + ); - // When location timed out or geofence is overridden, require the user to - // provide notes explaining why they are clocking in without verified - // proximity. - if ((event.isLocationTimedOut || event.isGeofenceOverridden) && - (event.notes == null || event.notes!.trim().isEmpty)) { + final ClockInValidationResult validationResult = + _validator.validate(validationContext); + + if (!validationResult.isValid) { emit(state.copyWith( status: ClockInStatus.failure, - errorMessage: 'errors.clock_in.notes_required_for_timeout', + errorMessage: validationResult.errorKey, )); return; } @@ -156,6 +173,12 @@ class ClockInBloc extends Bloc status: ClockInStatus.success, attendance: newStatus, )); + + // Start background tracking after successful clock-in. + _dispatchBackgroundTrackingStarted( + event: event, + activeShiftId: newStatus.activeShiftId, + ); }, onError: (String errorKey) => state.copyWith( status: ClockInStatus.failure, @@ -165,6 +188,8 @@ class ClockInBloc extends Bloc } /// Handles a clock-out request. + /// + /// On success, dispatches [BackgroundTrackingStopped] to [_geofenceBloc]. Future _onCheckOut( CheckOutRequested event, Emitter emit, @@ -184,6 +209,14 @@ class ClockInBloc extends Bloc status: ClockInStatus.success, attendance: newStatus, )); + + // Stop background tracking after successful clock-out. + _geofenceBloc.add( + BackgroundTrackingStopped( + clockOutTitle: event.clockOutTitle, + clockOutBody: event.clockOutBody, + ), + ); }, onError: (String errorKey) => state.copyWith( status: ClockInStatus.failure, @@ -191,4 +224,33 @@ class ClockInBloc extends Bloc ), ); } + + /// Safely parses a time string into a [DateTime], returning `null` on failure. + static DateTime? _tryParseDateTime(String? value) { + if (value == null || value.isEmpty) return null; + return DateTime.tryParse(value); + } + + /// Dispatches [BackgroundTrackingStarted] to [_geofenceBloc] if the + /// geofence has target coordinates. + void _dispatchBackgroundTrackingStarted({ + required CheckInRequested event, + required String? activeShiftId, + }) { + final GeofenceState geofenceState = _geofenceBloc.state; + + if (geofenceState.targetLat != null && + geofenceState.targetLng != null && + activeShiftId != null) { + _geofenceBloc.add( + BackgroundTrackingStarted( + shiftId: activeShiftId, + targetLat: geofenceState.targetLat!, + targetLng: geofenceState.targetLng!, + greetingTitle: event.clockInGreetingTitle, + greetingBody: event.clockInGreetingBody, + ), + ); + } + } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart index 181ed372..d8c30eb1 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart @@ -36,45 +36,46 @@ class DateSelected extends ClockInEvent { /// Emitted when the user requests to clock in. /// -/// [isLocationVerified] and [isLocationTimedOut] are provided by the UI layer -/// from the GeofenceBloc state, bridging the two BLoCs. +/// Geofence state is read directly by the BLoC from [GeofenceBloc], +/// so this event only carries the shift ID, optional notes, and +/// notification strings for background tracking. class CheckInRequested extends ClockInEvent { const CheckInRequested({ required this.shiftId, this.notes, - this.isLocationVerified = false, - this.isLocationTimedOut = false, - this.isGeofenceOverridden = false, + this.clockInGreetingTitle = '', + this.clockInGreetingBody = '', }); /// The ID of the shift to clock into. final String shiftId; - /// Optional notes provided by the user. + /// Optional notes provided by the user (e.g. geofence override notes). final String? notes; - /// Whether the geofence verification passed (user is within radius). - final bool isLocationVerified; + /// Localized title for the clock-in greeting notification. + final String clockInGreetingTitle; - /// Whether the geofence verification timed out (GPS unavailable). - final bool isLocationTimedOut; - - /// Whether the worker explicitly overrode geofence via the justification modal. - final bool isGeofenceOverridden; + /// Localized body for the clock-in greeting notification. + final String clockInGreetingBody; @override List get props => [ shiftId, notes, - isLocationVerified, - isLocationTimedOut, - isGeofenceOverridden, + clockInGreetingTitle, + clockInGreetingBody, ]; } /// Emitted when the user requests to clock out. class CheckOutRequested extends ClockInEvent { - const CheckOutRequested({this.notes, this.breakTimeMinutes}); + const CheckOutRequested({ + this.notes, + this.breakTimeMinutes, + this.clockOutTitle = '', + this.clockOutBody = '', + }); /// Optional notes provided by the user. final String? notes; @@ -82,8 +83,19 @@ class CheckOutRequested extends ClockInEvent { /// Break time taken during the shift, in minutes. final int? breakTimeMinutes; + /// Localized title for the clock-out notification. + final String clockOutTitle; + + /// Localized body for the clock-out notification. + final String clockOutBody; + @override - List get props => [notes, breakTimeMinutes]; + List get props => [ + notes, + breakTimeMinutes, + clockOutTitle, + clockOutBody, + ]; } /// Emitted when the user changes the check-in mode (e.g. swipe vs tap). diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart index 1472d3a2..e2db2f73 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart @@ -5,6 +5,7 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import '../../../data/services/background_geofence_service.dart'; +import '../../../data/services/clock_in_notification_service.dart'; import '../../../domain/models/geofence_result.dart'; import '../../../domain/services/geofence_service_interface.dart'; import 'geofence_event.dart'; @@ -23,8 +24,10 @@ class GeofenceBloc extends Bloc GeofenceBloc({ required GeofenceServiceInterface geofenceService, required BackgroundGeofenceService backgroundGeofenceService, + required ClockInNotificationService notificationService, }) : _geofenceService = geofenceService, _backgroundGeofenceService = backgroundGeofenceService, + _notificationService = notificationService, super(const GeofenceState.initial()) { on(_onStarted); on(_onResultUpdated); @@ -42,6 +45,9 @@ class GeofenceBloc extends Bloc /// The background service for periodic tracking while clocked in. final BackgroundGeofenceService _backgroundGeofenceService; + /// The notification service for clock-in related notifications. + final ClockInNotificationService _notificationService; + /// Active subscription to the foreground geofence location stream. StreamSubscription? _geofenceSubscription; @@ -64,7 +70,7 @@ class GeofenceBloc extends Bloc emit: emit.call, action: () async { // Check permission first. - final permission = await _geofenceService.ensurePermission(); + final LocationPermissionStatus permission = await _geofenceService.ensurePermission(); emit(state.copyWith(permissionStatus: permission)); if (permission == LocationPermissionStatus.denied || @@ -81,12 +87,12 @@ class GeofenceBloc extends Bloc // Start monitoring location service status changes. await _serviceStatusSubscription?.cancel(); _serviceStatusSubscription = - _geofenceService.watchServiceStatus().listen((isEnabled) { + _geofenceService.watchServiceStatus().listen((bool isEnabled) { add(GeofenceServiceStatusChanged(isEnabled)); }); // Get initial position with a 30s timeout. - final result = await _geofenceService.checkGeofenceWithTimeout( + final GeofenceResult? result = await _geofenceService.checkGeofenceWithTimeout( targetLat: event.targetLat, targetLng: event.targetLng, ); @@ -105,7 +111,7 @@ class GeofenceBloc extends Bloc targetLng: event.targetLng, ) .listen( - (result) => add(GeofenceResultUpdated(result)), + (GeofenceResult result) => add(GeofenceResultUpdated(result)), ); }, onError: (String errorKey) => state.copyWith( @@ -172,7 +178,7 @@ class GeofenceBloc extends Bloc await handleError( emit: emit.call, action: () async { - final result = await _geofenceService.checkGeofenceWithTimeout( + final GeofenceResult? result = await _geofenceService.checkGeofenceWithTimeout( targetLat: state.targetLat!, targetLng: state.targetLng!, ); @@ -199,7 +205,7 @@ class GeofenceBloc extends Bloc emit: emit.call, action: () async { // Request upgrade to "Always" permission for background tracking. - final permission = await _geofenceService.requestAlwaysPermission(); + final LocationPermissionStatus permission = await _geofenceService.requestAlwaysPermission(); emit(state.copyWith(permissionStatus: permission)); // Start background tracking regardless (degrades gracefully). @@ -210,7 +216,7 @@ class GeofenceBloc extends Bloc ); // Show greeting notification using localized strings from the UI. - await _backgroundGeofenceService.showClockInGreetingNotification( + await _notificationService.showClockInGreeting( title: event.greetingTitle, body: event.greetingBody, ); @@ -235,7 +241,7 @@ class GeofenceBloc extends Bloc await _backgroundGeofenceService.stopBackgroundTracking(); // Show clock-out notification using localized strings from the UI. - await _backgroundGeofenceService.showClockOutNotification( + await _notificationService.showClockOutNotification( title: event.clockOutTitle, body: event.clockOutBody, ); diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart index 1b1c219b..c5f68a60 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart @@ -13,27 +13,27 @@ abstract class GeofenceEvent extends Equatable { /// Starts foreground geofence verification for a target location. class GeofenceStarted extends GeofenceEvent { + /// Creates a [GeofenceStarted] event. + const GeofenceStarted({required this.targetLat, required this.targetLng}); + /// Target latitude of the shift location. final double targetLat; /// Target longitude of the shift location. final double targetLng; - /// Creates a [GeofenceStarted] event. - const GeofenceStarted({required this.targetLat, required this.targetLng}); - @override List get props => [targetLat, targetLng]; } /// Emitted when a new geofence result is received from the location stream. class GeofenceResultUpdated extends GeofenceEvent { - /// The latest geofence check result. - final GeofenceResult result; - /// Creates a [GeofenceResultUpdated] event. const GeofenceResultUpdated(this.result); + /// The latest geofence check result. + final GeofenceResult result; + @override List get props => [result]; } @@ -46,12 +46,12 @@ class GeofenceTimeoutReached extends GeofenceEvent { /// Emitted when the device location service status changes. class GeofenceServiceStatusChanged extends GeofenceEvent { - /// Whether location services are now enabled. - final bool isEnabled; - /// Creates a [GeofenceServiceStatusChanged] event. const GeofenceServiceStatusChanged(this.isEnabled); + /// Whether location services are now enabled. + final bool isEnabled; + @override List get props => [isEnabled]; } @@ -64,6 +64,15 @@ class GeofenceRetryRequested extends GeofenceEvent { /// Starts background tracking after successful clock-in. class BackgroundTrackingStarted extends GeofenceEvent { + /// Creates a [BackgroundTrackingStarted] event. + const BackgroundTrackingStarted({ + required this.shiftId, + required this.targetLat, + required this.targetLng, + required this.greetingTitle, + required this.greetingBody, + }); + /// The shift ID being tracked. final String shiftId; @@ -79,15 +88,6 @@ class BackgroundTrackingStarted extends GeofenceEvent { /// Localized greeting notification body passed from the UI layer. final String greetingBody; - /// Creates a [BackgroundTrackingStarted] event. - const BackgroundTrackingStarted({ - required this.shiftId, - required this.targetLat, - required this.targetLng, - required this.greetingTitle, - required this.greetingBody, - }); - @override List get props => [shiftId, targetLat, targetLng, greetingTitle, greetingBody]; @@ -95,30 +95,30 @@ class BackgroundTrackingStarted extends GeofenceEvent { /// Stops background tracking after clock-out. class BackgroundTrackingStopped extends GeofenceEvent { - /// Localized clock-out notification title passed from the UI layer. - final String clockOutTitle; - - /// Localized clock-out notification body passed from the UI layer. - final String clockOutBody; - /// Creates a [BackgroundTrackingStopped] event. const BackgroundTrackingStopped({ required this.clockOutTitle, required this.clockOutBody, }); + /// Localized clock-out notification title passed from the UI layer. + final String clockOutTitle; + + /// Localized clock-out notification body passed from the UI layer. + final String clockOutBody; + @override List get props => [clockOutTitle, clockOutBody]; } /// Worker approved geofence override by providing justification notes. class GeofenceOverrideApproved extends GeofenceEvent { - /// The justification notes provided by the worker. - final String notes; - /// Creates a [GeofenceOverrideApproved] event. const GeofenceOverrideApproved({required this.notes}); + /// The justification notes provided by the worker. + final String notes; + @override List get props => [notes]; } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_state.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_state.dart index 080e5a75..a4ab8ed7 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_state.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_state.dart @@ -3,7 +3,6 @@ import 'package:krow_domain/krow_domain.dart'; /// State for the [GeofenceBloc]. class GeofenceState extends Equatable { - /// Creates a [GeofenceState] instance. const GeofenceState({ this.permissionStatus, @@ -19,6 +18,10 @@ class GeofenceState extends Equatable { this.targetLat, this.targetLng, }); + + /// Initial state before any geofence operations. + const GeofenceState.initial() : this(); + /// Current location permission status. final LocationPermissionStatus? permissionStatus; @@ -55,9 +58,6 @@ class GeofenceState extends Equatable { /// Target longitude being monitored. final double? targetLng; - /// Initial state before any geofence operations. - const GeofenceState.initial() : this(); - /// Creates a copy with the given fields replaced. GeofenceState copyWith({ LocationPermissionStatus? permissionStatus, diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart index 7b07af80..8aacb8ff 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart @@ -29,8 +29,12 @@ class ClockInPage extends StatelessWidget { appBar: UiAppBar(title: i18n.title, showBackButton: false), body: MultiBlocProvider( providers: >[ - BlocProvider.value(value: Modular.get()), - BlocProvider.value(value: Modular.get()), + BlocProvider.value( + value: Modular.get(), + ), + BlocProvider.value( + value: Modular.get(), + ), ], child: BlocListener( listenWhen: (ClockInState previous, ClockInState current) => diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/check_in_interaction.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/check_in_interaction.dart new file mode 100644 index 00000000..707d055f --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/check_in_interaction.dart @@ -0,0 +1,22 @@ +import 'package:flutter/widgets.dart'; + +/// Interface for different clock-in/out interaction methods (swipe, NFC, etc.). +/// +/// Each implementation encapsulates the UI and behavior for a specific +/// check-in mode, allowing the action section to remain mode-agnostic. +abstract class CheckInInteraction { + /// Unique identifier for this interaction mode (e.g. "swipe", "nfc"). + String get mode; + + /// Builds the action widget for this interaction method. + /// + /// The returned widget handles user interaction (swipe gesture, NFC tap, + /// etc.) and invokes [onCheckIn] or [onCheckOut] when the action completes. + Widget buildActionWidget({ + required bool isCheckedIn, + required bool isDisabled, + required bool isLoading, + required VoidCallback onCheckIn, + required VoidCallback onCheckOut, + }); +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/nfc_check_in_interaction.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/nfc_check_in_interaction.dart new file mode 100644 index 00000000..f479ac77 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/nfc_check_in_interaction.dart @@ -0,0 +1,118 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../widgets/nfc_scan_dialog.dart'; +import 'check_in_interaction.dart'; + +/// NFC-based check-in interaction that shows a tap button and scan dialog. +/// +/// When tapped, presents the [showNfcScanDialog] and triggers [onCheckIn] +/// or [onCheckOut] upon a successful scan. +class NfcCheckInInteraction implements CheckInInteraction { + /// Creates an NFC check-in interaction. + const NfcCheckInInteraction(); + + @override + String get mode => 'nfc'; + + @override + Widget buildActionWidget({ + required bool isCheckedIn, + required bool isDisabled, + required bool isLoading, + required VoidCallback onCheckIn, + required VoidCallback onCheckOut, + }) { + return _NfcCheckInButton( + isCheckedIn: isCheckedIn, + isDisabled: isDisabled, + isLoading: isLoading, + onCheckIn: onCheckIn, + onCheckOut: onCheckOut, + ); + } +} + +/// Tap button that launches the NFC scan dialog and triggers check-in/out. +class _NfcCheckInButton extends StatelessWidget { + const _NfcCheckInButton({ + required this.isCheckedIn, + required this.isDisabled, + required this.isLoading, + required this.onCheckIn, + required this.onCheckOut, + }); + + /// Whether the user is currently checked in. + final bool isCheckedIn; + + /// Whether the button should be disabled (e.g. geofence blocking). + final bool isDisabled; + + /// Whether a check-in/out action is in progress. + final bool isLoading; + + /// Called after a successful NFC scan when checking in. + final VoidCallback onCheckIn; + + /// Called after a successful NFC scan when checking out. + final VoidCallback onCheckOut; + + @override + Widget build(BuildContext context) { + final TranslationsStaffClockInSwipeEn i18n = + Translations.of(context).staff.clock_in.swipe; + final Color baseColor = isCheckedIn ? UiColors.success : UiColors.primary; + + return GestureDetector( + onTap: () => _handleTap(context), + child: Container( + height: 56, + decoration: BoxDecoration( + color: isDisabled ? UiColors.bgSecondary : baseColor, + borderRadius: UiConstants.radiusLg, + boxShadow: isDisabled + ? [] + : [ + BoxShadow( + color: baseColor.withValues(alpha: 0.4), + blurRadius: 25, + offset: const Offset(0, 10), + spreadRadius: -5, + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(UiIcons.wifi, color: UiColors.white), + const SizedBox(width: UiConstants.space3), + Text( + isLoading + ? (isCheckedIn ? i18n.checking_out : i18n.checking_in) + : (isCheckedIn ? i18n.nfc_checkout : i18n.nfc_checkin), + style: UiTypography.body1b.copyWith( + color: isDisabled ? UiColors.textDisabled : UiColors.white, + ), + ), + ], + ), + ), + ); + } + + /// Opens the NFC scan dialog and triggers the appropriate callback on success. + Future _handleTap(BuildContext context) async { + if (isLoading || isDisabled) return; + + final bool scanned = await showNfcScanDialog(context); + if (scanned && context.mounted) { + if (isCheckedIn) { + onCheckOut(); + } else { + onCheckIn(); + } + } + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/swipe_check_in_interaction.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/swipe_check_in_interaction.dart new file mode 100644 index 00000000..21273af9 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/swipe_check_in_interaction.dart @@ -0,0 +1,30 @@ +import 'package:flutter/widgets.dart'; + +import '../widgets/swipe_to_check_in.dart'; +import 'check_in_interaction.dart'; + +/// Swipe-based check-in interaction using the [SwipeToCheckIn] slider widget. +class SwipeCheckInInteraction implements CheckInInteraction { + /// Creates a swipe check-in interaction. + const SwipeCheckInInteraction(); + + @override + String get mode => 'swipe'; + + @override + Widget buildActionWidget({ + required bool isCheckedIn, + required bool isDisabled, + required bool isLoading, + required VoidCallback onCheckIn, + required VoidCallback onCheckOut, + }) { + return SwipeToCheckIn( + isCheckedIn: isCheckedIn, + isDisabled: isDisabled, + isLoading: isLoading, + onCheckIn: onCheckIn, + onCheckOut: onCheckOut, + ); + } +} 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 292afac0..c2efc4c1 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 @@ -8,24 +8,25 @@ import 'package:krow_domain/krow_domain.dart'; import '../bloc/clock_in/clock_in_bloc.dart'; import '../bloc/clock_in/clock_in_event.dart'; -import '../bloc/clock_in/clock_in_state.dart'; import '../bloc/geofence/geofence_bloc.dart'; -import '../bloc/geofence/geofence_event.dart'; import '../bloc/geofence/geofence_state.dart'; -import 'clock_in_helpers.dart'; +import '../../domain/validators/clock_in_validation_context.dart'; +import '../../domain/validators/time_window_validator.dart'; +import '../strategies/check_in_interaction.dart'; +import '../strategies/nfc_check_in_interaction.dart'; +import '../strategies/swipe_check_in_interaction.dart'; import 'early_check_in_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'; import 'shift_completed_banner.dart'; -import 'swipe_to_check_in.dart'; /// Orchestrates which action widget is displayed based on the current state. /// -/// Decides between the swipe-to-check-in slider, the early-arrival banner, -/// the shift-completed banner, or the no-shifts placeholder. Also shows the -/// [GeofenceStatusBanner] and manages background tracking lifecycle. +/// Uses the [CheckInInteraction] strategy pattern to delegate the actual +/// check-in/out UI to mode-specific implementations (swipe, NFC, etc.). +/// Also shows the [GeofenceStatusBanner]. Background tracking lifecycle +/// is managed by [ClockInBloc], not this widget. class ClockInActionSection extends StatelessWidget { /// Creates the action section. const ClockInActionSection({ @@ -37,6 +38,13 @@ class ClockInActionSection extends StatelessWidget { super.key, }); + /// Available check-in interaction strategies keyed by mode identifier. + static const Map _interactions = + { + 'swipe': SwipeCheckInInteraction(), + 'nfc': NfcCheckInInteraction(), + }; + /// The currently selected shift, or null if none is selected. final Shift? selectedShift; @@ -52,46 +60,14 @@ class ClockInActionSection extends StatelessWidget { /// Whether a check-in or check-out action is currently in progress. final bool isActionInProgress; + /// Resolves the [CheckInInteraction] for the current mode. + /// + /// Falls back to [SwipeCheckInInteraction] if the mode is unrecognized. + CheckInInteraction get _currentInteraction => + _interactions[checkInMode] ?? const SwipeCheckInInteraction(); + @override Widget build(BuildContext context) { - return MultiBlocListener( - listeners: >[ - // Start background tracking after successful check-in. - BlocListener( - listenWhen: (ClockInState previous, ClockInState current) { - return previous.status == ClockInStatus.actionInProgress && - current.status == ClockInStatus.success && - current.attendance.isCheckedIn && - !previous.attendance.isCheckedIn; - }, - - listener: (BuildContext context, ClockInState state) { - _startBackgroundTracking(context, state); - }, - ), - // Stop background tracking after clock-out. - BlocListener( - listenWhen: (ClockInState previous, ClockInState current) => - previous.attendance.isCheckedIn && - !current.attendance.isCheckedIn, - listener: (BuildContext context, ClockInState _) { - final TranslationsStaffClockInGeofenceEn geofenceI18n = - Translations.of(context).staff.clock_in.geofence; - ReadContext(context).read().add( - BackgroundTrackingStopped( - clockOutTitle: geofenceI18n.clock_out_title, - clockOutBody: geofenceI18n.clock_out_body, - ), - ); - }, - ), - ], - child: _buildContent(context), - ); - } - - /// Builds the main content column with geofence banner and action widget. - Widget _buildContent(BuildContext context) { if (selectedShift != null && checkOutTime == null) { return _buildActiveShiftAction(context); } @@ -105,14 +81,14 @@ class ClockInActionSection extends StatelessWidget { /// Builds the action widget for an active (not completed) shift. Widget _buildActiveShiftAction(BuildContext context) { - if (!isCheckedIn && !ClockInHelpers.isCheckInAllowed(selectedShift!)) { + if (!isCheckedIn && !_isCheckInAllowed(selectedShift!)) { return Column( mainAxisSize: MainAxisSize.min, children: [ const GeofenceStatusBanner(), const SizedBox(height: UiConstants.space3), EarlyCheckInBanner( - availabilityTime: ClockInHelpers.getCheckInAvailabilityTime( + availabilityTime: _getAvailabilityTimeText( selectedShift!, context, ), @@ -138,11 +114,9 @@ class ClockInActionSection extends StatelessWidget { mainAxisSize: MainAxisSize.min, spacing: UiConstants.space4, children: [ - // Geofence status banner is shown even when not blocking to provide feedback const GeofenceStatusBanner(), - SwipeToCheckIn( + _currentInteraction.buildActionWidget( isCheckedIn: isCheckedIn, - mode: checkInMode, isDisabled: isGeofenceBlocking, isLoading: isActionInProgress, onCheckIn: () => _handleCheckIn(context), @@ -154,76 +128,70 @@ class ClockInActionSection extends StatelessWidget { ); } - /// Triggers the check-in flow, reading geofence state for location data. - Future _handleCheckIn(BuildContext context) async { + /// Triggers the check-in flow, passing notification strings and + /// override notes from geofence state. + void _handleCheckIn(BuildContext context) { final GeofenceState geofenceState = ReadContext( context, ).read().state; + final TranslationsStaffClockInGeofenceEn geofenceI18n = + Translations.of(context).staff.clock_in.geofence; - if (checkInMode == 'nfc') { - final bool scanned = await showNfcScanDialog(context); - if (scanned && context.mounted) { - ReadContext(context).read().add( - CheckInRequested( - shiftId: selectedShift!.id, - notes: geofenceState.overrideNotes, - isLocationVerified: geofenceState.isLocationVerified, - isLocationTimedOut: geofenceState.isLocationTimedOut, - isGeofenceOverridden: geofenceState.isGeofenceOverridden, - ), - ); - } - } else { - ReadContext(context).read().add( - CheckInRequested( - shiftId: selectedShift!.id, - notes: geofenceState.overrideNotes, - isLocationVerified: geofenceState.isLocationVerified, - isLocationTimedOut: geofenceState.isLocationTimedOut, - isGeofenceOverridden: geofenceState.isGeofenceOverridden, - ), - ); + ReadContext(context).read().add( + CheckInRequested( + shiftId: selectedShift!.id, + notes: geofenceState.overrideNotes, + clockInGreetingTitle: geofenceI18n.clock_in_greeting_title, + clockInGreetingBody: geofenceI18n.clock_in_greeting_body, + ), + ); + } + + /// Whether the user is allowed to check in for the given [shift]. + /// + /// Delegates to [TimeWindowValidator]; returns `true` if the start time + /// cannot be parsed (don't block the user). + bool _isCheckInAllowed(Shift shift) { + final DateTime? shiftStart = DateTime.tryParse(shift.startTime); + if (shiftStart == null) return true; + + final ClockInValidationContext validationContext = ClockInValidationContext( + isCheckingIn: true, + shiftStartTime: shiftStart, + ); + return const TimeWindowValidator().validate(validationContext).isValid; + } + + /// Returns the formatted earliest check-in time for the given [shift]. + /// + /// Falls back to the localized "soon" label when the start time cannot + /// be parsed. + String _getAvailabilityTimeText(Shift shift, BuildContext context) { + final DateTime? shiftStart = DateTime.tryParse(shift.startTime.trim()); + if (shiftStart != null) { + return TimeWindowValidator.getAvailabilityTime(shiftStart); } + return Translations.of(context).staff.clock_in.soon; } /// Triggers the check-out flow via the lunch-break confirmation dialog. void _handleCheckOut(BuildContext context) { + final TranslationsStaffClockInGeofenceEn geofenceI18n = + Translations.of(context).staff.clock_in.geofence; + showDialog( context: context, builder: (BuildContext dialogContext) => LunchBreakDialog( onComplete: () { Modular.to.popSafe(); - ReadContext( - context, - ).read().add(const CheckOutRequested()); + ReadContext(context).read().add( + CheckOutRequested( + clockOutTitle: geofenceI18n.clock_out_title, + clockOutBody: geofenceI18n.clock_out_body, + ), + ); }, ), ); } - - /// Dispatches [BackgroundTrackingStarted] if the geofence has target - /// coordinates after a successful check-in. - void _startBackgroundTracking(BuildContext context, ClockInState state) { - final GeofenceState geofenceState = ReadContext( - context, - ).read().state; - - if (geofenceState.targetLat != null && - geofenceState.targetLng != null && - state.attendance.activeShiftId != null) { - final TranslationsStaffClockInGeofenceEn geofenceI18n = Translations.of( - context, - ).staff.clock_in.geofence; - - ReadContext(context).read().add( - BackgroundTrackingStarted( - shiftId: state.attendance.activeShiftId!, - targetLat: geofenceState.targetLat!, - targetLng: geofenceState.targetLng!, - greetingTitle: geofenceI18n.clock_in_greeting_title, - greetingBody: geofenceI18n.clock_in_greeting_body, - ), - ); - } - } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_helpers.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_helpers.dart deleted file mode 100644 index 9f64639d..00000000 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_helpers.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:core_localization/core_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:krow_domain/krow_domain.dart'; - -/// Pure utility functions shared across clock-in widgets. -/// -/// These are stateless helpers that handle time formatting and -/// shift check-in availability calculations. -class ClockInHelpers { - const ClockInHelpers._(); - - /// Formats a time string (ISO 8601 or HH:mm) into a human-readable - /// 12-hour format (e.g. "9:00 AM"). - static String formatTime(String timeStr) { - if (timeStr.isEmpty) return ''; - try { - final DateTime dt = DateTime.parse(timeStr); - return DateFormat('h:mm a').format(dt); - } catch (_) { - try { - final List parts = timeStr.split(':'); - if (parts.length >= 2) { - final DateTime dt = DateTime( - 2022, - 1, - 1, - int.parse(parts[0]), - int.parse(parts[1]), - ); - return DateFormat('h:mm a').format(dt); - } - return timeStr; - } catch (e) { - return timeStr; - } - } - } - - /// Whether the user is allowed to check in for the given [shift]. - /// - /// Check-in is permitted 15 minutes before the shift start time. - /// Falls back to `true` if the start time cannot be parsed. - static bool isCheckInAllowed(Shift shift) { - try { - final DateTime shiftStart = DateTime.parse(shift.startTime); - final DateTime windowStart = shiftStart.subtract( - const Duration(minutes: 15), - ); - return DateTime.now().isAfter(windowStart); - } catch (e) { - return true; - } - } - - /// Returns the earliest time the user may check in for the given [shift], - /// formatted as a 12-hour string (e.g. "8:45 AM"). - /// - /// Falls back to the localized "soon" label when the start time cannot - /// be parsed. - static String getCheckInAvailabilityTime( - Shift shift, - BuildContext context, - ) { - try { - final DateTime shiftStart = DateTime.parse(shift.startTime.trim()); - final DateTime windowStart = shiftStart.subtract( - const Duration(minutes: 15), - ); - return DateFormat('h:mm a').format(windowStart); - } catch (e) { - final TranslationsStaffClockInEn i18n = Translations.of( - context, - ).staff.clock_in; - return i18n.soon; - } - } -} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart index fc63f090..3b4d0d97 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart @@ -3,7 +3,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'clock_in_helpers.dart'; +import 'package:krow_core/core.dart' show formatTime; /// A selectable card that displays a single shift's summary information. /// @@ -110,7 +110,7 @@ class _ShiftTimeAndRate extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - '${ClockInHelpers.formatTime(shift.startTime)} - ${ClockInHelpers.formatTime(shift.endTime)}', + '${formatTime(shift.startTime)} - ${formatTime(shift.endTime)}', style: UiTypography.body3m.textSecondary, ), Text( diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart index b9c8599b..8c0bc42e 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart @@ -3,21 +3,35 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +/// A swipe-to-confirm slider for clock-in and clock-out actions. +/// +/// Displays a draggable handle that the user slides to the end to confirm +/// check-in or check-out. This widget only handles the swipe interaction; +/// NFC mode is handled by a separate [CheckInInteraction] strategy. class SwipeToCheckIn extends StatefulWidget { + /// Creates a swipe-to-check-in slider. const SwipeToCheckIn({ super.key, this.onCheckIn, this.onCheckOut, this.isLoading = false, - this.mode = 'swipe', this.isCheckedIn = false, this.isDisabled = false, }); + + /// Called when the user completes the swipe to check in. final VoidCallback? onCheckIn; + + /// Called when the user completes the swipe to check out. final VoidCallback? onCheckOut; + + /// Whether a check-in/out action is currently in progress. final bool isLoading; - final String mode; // 'swipe' or 'nfc' + + /// Whether the user is currently checked in. final bool isCheckedIn; + + /// Whether the slider is disabled (e.g. geofence blocking). final bool isDisabled; @override @@ -76,57 +90,6 @@ class _SwipeToCheckInState extends State @override Widget build(BuildContext context) { final TranslationsStaffClockInSwipeEn i18n = Translations.of(context).staff.clock_in.swipe; - final Color baseColor = widget.isCheckedIn - ? UiColors.success - : UiColors.primary; - - if (widget.mode == 'nfc') { - return GestureDetector( - onTap: () { - if (widget.isLoading || widget.isDisabled) return; - // Simulate completion for NFC tap - Future.delayed(const Duration(milliseconds: 300), () { - if (widget.isCheckedIn) { - widget.onCheckOut?.call(); - } else { - widget.onCheckIn?.call(); - } - }); - }, - child: Container( - height: 56, - decoration: BoxDecoration( - color: widget.isDisabled ? UiColors.bgSecondary : baseColor, - borderRadius: UiConstants.radiusLg, - boxShadow: widget.isDisabled ? [] : [ - BoxShadow( - color: baseColor.withValues(alpha: 0.4), - blurRadius: 25, - offset: const Offset(0, 10), - spreadRadius: -5, - ), - ], - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(UiIcons.wifi, color: UiColors.white), - const SizedBox(width: UiConstants.space3), - Text( - widget.isLoading - ? (widget.isCheckedIn - ? i18n.checking_out - : i18n.checking_in) - : (widget.isCheckedIn ? i18n.nfc_checkout : i18n.nfc_checkin), - style: UiTypography.body1b.copyWith( - color: widget.isDisabled ? UiColors.textDisabled : UiColors.white, - ), - ), - ], - ), - ), - ); - } return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart index d509a2b7..370362fc 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart @@ -4,6 +4,7 @@ import 'package:krow_core/core.dart'; import 'data/repositories_impl/clock_in_repository_impl.dart'; import 'data/services/background_geofence_service.dart'; +import 'data/services/clock_in_notification_service.dart'; import 'data/services/geofence_service_impl.dart'; import 'domain/repositories/clock_in_repository_interface.dart'; import 'domain/services/geofence_service_interface.dart'; @@ -11,13 +12,18 @@ import 'domain/usecases/clock_in_usecase.dart'; import 'domain/usecases/clock_out_usecase.dart'; import 'domain/usecases/get_attendance_status_usecase.dart'; import 'domain/usecases/get_todays_shift_usecase.dart'; +import 'domain/validators/clock_in_validator.dart'; +import 'domain/validators/composite_clock_in_validator.dart'; +import 'domain/validators/geofence_validator.dart'; +import 'domain/validators/override_notes_validator.dart'; +import 'domain/validators/time_window_validator.dart'; import 'presentation/bloc/clock_in/clock_in_bloc.dart'; import 'presentation/bloc/geofence/geofence_bloc.dart'; import 'presentation/pages/clock_in_page.dart'; /// Module for the staff clock-in feature. /// -/// Registers repositories, use cases, geofence services, and BLoCs. +/// Registers repositories, use cases, validators, geofence services, and BLoCs. class StaffClockInModule extends Module { @override List get imports => [CoreModule()]; @@ -36,23 +42,50 @@ class StaffClockInModule extends Module { i.add( () => BackgroundGeofenceService( backgroundTaskService: i.get(), - notificationService: i.get(), storageService: i.get(), ), ); + // Notification Service (clock-in / clock-out / geofence notifications) + i.add( + () => ClockInNotificationService( + notificationService: i.get(), + ), + ); + // Use Cases i.add(GetTodaysShiftUseCase.new); i.add(GetAttendanceStatusUseCase.new); i.add(ClockInUseCase.new); i.add(ClockOutUseCase.new); - // BLoCs (transient -- new instance per navigation) - i.add(ClockInBloc.new); - i.add( + // Validators + i.addLazySingleton( + () => const CompositeClockInValidator([ + GeofenceValidator(), + TimeWindowValidator(), + OverrideNotesValidator(), + ]), + ); + + // BLoCs + // GeofenceBloc is a lazy singleton so that ClockInBloc and the widget tree + // share the same instance within a navigation scope. + i.addLazySingleton( () => GeofenceBloc( geofenceService: i.get(), backgroundGeofenceService: i.get(), + notificationService: i.get(), + ), + ); + i.add( + () => ClockInBloc( + getTodaysShift: i.get(), + getAttendanceStatus: i.get(), + clockIn: i.get(), + clockOut: i.get(), + geofenceBloc: i.get(), + validator: i.get(), ), ); } From aa556c4a05310f8da9687f35565efc50922433fa Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 14 Mar 2026 20:03:30 -0400 Subject: [PATCH 19/33] refactor(clock_in): reorganize validation logic into separate validator files --- .../{ => validators}/clock_in_validator.dart | 4 ++-- .../{ => validators}/composite_clock_in_validator.dart | 4 ++-- .../{ => validators}/geofence_validator.dart | 4 ++-- .../{ => validators}/override_notes_validator.dart | 4 ++-- .../{ => validators}/time_window_validator.dart | 4 ++-- .../src/presentation/bloc/clock_in/clock_in_bloc.dart | 2 +- .../presentation/widgets/clock_in_action_section.dart | 2 +- .../staff/clock_in/lib/src/staff_clock_in_module.dart | 10 +++++----- 8 files changed, 17 insertions(+), 17 deletions(-) rename apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/{ => validators}/clock_in_validator.dart (81%) rename apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/{ => validators}/composite_clock_in_validator.dart (92%) rename apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/{ => validators}/geofence_validator.dart (93%) rename apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/{ => validators}/override_notes_validator.dart (92%) rename apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/{ => validators}/time_window_validator.dart (93%) diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validator.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/clock_in_validator.dart similarity index 81% rename from apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validator.dart rename to apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/clock_in_validator.dart index 8b818524..62ccdc54 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/clock_in_validator.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/clock_in_validator.dart @@ -1,5 +1,5 @@ -import 'clock_in_validation_context.dart'; -import 'clock_in_validation_result.dart'; +import '../clock_in_validation_context.dart'; +import '../clock_in_validation_result.dart'; /// Abstract interface for a single step in the clock-in validation pipeline. /// diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/composite_clock_in_validator.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/composite_clock_in_validator.dart similarity index 92% rename from apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/composite_clock_in_validator.dart rename to apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/composite_clock_in_validator.dart index 3521ee7a..d6cd0173 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/composite_clock_in_validator.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/composite_clock_in_validator.dart @@ -1,5 +1,5 @@ -import 'clock_in_validation_context.dart'; -import 'clock_in_validation_result.dart'; +import '../clock_in_validation_context.dart'; +import '../clock_in_validation_result.dart'; import 'clock_in_validator.dart'; /// Runs a list of [ClockInValidator]s in order, short-circuiting on first failure. diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/geofence_validator.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/geofence_validator.dart similarity index 93% rename from apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/geofence_validator.dart rename to apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/geofence_validator.dart index 1f6c3c80..cf2d8704 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/geofence_validator.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/geofence_validator.dart @@ -1,5 +1,5 @@ -import 'clock_in_validation_context.dart'; -import 'clock_in_validation_result.dart'; +import '../clock_in_validation_context.dart'; +import '../clock_in_validation_result.dart'; import 'clock_in_validator.dart'; /// Validates that geofence requirements are satisfied before clock-in. diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/override_notes_validator.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/override_notes_validator.dart similarity index 92% rename from apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/override_notes_validator.dart rename to apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/override_notes_validator.dart index a425e53d..22eef4c0 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/override_notes_validator.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/override_notes_validator.dart @@ -1,5 +1,5 @@ -import 'clock_in_validation_context.dart'; -import 'clock_in_validation_result.dart'; +import '../clock_in_validation_context.dart'; +import '../clock_in_validation_result.dart'; import 'clock_in_validator.dart'; /// Validates that override notes are provided when required. diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/time_window_validator.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/time_window_validator.dart similarity index 93% rename from apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/time_window_validator.dart rename to apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/time_window_validator.dart index 4fcac299..a38edd62 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/time_window_validator.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/time_window_validator.dart @@ -1,7 +1,7 @@ import 'package:intl/intl.dart'; +import 'package:staff_clock_in/src/domain/validators/clock_in_validation_context.dart'; +import 'package:staff_clock_in/src/domain/validators/clock_in_validation_result.dart'; -import 'clock_in_validation_context.dart'; -import 'clock_in_validation_result.dart'; import 'clock_in_validator.dart'; /// Validates that the current time falls within the allowed window. diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart index d2db4a44..748509d1 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart @@ -10,7 +10,7 @@ import '../../../domain/usecases/get_attendance_status_usecase.dart'; import '../../../domain/usecases/get_todays_shift_usecase.dart'; import '../../../domain/validators/clock_in_validation_context.dart'; import '../../../domain/validators/clock_in_validation_result.dart'; -import '../../../domain/validators/composite_clock_in_validator.dart'; +import '../../../domain/validators/validators/composite_clock_in_validator.dart'; import '../geofence/geofence_bloc.dart'; import '../geofence/geofence_event.dart'; import '../geofence/geofence_state.dart'; 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 c2efc4c1..99c348d1 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 @@ -11,7 +11,7 @@ import '../bloc/clock_in/clock_in_event.dart'; import '../bloc/geofence/geofence_bloc.dart'; import '../bloc/geofence/geofence_state.dart'; import '../../domain/validators/clock_in_validation_context.dart'; -import '../../domain/validators/time_window_validator.dart'; +import '../../domain/validators/validators/time_window_validator.dart'; import '../strategies/check_in_interaction.dart'; import '../strategies/nfc_check_in_interaction.dart'; import '../strategies/swipe_check_in_interaction.dart'; diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart index 370362fc..32945ba3 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart @@ -12,11 +12,11 @@ import 'domain/usecases/clock_in_usecase.dart'; import 'domain/usecases/clock_out_usecase.dart'; import 'domain/usecases/get_attendance_status_usecase.dart'; import 'domain/usecases/get_todays_shift_usecase.dart'; -import 'domain/validators/clock_in_validator.dart'; -import 'domain/validators/composite_clock_in_validator.dart'; -import 'domain/validators/geofence_validator.dart'; -import 'domain/validators/override_notes_validator.dart'; -import 'domain/validators/time_window_validator.dart'; +import 'domain/validators/validators/clock_in_validator.dart'; +import 'domain/validators/validators/composite_clock_in_validator.dart'; +import 'domain/validators/validators/geofence_validator.dart'; +import 'domain/validators/validators/override_notes_validator.dart'; +import 'domain/validators/validators/time_window_validator.dart'; import 'presentation/bloc/clock_in/clock_in_bloc.dart'; import 'presentation/bloc/geofence/geofence_bloc.dart'; import 'presentation/pages/clock_in_page.dart'; From e93f7f70046645931c434fd15290cbfcc12200bf Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 14 Mar 2026 20:11:19 -0400 Subject: [PATCH 20/33] fix(clock_in): improve error handling and state reset logic in clock-in process --- .../src/presentation/bloc/clock_in/clock_in_bloc.dart | 5 +++++ .../lib/src/presentation/pages/clock_in_page.dart | 4 +++- .../lib/src/presentation/widgets/swipe_to_check_in.dart | 9 +++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart index 748509d1..b5532656 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart @@ -133,6 +133,11 @@ class ClockInBloc extends Bloc CheckInRequested event, Emitter emit, ) async { + // Clear previous error so repeated failures are always emitted as new states. + if (state.errorMessage != null) { + emit(state.copyWith(errorMessage: null)); + } + final Shift? shift = state.selectedShift; final GeofenceState geofenceState = _geofenceBloc.state; diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart index 8aacb8ff..279749a0 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart @@ -39,7 +39,9 @@ class ClockInPage extends StatelessWidget { child: BlocListener( listenWhen: (ClockInState previous, ClockInState current) => current.status == ClockInStatus.failure && - current.errorMessage != null, + current.errorMessage != null && + (previous.status != current.status || + previous.errorMessage != current.errorMessage), listener: (BuildContext context, ClockInState state) { UiSnackbar.show( context, diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart index 8c0bc42e..906cca30 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart @@ -47,12 +47,21 @@ class _SwipeToCheckInState extends State @override void didUpdateWidget(SwipeToCheckIn oldWidget) { super.didUpdateWidget(oldWidget); + // Reset on check-in state change (successful action). if (widget.isCheckedIn != oldWidget.isCheckedIn) { setState(() { _isComplete = false; _dragValue = 0.0; }); } + // Reset on error: loading finished but check-in state didn't change. + if (oldWidget.isLoading && !widget.isLoading && + widget.isCheckedIn == oldWidget.isCheckedIn && _isComplete) { + setState(() { + _isComplete = false; + _dragValue = 0.0; + }); + } } void _onDragUpdate(DragUpdateDetails details, double maxWidth) { From 10bd61b25087634e9cf177f09e99771b86bb3ea6 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 14 Mar 2026 20:21:10 -0400 Subject: [PATCH 21/33] feat(clock_in): add error handling support to check-in interactions --- .../strategies/check_in_interaction.dart | 1 + .../strategies/nfc_check_in_interaction.dart | 1 + .../swipe_check_in_interaction.dart | 2 + .../widgets/clock_in_action_section.dart | 40 ++++++++++--------- .../presentation/widgets/clock_in_body.dart | 1 + .../widgets/swipe_to_check_in.dart | 12 ++++-- 6 files changed, 36 insertions(+), 21 deletions(-) diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/check_in_interaction.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/check_in_interaction.dart index 707d055f..6f218f90 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/check_in_interaction.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/check_in_interaction.dart @@ -16,6 +16,7 @@ abstract class CheckInInteraction { required bool isCheckedIn, required bool isDisabled, required bool isLoading, + required bool hasError, required VoidCallback onCheckIn, required VoidCallback onCheckOut, }); diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/nfc_check_in_interaction.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/nfc_check_in_interaction.dart index f479ac77..efa2e0e3 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/nfc_check_in_interaction.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/nfc_check_in_interaction.dart @@ -21,6 +21,7 @@ class NfcCheckInInteraction implements CheckInInteraction { required bool isCheckedIn, required bool isDisabled, required bool isLoading, + required bool hasError, required VoidCallback onCheckIn, required VoidCallback onCheckOut, }) { diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/swipe_check_in_interaction.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/swipe_check_in_interaction.dart index 21273af9..4e27e0e6 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/swipe_check_in_interaction.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/swipe_check_in_interaction.dart @@ -16,6 +16,7 @@ class SwipeCheckInInteraction implements CheckInInteraction { required bool isCheckedIn, required bool isDisabled, required bool isLoading, + required bool hasError, required VoidCallback onCheckIn, required VoidCallback onCheckOut, }) { @@ -23,6 +24,7 @@ class SwipeCheckInInteraction implements CheckInInteraction { isCheckedIn: isCheckedIn, isDisabled: isDisabled, isLoading: isLoading, + hasError: hasError, onCheckIn: onCheckIn, onCheckOut: onCheckOut, ); 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 99c348d1..c1791c0b 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 @@ -6,16 +6,15 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; +import '../../domain/validators/clock_in_validation_context.dart'; +import '../../domain/validators/validators/time_window_validator.dart'; import '../bloc/clock_in/clock_in_bloc.dart'; import '../bloc/clock_in/clock_in_event.dart'; import '../bloc/geofence/geofence_bloc.dart'; import '../bloc/geofence/geofence_state.dart'; -import '../../domain/validators/clock_in_validation_context.dart'; -import '../../domain/validators/validators/time_window_validator.dart'; import '../strategies/check_in_interaction.dart'; import '../strategies/nfc_check_in_interaction.dart'; import '../strategies/swipe_check_in_interaction.dart'; -import 'early_check_in_banner.dart'; import 'geofence_status_banner/geofence_status_banner.dart'; import 'lunch_break_modal.dart'; import 'no_shifts_banner.dart'; @@ -35,6 +34,7 @@ class ClockInActionSection extends StatelessWidget { required this.checkOutTime, required this.checkInMode, required this.isActionInProgress, + this.hasError = false, super.key, }); @@ -60,6 +60,9 @@ class ClockInActionSection extends StatelessWidget { /// Whether a check-in or check-out action is currently in progress. final bool isActionInProgress; + /// Whether the last action attempt resulted in an error. + final bool hasError; + /// Resolves the [CheckInInteraction] for the current mode. /// /// Falls back to [SwipeCheckInInteraction] if the mode is unrecognized. @@ -81,21 +84,21 @@ class ClockInActionSection extends StatelessWidget { /// Builds the action widget for an active (not completed) shift. Widget _buildActiveShiftAction(BuildContext context) { - if (!isCheckedIn && !_isCheckInAllowed(selectedShift!)) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - const GeofenceStatusBanner(), - const SizedBox(height: UiConstants.space3), - EarlyCheckInBanner( - availabilityTime: _getAvailabilityTimeText( - selectedShift!, - context, - ), - ), - ], - ); - } + // if (!isCheckedIn && !_isCheckInAllowed(selectedShift!)) { + // return Column( + // mainAxisSize: MainAxisSize.min, + // children: [ + // const GeofenceStatusBanner(), + // const SizedBox(height: UiConstants.space3), + // EarlyCheckInBanner( + // availabilityTime: _getAvailabilityTimeText( + // selectedShift!, + // context, + // ), + // ), + // ], + // ); + // } return BlocBuilder( builder: (BuildContext context, GeofenceState geofenceState) { @@ -119,6 +122,7 @@ class ClockInActionSection extends StatelessWidget { isCheckedIn: isCheckedIn, isDisabled: isGeofenceBlocking, isLoading: isActionInProgress, + hasError: hasError, onCheckIn: () => _handleCheckIn(context), onCheckOut: () => _handleCheckOut(context), ), diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart index 8b0032f4..05f1f7cc 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart @@ -111,6 +111,7 @@ class _ClockInBodyState extends State { checkInMode: state.checkInMode, isActionInProgress: state.status == ClockInStatus.actionInProgress, + hasError: state.status == ClockInStatus.failure, ), // checked-in banner (only when checked in to the selected shift) diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart index 906cca30..2ed7cc7b 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart @@ -17,6 +17,7 @@ class SwipeToCheckIn extends StatefulWidget { this.isLoading = false, this.isCheckedIn = false, this.isDisabled = false, + this.hasError = false, }); /// Called when the user completes the swipe to check in. @@ -34,6 +35,9 @@ class SwipeToCheckIn extends StatefulWidget { /// Whether the slider is disabled (e.g. geofence blocking). final bool isDisabled; + /// Whether an error occurred during the last action attempt. + final bool hasError; + @override State createState() => _SwipeToCheckInState(); } @@ -54,9 +58,11 @@ class _SwipeToCheckInState extends State _dragValue = 0.0; }); } - // Reset on error: loading finished but check-in state didn't change. - if (oldWidget.isLoading && !widget.isLoading && - widget.isCheckedIn == oldWidget.isCheckedIn && _isComplete) { + // Reset on error: loading finished without state change, or validation error. + if (_isComplete && + widget.isCheckedIn == oldWidget.isCheckedIn && + ((oldWidget.isLoading && !widget.isLoading) || + (!oldWidget.hasError && widget.hasError))) { setState(() { _isComplete = false; _dragValue = 0.0; From f6de07fc258a1752b41a948880a108b0d7bda173 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 14 Mar 2026 20:28:28 -0400 Subject: [PATCH 22/33] refactor(clock_in): rename error handling variable for clarity in check-in interactions --- .claude/agents/mobile-builder.md | 1 + .../strategies/check_in_interaction.dart | 2 +- .../strategies/nfc_check_in_interaction.dart | 2 +- .../swipe_check_in_interaction.dart | 4 +- .../widgets/clock_in_action_section.dart | 37 ++++++++++--------- .../presentation/widgets/clock_in_body.dart | 2 +- .../widgets/swipe_to_check_in.dart | 6 +-- 7 files changed, 28 insertions(+), 26 deletions(-) diff --git a/.claude/agents/mobile-builder.md b/.claude/agents/mobile-builder.md index cd55655b..3d9009e0 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: +- **Use `package:` imports everywhere inside `lib/`** for consistency and robustness. Use relative imports only in `test/` and `bin/` directories. Example: `import 'package:staff_clock_in/src/presentation/bloc/clock_in/clock_in_bloc.dart';` not `import '../bloc/clock_in/clock_in_bloc.dart';` - 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 diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/check_in_interaction.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/check_in_interaction.dart index 6f218f90..dd0f5bdc 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/check_in_interaction.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/check_in_interaction.dart @@ -16,7 +16,7 @@ abstract class CheckInInteraction { required bool isCheckedIn, required bool isDisabled, required bool isLoading, - required bool hasError, + required bool hasClockinError, required VoidCallback onCheckIn, required VoidCallback onCheckOut, }); diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/nfc_check_in_interaction.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/nfc_check_in_interaction.dart index efa2e0e3..8dc3297d 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/nfc_check_in_interaction.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/nfc_check_in_interaction.dart @@ -21,7 +21,7 @@ class NfcCheckInInteraction implements CheckInInteraction { required bool isCheckedIn, required bool isDisabled, required bool isLoading, - required bool hasError, + required bool hasClockinError, required VoidCallback onCheckIn, required VoidCallback onCheckOut, }) { diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/swipe_check_in_interaction.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/swipe_check_in_interaction.dart index 4e27e0e6..56a6a1ee 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/swipe_check_in_interaction.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/strategies/swipe_check_in_interaction.dart @@ -16,7 +16,7 @@ class SwipeCheckInInteraction implements CheckInInteraction { required bool isCheckedIn, required bool isDisabled, required bool isLoading, - required bool hasError, + required bool hasClockinError, required VoidCallback onCheckIn, required VoidCallback onCheckOut, }) { @@ -24,7 +24,7 @@ class SwipeCheckInInteraction implements CheckInInteraction { isCheckedIn: isCheckedIn, isDisabled: isDisabled, isLoading: isLoading, - hasError: hasError, + hasClockinError: hasClockinError, onCheckIn: onCheckIn, onCheckOut: onCheckOut, ); 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 c1791c0b..bbc2234b 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 @@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_clock_in/src/presentation/widgets/early_check_in_banner.dart'; import '../../domain/validators/clock_in_validation_context.dart'; import '../../domain/validators/validators/time_window_validator.dart'; @@ -34,7 +35,7 @@ class ClockInActionSection extends StatelessWidget { required this.checkOutTime, required this.checkInMode, required this.isActionInProgress, - this.hasError = false, + this.hasClockinError = false, super.key, }); @@ -61,7 +62,7 @@ class ClockInActionSection extends StatelessWidget { final bool isActionInProgress; /// Whether the last action attempt resulted in an error. - final bool hasError; + final bool hasClockinError; /// Resolves the [CheckInInteraction] for the current mode. /// @@ -84,21 +85,21 @@ class ClockInActionSection extends StatelessWidget { /// Builds the action widget for an active (not completed) shift. Widget _buildActiveShiftAction(BuildContext context) { - // if (!isCheckedIn && !_isCheckInAllowed(selectedShift!)) { - // return Column( - // mainAxisSize: MainAxisSize.min, - // children: [ - // const GeofenceStatusBanner(), - // const SizedBox(height: UiConstants.space3), - // EarlyCheckInBanner( - // availabilityTime: _getAvailabilityTimeText( - // selectedShift!, - // context, - // ), - // ), - // ], - // ); - // } + if (!isCheckedIn && !_isCheckInAllowed(selectedShift!)) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const GeofenceStatusBanner(), + const SizedBox(height: UiConstants.space3), + EarlyCheckInBanner( + availabilityTime: _getAvailabilityTimeText( + selectedShift!, + context, + ), + ), + ], + ); + } return BlocBuilder( builder: (BuildContext context, GeofenceState geofenceState) { @@ -122,7 +123,7 @@ class ClockInActionSection extends StatelessWidget { isCheckedIn: isCheckedIn, isDisabled: isGeofenceBlocking, isLoading: isActionInProgress, - hasError: hasError, + hasClockinError: hasClockinError, onCheckIn: () => _handleCheckIn(context), onCheckOut: () => _handleCheckOut(context), ), diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart index 05f1f7cc..c9f6ea50 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart @@ -111,7 +111,7 @@ class _ClockInBodyState extends State { checkInMode: state.checkInMode, isActionInProgress: state.status == ClockInStatus.actionInProgress, - hasError: state.status == ClockInStatus.failure, + hasClockinError: state.status == ClockInStatus.failure, ), // checked-in banner (only when checked in to the selected shift) diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart index 2ed7cc7b..4f0b2fd5 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart @@ -17,7 +17,7 @@ class SwipeToCheckIn extends StatefulWidget { this.isLoading = false, this.isCheckedIn = false, this.isDisabled = false, - this.hasError = false, + this.hasClockinError = false, }); /// Called when the user completes the swipe to check in. @@ -36,7 +36,7 @@ class SwipeToCheckIn extends StatefulWidget { final bool isDisabled; /// Whether an error occurred during the last action attempt. - final bool hasError; + final bool hasClockinError; @override State createState() => _SwipeToCheckInState(); @@ -62,7 +62,7 @@ class _SwipeToCheckInState extends State if (_isComplete && widget.isCheckedIn == oldWidget.isCheckedIn && ((oldWidget.isLoading && !widget.isLoading) || - (!oldWidget.hasError && widget.hasError))) { + (!oldWidget.hasClockinError && widget.hasClockinError))) { setState(() { _isComplete = false; _dragValue = 0.0; From e02de1fb687b17972ffe9271c845d14af6c6d27c Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 14 Mar 2026 20:36:35 -0400 Subject: [PATCH 23/33] feat(clock_in): add early check-out banner and localization support --- .../lib/src/l10n/en.i18n.json | 2 + .../lib/src/l10n/es.i18n.json | 2 + .../widgets/clock_in_action_section.dart | 60 ++++++++++++++++--- .../widgets/early_check_out_banner.dart | 50 ++++++++++++++++ 4 files changed, 106 insertions(+), 8 deletions(-) create mode 100644 apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/early_check_out_banner.dart 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 4c6ad9c3..aabc7d71 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 @@ -856,6 +856,8 @@ "today_shift_badge": "TODAY'S SHIFT", "early_title": "You're early!", "check_in_at": "Check-in available at $time", + "early_checkout_title": "Too early to check out", + "check_out_at": "Check-out available at $time", "shift_completed": "Shift Completed!", "great_work": "Great work today", "no_shifts_today": "No confirmed shifts for today", 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 1651da22..ae607b0a 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 @@ -851,6 +851,8 @@ "today_shift_badge": "TURNO DE HOY", "early_title": "\u00a1Ha llegado temprano!", "check_in_at": "Entrada disponible a las $time", + "early_checkout_title": "Muy temprano para salir", + "check_out_at": "Salida disponible a las $time", "shift_completed": "\u00a1Turno completado!", "great_work": "Buen trabajo hoy", "no_shifts_today": "No hay turnos confirmados para hoy", 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 bbc2234b..1b111d41 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 @@ -6,6 +6,7 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:staff_clock_in/src/presentation/widgets/early_check_in_banner.dart'; +import 'package:staff_clock_in/src/presentation/widgets/early_check_out_banner.dart'; import '../../domain/validators/clock_in_validation_context.dart'; import '../../domain/validators/validators/time_window_validator.dart'; @@ -42,9 +43,9 @@ class ClockInActionSection extends StatelessWidget { /// Available check-in interaction strategies keyed by mode identifier. static const Map _interactions = { - 'swipe': SwipeCheckInInteraction(), - 'nfc': NfcCheckInInteraction(), - }; + 'swipe': SwipeCheckInInteraction(), + 'nfc': NfcCheckInInteraction(), + }; /// The currently selected shift, or null if none is selected. final Shift? selectedShift; @@ -85,6 +86,7 @@ class ClockInActionSection extends StatelessWidget { /// Builds the action widget for an active (not completed) shift. Widget _buildActiveShiftAction(BuildContext context) { + // Show geofence status and time-based availability banners when relevant. if (!isCheckedIn && !_isCheckInAllowed(selectedShift!)) { return Column( mainAxisSize: MainAxisSize.min, @@ -92,7 +94,20 @@ class ClockInActionSection extends StatelessWidget { const GeofenceStatusBanner(), const SizedBox(height: UiConstants.space3), EarlyCheckInBanner( - availabilityTime: _getAvailabilityTimeText( + availabilityTime: _getAvailabilityTimeText(selectedShift!, context), + ), + ], + ); + } + + if (isCheckedIn && !_isCheckOutAllowed(selectedShift!)) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const GeofenceStatusBanner(), + const SizedBox(height: UiConstants.space3), + EarlyCheckOutBanner( + availabilityTime: _getCheckOutAvailabilityTimeText( selectedShift!, context, ), @@ -139,8 +154,9 @@ class ClockInActionSection extends StatelessWidget { final GeofenceState geofenceState = ReadContext( context, ).read().state; - final TranslationsStaffClockInGeofenceEn geofenceI18n = - Translations.of(context).staff.clock_in.geofence; + final TranslationsStaffClockInGeofenceEn geofenceI18n = Translations.of( + context, + ).staff.clock_in.geofence; ReadContext(context).read().add( CheckInRequested( @@ -167,6 +183,33 @@ class ClockInActionSection extends StatelessWidget { return const TimeWindowValidator().validate(validationContext).isValid; } + /// Whether the user is allowed to check out for the given [shift]. + /// + /// Delegates to [TimeWindowValidator]; returns `true` if the end time + /// cannot be parsed (don't block the user). + bool _isCheckOutAllowed(Shift shift) { + final DateTime? shiftEnd = DateTime.tryParse(shift.endTime); + if (shiftEnd == null) return true; + + final ClockInValidationContext validationContext = ClockInValidationContext( + isCheckingIn: false, + shiftEndTime: shiftEnd, + ); + return const TimeWindowValidator().validate(validationContext).isValid; + } + + /// Returns the formatted earliest check-out time for the given [shift]. + /// + /// Falls back to the localized "soon" label when the end time cannot + /// be parsed. + String _getCheckOutAvailabilityTimeText(Shift shift, BuildContext context) { + final DateTime? shiftEnd = DateTime.tryParse(shift.endTime.trim()); + if (shiftEnd != null) { + return TimeWindowValidator.getAvailabilityTime(shiftEnd); + } + return Translations.of(context).staff.clock_in.soon; + } + /// Returns the formatted earliest check-in time for the given [shift]. /// /// Falls back to the localized "soon" label when the start time cannot @@ -181,8 +224,9 @@ class ClockInActionSection extends StatelessWidget { /// Triggers the check-out flow via the lunch-break confirmation dialog. void _handleCheckOut(BuildContext context) { - final TranslationsStaffClockInGeofenceEn geofenceI18n = - Translations.of(context).staff.clock_in.geofence; + final TranslationsStaffClockInGeofenceEn geofenceI18n = Translations.of( + context, + ).staff.clock_in.geofence; showDialog( context: context, diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/early_check_out_banner.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/early_check_out_banner.dart new file mode 100644 index 00000000..eda5272a --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/early_check_out_banner.dart @@ -0,0 +1,50 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Banner shown when the user tries to check out too early. +/// +/// Displays a clock icon and a message indicating when check-out +/// will become available. +class EarlyCheckOutBanner extends StatelessWidget { + /// Creates an early check-out banner. + const EarlyCheckOutBanner({ + required this.availabilityTime, + super.key, + }); + + /// Formatted time string when check-out becomes available (e.g. "4:45 PM"). + final String availabilityTime; + + @override + Widget build(BuildContext context) { + final TranslationsStaffClockInEn i18n = Translations.of( + context, + ).staff.clock_in; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(UiConstants.space6), + decoration: BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: UiConstants.radiusLg, + ), + child: Column( + children: [ + const Icon(UiIcons.clock, size: 48, color: UiColors.iconThird), + const SizedBox(height: UiConstants.space4), + Text( + i18n.early_checkout_title, + style: UiTypography.body1m.textSecondary, + ), + const SizedBox(height: UiConstants.space1), + Text( + i18n.check_out_at(time: availabilityTime), + style: UiTypography.body2r.textSecondary, + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} From 29adf60b8a9ec70e57cef7d2463ba4f71c8e8362 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 15 Mar 2026 00:27:34 -0400 Subject: [PATCH 24/33] feat(specification): update clock-in process with time-window enforcement and location verification details --- .claude/skills/krow-paper-design/SKILL.md | 32 ++- docs/DESIGN/product-specification.md | 273 ++++++++++++++-------- 2 files changed, 209 insertions(+), 96 deletions(-) diff --git a/.claude/skills/krow-paper-design/SKILL.md b/.claude/skills/krow-paper-design/SKILL.md index 93860546..b4f56157 100644 --- a/.claude/skills/krow-paper-design/SKILL.md +++ b/.claude/skills/krow-paper-design/SKILL.md @@ -190,10 +190,22 @@ All chips: border 1.5px, text Manrope 14px/600, gap 8px for icon+text - Active (filled): bg `#0A39DF`, radius 8px, padding 6px/12px - Text Manrope 12px/600 `#FFFFFF` -**Status Badges:** +**XSmall (Status Chips):** +- For inline status indicators on list rows, section overviews, and cards +- Height: ~20px, padding: 3px/8px, no border +- Text: Manrope 11px/700, uppercase, letter-spacing 0.03-0.04em +- Variants: + - Required/Pending: bg `#FEF9EE`, text `#D97706`, radius 6px + - Active/Complete: bg `#ECFDF5`, text `#059669`, radius 6px + - Confirmed/Info: bg `#E9F0FF`, text `#0A39DF`, radius 6px + - Error/Rejected: bg `#FEF2F2`, text `#F04444`, radius 6px + - Neutral/Disabled: bg `#F1F3F5`, text `#94A3B8`, radius 6px + +**Status Badges (legacy):** - Radius: 8px, padding: 4px/8px - Text: Manrope 11px/600-700, uppercase, letter-spacing 0.04em - Colors follow semantic badge table above +- Prefer XSmall Chips for new designs ### Text Inputs @@ -247,6 +259,24 @@ All chips: border 1.5px, text Manrope 14px/600, gap 8px for icon+text - Value: Inter Tight 20px/700 `#121826` - Layout: flex row, equal width columns, gap 8px +### Notice Banners + +Contextual banners for alerts, warnings, and informational notices. Used in forms, review screens, and detail pages. + +- Container: radius 10px, padding 14px, gap 6px, flex column +- Icon + Title row: flex row, gap 8-10px, align center +- Icon: 18×18 SVG, same color as text +- Title: Manrope 14px/600, line-height 18px +- Body: Manrope 12px/400, line-height 18px + +**Variants:** +| Variant | Background | Color | Title Weight | Icon | +|---------|-----------|-------|-------------|------| +| Error | `#FEF2F2` | `#F04444` | 600 | ⊗ (circle-x) | +| Warning | `#FEF9EE` | `#E6A817` | 600 | △ (triangle-alert) | +| Info/Notice | `#E9F0FF` | `#0A39DF` | 600 | ⓘ (circle-info) | +| Success | `#ECFDF5` | `#059669` | 600 | ✓ (circle-check) | + ### Contact/Info Rows - Container: radius 12px, border 0.5px `#D1D5DB`, background `#FFFFFF`, overflow clip diff --git a/docs/DESIGN/product-specification.md b/docs/DESIGN/product-specification.md index 0163fa86..fd7e5515 100644 --- a/docs/DESIGN/product-specification.md +++ b/docs/DESIGN/product-specification.md @@ -6,7 +6,7 @@ ## Document Information **Version**: 1.0 -**Last Updated**: March 9, 2026 +**Last Updated**: March 14, 2026 **Purpose**: This document describes the functional behavior and user experience of KROW's mobile workforce management platform from a design perspective. --- @@ -1575,130 +1575,213 @@ Provide workers with a personalized dashboard showing shift summaries, recommend ## Staff: Clock In Out ### Purpose -Track worker attendance with location verification. Workers can check in and out of shifts, log break times, and enable commute tracking. +Track worker attendance with time-window enforcement and location verification. Workers view their scheduled shifts, check in using a swipe or NFC gesture, record break details during check-out, and can override location requirements with a written justification when GPS is unavailable. ### User Stories -#### Story 1: Check In to Shift with Location Verification +#### Story 1: View and Select Today's Shift **As a** worker -**I want to** register my arrival to a shift with automatic location verification \n**So that** I confirm my presence and initiate time tracking +**I want to** see my scheduled shifts for today and select which one I'm attending +**So that** I can clock in to the correct shift + +**Task Flow:** +1. Worker accesses the attendance tracking area +2. System loads the worker's shifts for the selected date (defaults to today) +3. Worker can navigate through a date strip spanning 3 days in the past, today, and 3 days ahead; only today is interactive +4. Each shift in the list shows: + - Role or position title + - Client name and venue location + - Scheduled start and end times + - Hourly rate +5. If already clocked in, the active shift is automatically highlighted +6. Otherwise, the most recent shift in the list is selected by default +7. Worker selects a shift to begin time-window and location verification for that venue + +**Information Required:** +- Shift selection (when multiple shifts are scheduled for the day) + +**Information Provided to User:** +- All shifts scheduled for today +- Which dates in the strip have shifts scheduled +- Which shift is currently active or selected + +**Edge Cases:** +- No shifts today: Message "No shifts scheduled today" with access to the shift marketplace +- Single shift: Auto-selected, no manual selection needed + +--- + +#### Story 2: Check In to Shift +**As a** worker +**I want to** register my arrival at a shift within the allowed time window +**So that** my work time is tracked from the moment I arrive ```mermaid graph TD - A[Start: Access Clock In] --> B[Load Today's Shifts] - B --> C{Multiple Shifts?} - C -->|Yes| D[Select Specific Shift] - C -->|No| E[Shift Auto-Selected] - D --> F[Request Location Permission] - E --> F - F --> G{Permission Granted?} - G -->|No| H[Error: Location Required] - G -->|Yes| I[Acquire Current Location] - I --> J[Calculate Distance from Venue] - J --> K{Within 500m?} - K -->|No| L[Warning: Too Far from Venue] - K -->|Yes| M[Enable Check-In] - M --> N{Confirmation Method?} - N -->|Swipe| O[Swipe Gesture Confirmation] - N -->|Action| P[Direct Confirmation] - O --> Q[Optional: Provide Check-In Notes] + A[Select Shift] --> B{Within Check-In Window?} + B -->|Too Early| C[Notice: Shows Time When Check-In Opens] + B -->|Window Open| D{GPS Permission?} + D -->|Denied| E[Permission Denied — Override Available] + D -->|Permanently Denied| F[Must Enable in Device Settings — Override Available] + D -->|Granted| G{GPS Service Enabled?} + G -->|Off| H[GPS Off — Override Available] + G -->|On| I[Acquiring Location — 30s Window] + I -->|Within 500m| J[Location Verified] + I -->|Outside 500m| K[Outside Geofence — Override Available] + I -->|Timeout| L[GPS Timeout — Retry or Override] + E --> M{Override with Justification?} + F --> M + H --> M + K --> M + L --> M + M -->|Submit Justification| J + M -->|Retry GPS| I + J --> N{Confirm Method?} + N -->|Swipe| O[Complete Swipe Gesture] + N -->|NFC| P[Complete NFC Tap] + O --> Q[Check-In Submitted] P --> Q - Q --> R[Submit Check-In] - R --> S[Success: Arrival Registered] - S --> T[Display Check-Out Capability
Show Break Logging
Show Commute Tracking] + Q --> R[Check-In Confirmed — Background Location Tracking Begins] ``` **Task Flow:** -1. User accesses attendance tracking area -2. System loads today's scheduled shifts -3. Shift selection:\n - If multiple shifts scheduled: User selects desired shift\n - If single shift: System auto-selects\n4. System requests location access permission (if not previously granted)\n5. User grants location access\n6. System acquires user's current geographical position\n7. System calculates distance from designated shift venue\n8. If within 500 meter radius:\n - Check-in capability becomes available\n - Distance information displayed (e.g., \"120m away\")\n9. User can register arrival via two methods:\n - **Gesture confirmation**: Swipe action across designated area\n - **Direct confirmation**: Direct action submission\n10. Optional notes interface appears (user can provide additional information or skip)\n11. User confirms arrival registration\n12. System confirms successful check-in: \"Checked in to [Shift Name]\"\n13. Interface updates to show:\n - Check-in timestamp\n - Break logging capability\n - Check-out capability\n - Optional: Commute tracking features\n\n**Information Required:**\n- Location permission (system request)\n- Shift selection (if multiple available)\n- Check-in confirmation (gesture or direct action)\n- Optional arrival notes (text)\n\n**Information Provided to User:**\n- Current distance from venue location\n- Location verification status\n- Check-in confirmation with precise timestamp\n- Updated interface showing departure registration capability\n\n**Edge Cases:**\n- **Location permission denied**: Error message \"Location access required to check in\" with guidance to device settings\n- **Distance exceeds threshold** (>500m): Warning \"You're too far from the venue. Move closer to check in.\" with actual distance displayed\n- **GPS signal unavailable**: Error \"Unable to determine location. Check your connection.\"\n- **Already registered arrival**: Display \"Already checked in at [time]\" with departure registration capability\n- **Incorrect shift selected**: User can modify selection before arrival confirmation\n- **Network connectivity issues**: Queue check-in for submission when connection restored - ---- - -#### Story 2: Log Break Time -**As a** worker -**I want to** record when I take breaks -**So that** my break time is accurately tracked and properly deducted from billable hours - -**Task Flow:** -1. User has registered arrival to shift -2. System displays break logging capability -3. User initiates break period recording -4. System displays running timer tracking break duration -5. User completes break and ends break period recording -6. System records total break duration -7. Optional: User can categorize break type (lunch, rest, etc.) +1. Worker selects a shift +2. System checks the current time against the shift's scheduled start time +3. If more than 15 minutes before shift start: check-in is not yet available; the time when it opens is displayed +4. Once within the check-in window, location verification begins automatically +5. System requests GPS permission if not already granted +6. GPS attempts to acquire the worker's current location (up to 30-second window) +7. System calculates distance from the shift's venue coordinates +8. If within 500 metres: check-in is enabled +9. If location cannot be verified (outside geofence, GPS timeout, permission issues): worker may provide a written justification to override — see Story 3 +10. With location verified or overridden, worker confirms check-in using one of two methods: + - **Swipe confirmation**: Drag a slider across at least 80% of its range + - **NFC confirmation**: Tap device to the NFC reader at the venue +11. System submits check-in to the backend +12. System confirms check-in with a timestamp +13. Background location tracking begins automatically for the duration of the shift **Information Required:** -- Break start (user-initiated) -- Break end (user-initiated) -- Optional: Break type classification +- GPS permission (system request) +- Shift selection (if multiple shifts today) +- Check-in confirmation (swipe gesture or NFC tap) **Information Provided to User:** -- Active break timer display -- Total break time recorded -- Confirmation of break logging +- Time window status: how long until check-in opens, or confirmation that it is open +- Location verification status and current distance from venue +- Check-in confirmation with exact timestamp +- Confirmation that background location tracking is now active **Edge Cases:** -- Forgot to end break: Capability to manually adjust break duration -- Multiple breaks: System tracks each break period independently with cumulative tracking -- System interruption: Break timer continues in background, recovers on re-access +- **Too early to check in**: Check-in unavailable; exact time when it becomes available is shown +- **GPS permission denied**: Worker can open device settings or use justification override +- **GPS permanently denied**: Worker must enable location in device settings; override also available +- **GPS service off**: Worker directed to enable device GPS; override also available +- **Outside geofence**: Distance from venue displayed; override with written justification permitted +- **GPS timeout (30 seconds)**: Worker can retry location check or use justification override +- **Already checked in**: Check-out flow shown instead; prior check-in time displayed --- -#### Story 3: Check Out of Shift +#### Story 3: Override Location Requirement **As a** worker -**I want to** register my departure from a shift -**So that** my work time is fully recorded for compensation +**I want to** clock in even when location verification cannot be completed +**So that** my attendance is recorded despite GPS issues beyond my control **Task Flow:** -1. User has registered arrival and completed work -2. User initiates departure registration -3. Optional notes interface appears -4. User provides additional information (if desired) or skips -5. User confirms departure -6. System verifies location again (same 500m proximity requirement) -7. System records departure timestamp -8. System calculates total work time (arrival - departure minus breaks) -9. System presents work summary displaying: - - Arrival time - - Departure time - - Total hours worked - - Break time deducted - - Estimated compensation (if available) - -**Information Required:**\n- Departure confirmation\n- Optional departure notes (text)\n- Location verification\n\n**Information Provided to User:**\n- Departure confirmation with precise timestamp\n- Comprehensive work summary (hours worked, breaks taken, estimated pay)\n- Complete time tracking information\n\n**Edge Cases:**\n- Departure distance exceeds venue threshold: Warning message but may allow with approval workflow\n- Forgot to register departure: Supervisor manual adjustment capability or automatic departure at scheduled shift end\n- Early departure: Warning \"Shift not yet complete. Confirm early check-out?\" with acknowledgment required\n- Network issues: Queue departure registration for submission when connected - ---- - -#### Story 4: Enable Commute Tracking -**As a** worker -**I want to** enable commute tracking -**So that** clients can monitor my estimated arrival time - -**Task Flow:** -1. After registering shift arrival, user sees commute tracking capability -2. User enables commute tracking -3. System begins continuous location monitoring -4. System calculates estimated time of arrival to venue -5. ETA information displayed to user and visible to client -6. System provides real-time updates of distance and ETA -7. When user proximity reaches venue (distance < 50m), system automatically disables commute mode +1. Location verification fails or cannot complete (permission denied, GPS off, outside 500m, or 30-second timeout) +2. System presents the reason for the location issue and an option to proceed without verification +3. Worker requests to proceed without location verification +4. System presents a written justification form +5. Worker provides a written explanation of why location verification is not possible +6. Justification must be non-empty before submission is allowed +7. Worker submits justification +8. System marks the attendance record as location-overridden and stores the justification note +9. Check-in confirmation (swipe or NFC) becomes available +10. Worker proceeds with normal check-in flow **Information Required:** -- Commute tracking preference (enabled/disabled) -- Continuous location updates +- Written justification (required; cannot be empty) **Information Provided to User:** +- Explanation of the location issue encountered +- Confirmation that the override justification has been recorded +- Confirmation that check-in can now proceed + +**Edge Cases:** +- Empty justification: Submission is prevented until text is provided +- Justification is stored alongside the attendance record for administrative review + +--- + +#### Story 4: Check Out with Break Details +**As a** worker +**I want to** record my departure and any break taken during my shift +**So that** my total worked hours and compensation are calculated accurately + +**Task Flow:** +1. Worker has previously checked in and is within the check-out time window (within 15 minutes of shift end) +2. If attempting to check out too early: check-out is not yet available; the time when it opens is displayed +3. Same location verification and override rules apply as check-in (Story 2 and Story 3) +4. Worker confirms check-out using swipe or NFC (same methods as check-in) +5. System presents a multi-step break details form: + - **Step 1**: Did you take a lunch break? (Yes / No) + - **Step 2a — if Yes**: Select break start time and break end time (available in 15-minute increments) + - **Step 2b — if No**: Select the reason no break was taken (from a predefined list) + - **Step 3**: Optional additional notes about the shift + - **Step 4**: Review summary of all submitted details +6. Worker confirms the summary +7. System submits check-out and break information to the backend +8. System confirms shift is complete +9. Background location tracking stops automatically + +**Information Required:** +- Check-out confirmation (swipe gesture or NFC tap) +- Break taken: Yes or No +- If Yes: break start time and break end time (15-minute increments) +- If No: reason for no break (selection from predefined options) +- Optional: additional shift notes + +**Information Provided to User:** +- Time window status (when check-out becomes available, or confirmed open) +- Break detail form with step-by-step guidance +- Summary of all submitted information before final confirmation +- Check-out confirmation with exact timestamp +- Shift completion status + +**Edge Cases:** +- **Too early to check out**: Check-out unavailable; exact time when it becomes available is shown +- **Geofence and override rules**: Same location verification and override flow as check-in applies +- **Break time selection**: Times are chosen from 15-minute slot options, not free-text entry +- **Network issue**: Check-out request queued for submission when connection is restored + +--- + +#### Story 5: Track Commute to Shift +**As a** worker +**I want to** share my estimated arrival time before a shift +**So that** the client knows when to expect me + +**Task Flow:** +1. Before a shift begins, the system offers commute tracking +2. Worker consents to sharing commute location data +3. System monitors the worker's distance to the venue and calculates estimated time of arrival +4. ETA information is made visible to the client +5. Worker is shown their current distance to the venue and estimated arrival time +6. When the worker arrives within range of the venue, commute tracking automatically deactivates + +**Information Required:** +- Consent to share commute location + +**Information Provided to User:** +- Current distance to the shift venue - Estimated arrival time (e.g., "Arriving in 12 minutes") -- Distance to venue (e.g., "2.3 km away") -- Real-time progress updates +- Confirmation when commute tracking deactivates upon arrival **Edge Cases:** -- Location tracking interruption: System displays last known position -- Arrival but ETA persisting: Auto-clears when within 50m proximity -- Privacy preference: User can disable tracking at any time -- Route changes: ETA automatically recalculates based on current position +- Location permission required for commute tracking to function +- Worker can withdraw consent and disable commute tracking at any time +- Route changes cause estimated arrival time to automatically recalculate --- From 5c99ddbca7be5c556d7335c3bc03fb0a7e8d9a81 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 15 Mar 2026 21:07:57 -0400 Subject: [PATCH 25/33] feat(SKILL.md): update interaction patterns for modals and long lists with date filters --- .claude/skills/krow-paper-design/SKILL.md | 28 ++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/.claude/skills/krow-paper-design/SKILL.md b/.claude/skills/krow-paper-design/SKILL.md index b4f56157..0f2bca58 100644 --- a/.claude/skills/krow-paper-design/SKILL.md +++ b/.claude/skills/krow-paper-design/SKILL.md @@ -409,7 +409,27 @@ Artboard (390x844, bg #FAFBFC) Bottom CTAs (primary + outline) ``` -## 5. Workflow Rules +## 5. Interaction Patterns + +### Modals → Bottom Sheets +All modal/dialog interactions MUST use bottom sheets, never centered modal dialogs. +- Sheet: white bg, 18px top-left/top-right radius, padding 24px, bottom safe area 34px +- Handle bar: 40px wide, 4px height, `#D1D5DB`, centered, 999px radius, 8px margin-bottom +- Overlay: `rgba(18, 24, 38, 0.55)` scrim behind sheet +- Title: Inter Tight 20px/700, `#121826` +- Subtitle: Manrope 13px/400, `#6A7382` +- Primary CTA: full-width at bottom of sheet +- Dismiss: "Skip" or "Cancel" text link below CTA, or swipe-down gesture + +### Long Lists with Date Filters +When displaying lists with date filtering (e.g., shift history, timecards, payment history): +- Group items by **month** (e.g., "MARCH 2026", "FEBRUARY 2026") +- Month headers use Overline Label style: Manrope 11px/600, uppercase, `#6A7382`, letter-spacing 0.06em +- Gap: 10px below month header to first item, 24px between month groups +- Most recent month first (reverse chronological) +- Date filter at top (chip or dropdown): "Last 30 days", "Last 3 months", "This year", custom range + +## 6. Workflow Rules ### Write Incrementally @@ -447,7 +467,7 @@ When creating matching screens (e.g., two shift detail views): - Use same card/row component patterns - Maintain consistent padding and gap values -## 6. SVG Icon Patterns +## 7. SVG Icon Patterns ### Chevron Left (Back) ```html @@ -500,7 +520,7 @@ When creating matching screens (e.g., two shift detail views): ``` -## 7. Anti-Patterns +## 8. Anti-Patterns ### Colors - Never use `#0F4C81`, `#1A3A5C` (old navy) - use `#0A39DF` (Primary) @@ -521,6 +541,8 @@ When creating matching screens (e.g., two shift detail views): - Never skip review checkpoints after 2-3 modifications - Never create frames without following the naming convention - Never use `justifyContent: space-between` on artboards with many direct children - use `marginTop: auto` on the CTA instead +- Never use centered modal dialogs — always use bottom sheets for modal interactions +- Never show long date-filtered lists without grouping by month ## Summary From 86335dd177afdc85d578ccc64772d1e988aa00da Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 15 Mar 2026 21:42:12 -0400 Subject: [PATCH 26/33] feat(SKILL.md): add shift card design specifications and status chip variants --- .claude/skills/krow-paper-design/SKILL.md | 38 +++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/.claude/skills/krow-paper-design/SKILL.md b/.claude/skills/krow-paper-design/SKILL.md index 0f2bca58..f7c92a61 100644 --- a/.claude/skills/krow-paper-design/SKILL.md +++ b/.claude/skills/krow-paper-design/SKILL.md @@ -285,6 +285,44 @@ Contextual banners for alerts, warnings, and informational notices. Used in form - Label: Manrope 13px/500 `#6A7382`, width 72px fixed - Value: Manrope 13px/500 `#121826` (or `#0A39DF` for phone/links) +### Shift Cards + +Two variants for displaying shifts in lists. Cards are grouped under month headers. + +**Common card container:** +- Background: `#FFFFFF`, border: 0.5px `#D1D5DB`, radius: 12px, padding: 16px, gap: 12px + +**Header row** (top of card): +- Layout: flex row, space-between +- Left side: Role title + Venue subtitle (stacked) + - Role: Inter Tight 16px/600 `#121826` (primary — always most prominent) + - Venue: Manrope 13px/400 `#6A7382` +- Right side: XSmall status chip (flex-shrink 0) + +**Details row** (bottom of card): +- Layout: flex row, space-between, align start +- Left column (flex column, gap 6px): date, time, location — each as icon (16px `#6A7382`) + text (Manrope 13px/500-600 `#6A7382`) row with 6px gap +- Right column (earnings — only in Variant 1) + +**Variant 1 — With Earnings (Completed shifts):** +- Right side shows earnings, right-aligned: + - Amount: Inter Tight 14px/600 `#121826` (e.g., "$192.00") + - Rate below: Manrope 13px/500 `#6A7382` (e.g., "6 hrs · $32/hr") + +**Variant 2 — Without Earnings (Cancelled, No-Show, Upcoming):** +- No right-side earnings section — details row takes full width + +**Status chip variants on shift cards:** +| Status | Background | Text | +|--------|-----------|------| +| Confirmed | `#E9F0FF` | `#0A39DF` | +| Active | `#ECFDF5` | `#059669` | +| Pending | `#FEF9EE` | `#D97706` | +| Completed | `#ECFDF5` | `#059669` | +| Swap Requested | `#FEF9EE` | `#D97706` | +| No-Show | `#FEF2F2` | `#F04444` | +| Cancelled | `#F1F3F5` | `#6A7382` | + ### Section Headers - Text: Manrope 11px/600, uppercase, letter-spacing 0.06em, color `#6A7382` From 5fd2a44a8bda88ae3a455528a1b6d14099b9d330 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Mar 2026 01:27:15 -0400 Subject: [PATCH 27/33] feat: Add localization for hourly rate and enhance geofence notifications - Added localization for hourly rate in English and Spanish. - Updated background geofence service to use localized notification titles and bodies. - Enhanced ClockInBloc to compute time window flags for check-in/check-out availability. - Updated ClockInState and ClockInEvent to include check-in/check-out availability flags and messages. - Refactored ClockInActionSection to display availability messages based on computed flags. - Ensured compliance with design system for user-facing strings and notification messages. --- ...wer.md => mobile-architecture-reviewer.md} | 0 .../lib/src/l10n/en.i18n.json | 1 + .../lib/src/l10n/es.i18n.json | 1 + .../services/background_geofence_service.dart | 19 +++- .../bloc/clock_in/clock_in_bloc.dart | 95 ++++++++++++++++++- .../bloc/clock_in/clock_in_event.dart | 10 ++ .../bloc/clock_in/clock_in_state.dart | 30 ++++++ .../bloc/geofence/geofence_bloc.dart | 2 + .../bloc/geofence/geofence_event.dart | 21 +++- .../widgets/clock_in_action_section.dart | 87 +++++------------ .../presentation/widgets/clock_in_body.dart | 4 + .../src/presentation/widgets/shift_card.dart | 6 +- 12 files changed, 203 insertions(+), 73 deletions(-) rename .claude/agents/{architecture-reviewer.md => mobile-architecture-reviewer.md} (100%) diff --git a/.claude/agents/architecture-reviewer.md b/.claude/agents/mobile-architecture-reviewer.md similarity index 100% rename from .claude/agents/architecture-reviewer.md rename to .claude/agents/mobile-architecture-reviewer.md 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 aabc7d71..f4693848 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 @@ -862,6 +862,7 @@ "great_work": "Great work today", "no_shifts_today": "No confirmed shifts for today", "accept_shift_cta": "Accept a shift to clock in", + "per_hr": "\\$$amount/hr", "soon": "soon", "checked_in_at_label": "Checked in at", "not_in_range": "You must be within $distance m to clock in.", 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 ae607b0a..006e3dec 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 @@ -857,6 +857,7 @@ "great_work": "Buen trabajo hoy", "no_shifts_today": "No hay turnos confirmados para hoy", "accept_shift_cta": "Acepte un turno para registrar su entrada", + "per_hr": "\\$$amount/hr", "soon": "pronto", "checked_in_at_label": "Entrada registrada a las", "nfc_dialog": { diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart index 108b12f6..f13360c7 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart @@ -1,6 +1,6 @@ // ignore_for_file: avoid_print import 'package:krow_core/core.dart'; -import 'package:krow_domain/src/core/models/device_location.dart'; +import 'package:krow_domain/krow_domain.dart'; /// Top-level callback dispatcher for background geofence tasks. /// @@ -63,13 +63,17 @@ void backgroundGeofenceDispatcher() { 'showing notification', ); + final String title = inputData?['leftGeofenceTitle'] as String? ?? + "You've Left the Workplace"; + final String body = inputData?['leftGeofenceBody'] as String? ?? + 'You appear to be more than 500m from your shift location.'; + final NotificationService notificationService = NotificationService(); await notificationService.showNotification( id: BackgroundGeofenceService.leftGeofenceNotificationId, - title: "You've Left the Workplace", - body: - 'You appear to be more than 500m from your shift location.', + title: title, + body: body, ); } else { print( @@ -138,11 +142,14 @@ class BackgroundGeofenceService { /// Starts periodic 15-minute background geofence checks. /// /// Called after a successful clock-in. Persists the target coordinates - /// so the background isolate can access them. + /// and passes localized notification strings via [inputData] so the + /// background isolate can display them without DI. Future startBackgroundTracking({ required double targetLat, required double targetLng, required String shiftId, + required String leftGeofenceTitle, + required String leftGeofenceBody, }) async { await Future.wait(>[ _storageService.setDouble(_keyTargetLat, targetLat), @@ -159,6 +166,8 @@ class BackgroundGeofenceService { 'targetLat': targetLat, 'targetLng': targetLng, 'shiftId': shiftId, + 'leftGeofenceTitle': leftGeofenceTitle, + 'leftGeofenceBody': leftGeofenceBody, }, ); } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart index b5532656..7b84b7df 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart @@ -11,6 +11,7 @@ import '../../../domain/usecases/get_todays_shift_usecase.dart'; import '../../../domain/validators/clock_in_validation_context.dart'; import '../../../domain/validators/clock_in_validation_result.dart'; import '../../../domain/validators/validators/composite_clock_in_validator.dart'; +import '../../../domain/validators/validators/time_window_validator.dart'; import '../geofence/geofence_bloc.dart'; import '../geofence/geofence_event.dart'; import '../geofence/geofence_state.dart'; @@ -86,11 +87,19 @@ class ClockInBloc extends Bloc selectedShift ??= shifts.last; } + final _TimeWindowFlags timeFlags = _computeTimeWindowFlags( + selectedShift, + ); + emit(state.copyWith( status: ClockInStatus.success, todayShifts: shifts, selectedShift: selectedShift, attendance: status, + isCheckInAllowed: timeFlags.isCheckInAllowed, + isCheckOutAllowed: timeFlags.isCheckOutAllowed, + checkInAvailabilityTime: timeFlags.checkInAvailabilityTime, + checkOutAvailabilityTime: timeFlags.checkOutAvailabilityTime, )); }, onError: (String errorKey) => state.copyWith( @@ -100,12 +109,19 @@ class ClockInBloc extends Bloc ); } - /// Updates the currently selected shift. + /// Updates the currently selected shift and recomputes time window flags. void _onShiftSelected( ShiftSelected event, Emitter emit, ) { - emit(state.copyWith(selectedShift: event.shift)); + final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(event.shift); + emit(state.copyWith( + selectedShift: event.shift, + isCheckInAllowed: timeFlags.isCheckInAllowed, + isCheckOutAllowed: timeFlags.isCheckOutAllowed, + checkInAvailabilityTime: timeFlags.checkInAvailabilityTime, + checkOutAvailabilityTime: timeFlags.checkOutAvailabilityTime, + )); } /// Updates the selected date for shift viewing. @@ -236,6 +252,56 @@ class ClockInBloc extends Bloc return DateTime.tryParse(value); } + /// Computes time-window check-in/check-out flags for the given [shift]. + /// + /// Uses [TimeWindowValidator] so this business logic stays out of widgets. + static _TimeWindowFlags _computeTimeWindowFlags(Shift? shift) { + if (shift == null) { + return const _TimeWindowFlags(); + } + + const TimeWindowValidator validator = TimeWindowValidator(); + final DateTime? shiftStart = _tryParseDateTime(shift.startTime); + final DateTime? shiftEnd = _tryParseDateTime(shift.endTime); + + // Check-in window. + bool isCheckInAllowed = true; + String? checkInAvailabilityTime; + if (shiftStart != null) { + final ClockInValidationContext checkInCtx = ClockInValidationContext( + isCheckingIn: true, + shiftStartTime: shiftStart, + ); + isCheckInAllowed = validator.validate(checkInCtx).isValid; + if (!isCheckInAllowed) { + checkInAvailabilityTime = + TimeWindowValidator.getAvailabilityTime(shiftStart); + } + } + + // Check-out window. + bool isCheckOutAllowed = true; + String? checkOutAvailabilityTime; + if (shiftEnd != null) { + final ClockInValidationContext checkOutCtx = ClockInValidationContext( + isCheckingIn: false, + shiftEndTime: shiftEnd, + ); + isCheckOutAllowed = validator.validate(checkOutCtx).isValid; + if (!isCheckOutAllowed) { + checkOutAvailabilityTime = + TimeWindowValidator.getAvailabilityTime(shiftEnd); + } + } + + return _TimeWindowFlags( + isCheckInAllowed: isCheckInAllowed, + isCheckOutAllowed: isCheckOutAllowed, + checkInAvailabilityTime: checkInAvailabilityTime, + checkOutAvailabilityTime: checkOutAvailabilityTime, + ); + } + /// Dispatches [BackgroundTrackingStarted] to [_geofenceBloc] if the /// geofence has target coordinates. void _dispatchBackgroundTrackingStarted({ @@ -254,8 +320,33 @@ class ClockInBloc extends Bloc targetLng: geofenceState.targetLng!, greetingTitle: event.clockInGreetingTitle, greetingBody: event.clockInGreetingBody, + leftGeofenceTitle: event.leftGeofenceTitle, + leftGeofenceBody: event.leftGeofenceBody, ), ); } } } + +/// Internal value holder for time-window computation results. +class _TimeWindowFlags { + /// Creates a [_TimeWindowFlags] with default allowed values. + const _TimeWindowFlags({ + this.isCheckInAllowed = true, + this.isCheckOutAllowed = true, + this.checkInAvailabilityTime, + this.checkOutAvailabilityTime, + }); + + /// Whether the time window currently allows check-in. + final bool isCheckInAllowed; + + /// Whether the time window currently allows check-out. + final bool isCheckOutAllowed; + + /// Formatted time when check-in becomes available, or `null`. + final String? checkInAvailabilityTime; + + /// Formatted time when check-out becomes available, or `null`. + final String? checkOutAvailabilityTime; +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart index d8c30eb1..27c2aa44 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart @@ -45,6 +45,8 @@ class CheckInRequested extends ClockInEvent { this.notes, this.clockInGreetingTitle = '', this.clockInGreetingBody = '', + this.leftGeofenceTitle = '', + this.leftGeofenceBody = '', }); /// The ID of the shift to clock into. @@ -59,12 +61,20 @@ class CheckInRequested extends ClockInEvent { /// Localized body for the clock-in greeting notification. final String clockInGreetingBody; + /// Localized title for the left-geofence background notification. + final String leftGeofenceTitle; + + /// Localized body for the left-geofence background notification. + final String leftGeofenceBody; + @override List get props => [ shiftId, notes, clockInGreetingTitle, clockInGreetingBody, + leftGeofenceTitle, + leftGeofenceBody, ]; } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_state.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_state.dart index 3e69fd50..3febeaf2 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_state.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_state.dart @@ -18,6 +18,10 @@ class ClockInState extends Equatable { required this.selectedDate, this.checkInMode = 'swipe', this.errorMessage, + this.isCheckInAllowed = true, + this.isCheckOutAllowed = true, + this.checkInAvailabilityTime, + this.checkOutAvailabilityTime, }); /// Current page status. @@ -41,6 +45,18 @@ class ClockInState extends Equatable { /// Error message key for displaying failures. final String? errorMessage; + /// Whether the time window allows the user to check in. + final bool isCheckInAllowed; + + /// Whether the time window allows the user to check out. + final bool isCheckOutAllowed; + + /// Formatted earliest time when check-in becomes available, or `null`. + final String? checkInAvailabilityTime; + + /// Formatted earliest time when check-out becomes available, or `null`. + final String? checkOutAvailabilityTime; + /// Creates a copy of this state with the given fields replaced. ClockInState copyWith({ ClockInStatus? status, @@ -50,6 +66,10 @@ class ClockInState extends Equatable { DateTime? selectedDate, String? checkInMode, String? errorMessage, + bool? isCheckInAllowed, + bool? isCheckOutAllowed, + String? checkInAvailabilityTime, + String? checkOutAvailabilityTime, }) { return ClockInState( status: status ?? this.status, @@ -59,6 +79,12 @@ class ClockInState extends Equatable { selectedDate: selectedDate ?? this.selectedDate, checkInMode: checkInMode ?? this.checkInMode, errorMessage: errorMessage, + isCheckInAllowed: isCheckInAllowed ?? this.isCheckInAllowed, + isCheckOutAllowed: isCheckOutAllowed ?? this.isCheckOutAllowed, + checkInAvailabilityTime: + checkInAvailabilityTime ?? this.checkInAvailabilityTime, + checkOutAvailabilityTime: + checkOutAvailabilityTime ?? this.checkOutAvailabilityTime, ); } @@ -71,5 +97,9 @@ class ClockInState extends Equatable { selectedDate, checkInMode, errorMessage, + isCheckInAllowed, + isCheckOutAllowed, + checkInAvailabilityTime, + checkOutAvailabilityTime, ]; } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart index e2db2f73..5290a008 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart @@ -213,6 +213,8 @@ class GeofenceBloc extends Bloc targetLat: event.targetLat, targetLng: event.targetLng, shiftId: event.shiftId, + leftGeofenceTitle: event.leftGeofenceTitle, + leftGeofenceBody: event.leftGeofenceBody, ); // Show greeting notification using localized strings from the UI. diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart index c5f68a60..980d5c5d 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart @@ -71,6 +71,8 @@ class BackgroundTrackingStarted extends GeofenceEvent { required this.targetLng, required this.greetingTitle, required this.greetingBody, + required this.leftGeofenceTitle, + required this.leftGeofenceBody, }); /// The shift ID being tracked. @@ -88,9 +90,24 @@ class BackgroundTrackingStarted extends GeofenceEvent { /// Localized greeting notification body passed from the UI layer. final String greetingBody; + /// Localized title for the left-geofence notification, persisted to storage + /// for the background isolate. + final String leftGeofenceTitle; + + /// Localized body for the left-geofence notification, persisted to storage + /// for the background isolate. + final String leftGeofenceBody; + @override - List get props => - [shiftId, targetLat, targetLng, greetingTitle, greetingBody]; + List get props => [ + shiftId, + targetLat, + targetLng, + greetingTitle, + greetingBody, + leftGeofenceTitle, + leftGeofenceBody, + ]; } /// Stops background tracking after clock-out. 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 1b111d41..2a34900a 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 @@ -8,8 +8,6 @@ import 'package:krow_domain/krow_domain.dart'; import 'package:staff_clock_in/src/presentation/widgets/early_check_in_banner.dart'; import 'package:staff_clock_in/src/presentation/widgets/early_check_out_banner.dart'; -import '../../domain/validators/clock_in_validation_context.dart'; -import '../../domain/validators/validators/time_window_validator.dart'; import '../bloc/clock_in/clock_in_bloc.dart'; import '../bloc/clock_in/clock_in_event.dart'; import '../bloc/geofence/geofence_bloc.dart'; @@ -37,6 +35,10 @@ class ClockInActionSection extends StatelessWidget { required this.checkInMode, required this.isActionInProgress, this.hasClockinError = false, + this.isCheckInAllowed = true, + this.isCheckOutAllowed = true, + this.checkInAvailabilityTime, + this.checkOutAvailabilityTime, super.key, }); @@ -65,6 +67,18 @@ class ClockInActionSection extends StatelessWidget { /// Whether the last action attempt resulted in an error. final bool hasClockinError; + /// Whether the time window allows check-in, computed by the BLoC. + final bool isCheckInAllowed; + + /// Whether the time window allows check-out, computed by the BLoC. + final bool isCheckOutAllowed; + + /// Formatted earliest time when check-in becomes available, or `null`. + final String? checkInAvailabilityTime; + + /// Formatted earliest time when check-out becomes available, or `null`. + final String? checkOutAvailabilityTime; + /// Resolves the [CheckInInteraction] for the current mode. /// /// Falls back to [SwipeCheckInInteraction] if the mode is unrecognized. @@ -86,31 +100,30 @@ class ClockInActionSection extends StatelessWidget { /// Builds the action widget for an active (not completed) shift. Widget _buildActiveShiftAction(BuildContext context) { + final String soonLabel = Translations.of(context).staff.clock_in.soon; + // Show geofence status and time-based availability banners when relevant. - if (!isCheckedIn && !_isCheckInAllowed(selectedShift!)) { + if (!isCheckedIn && !isCheckInAllowed) { return Column( mainAxisSize: MainAxisSize.min, children: [ const GeofenceStatusBanner(), const SizedBox(height: UiConstants.space3), EarlyCheckInBanner( - availabilityTime: _getAvailabilityTimeText(selectedShift!, context), + availabilityTime: checkInAvailabilityTime ?? soonLabel, ), ], ); } - if (isCheckedIn && !_isCheckOutAllowed(selectedShift!)) { + if (isCheckedIn && !isCheckOutAllowed) { return Column( mainAxisSize: MainAxisSize.min, children: [ const GeofenceStatusBanner(), const SizedBox(height: UiConstants.space3), EarlyCheckOutBanner( - availabilityTime: _getCheckOutAvailabilityTimeText( - selectedShift!, - context, - ), + availabilityTime: checkOutAvailabilityTime ?? soonLabel, ), ], ); @@ -164,64 +177,12 @@ class ClockInActionSection extends StatelessWidget { notes: geofenceState.overrideNotes, clockInGreetingTitle: geofenceI18n.clock_in_greeting_title, clockInGreetingBody: geofenceI18n.clock_in_greeting_body, + leftGeofenceTitle: geofenceI18n.background_left_title, + leftGeofenceBody: geofenceI18n.background_left_body, ), ); } - /// Whether the user is allowed to check in for the given [shift]. - /// - /// Delegates to [TimeWindowValidator]; returns `true` if the start time - /// cannot be parsed (don't block the user). - bool _isCheckInAllowed(Shift shift) { - final DateTime? shiftStart = DateTime.tryParse(shift.startTime); - if (shiftStart == null) return true; - - final ClockInValidationContext validationContext = ClockInValidationContext( - isCheckingIn: true, - shiftStartTime: shiftStart, - ); - return const TimeWindowValidator().validate(validationContext).isValid; - } - - /// Whether the user is allowed to check out for the given [shift]. - /// - /// Delegates to [TimeWindowValidator]; returns `true` if the end time - /// cannot be parsed (don't block the user). - bool _isCheckOutAllowed(Shift shift) { - final DateTime? shiftEnd = DateTime.tryParse(shift.endTime); - if (shiftEnd == null) return true; - - final ClockInValidationContext validationContext = ClockInValidationContext( - isCheckingIn: false, - shiftEndTime: shiftEnd, - ); - return const TimeWindowValidator().validate(validationContext).isValid; - } - - /// Returns the formatted earliest check-out time for the given [shift]. - /// - /// Falls back to the localized "soon" label when the end time cannot - /// be parsed. - String _getCheckOutAvailabilityTimeText(Shift shift, BuildContext context) { - final DateTime? shiftEnd = DateTime.tryParse(shift.endTime.trim()); - if (shiftEnd != null) { - return TimeWindowValidator.getAvailabilityTime(shiftEnd); - } - return Translations.of(context).staff.clock_in.soon; - } - - /// Returns the formatted earliest check-in time for the given [shift]. - /// - /// Falls back to the localized "soon" label when the start time cannot - /// be parsed. - String _getAvailabilityTimeText(Shift shift, BuildContext context) { - final DateTime? shiftStart = DateTime.tryParse(shift.startTime.trim()); - if (shiftStart != null) { - return TimeWindowValidator.getAvailabilityTime(shiftStart); - } - return Translations.of(context).staff.clock_in.soon; - } - /// Triggers the check-out flow via the lunch-break confirmation dialog. void _handleCheckOut(BuildContext context) { final TranslationsStaffClockInGeofenceEn geofenceI18n = Translations.of( diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart index c9f6ea50..fc67a5b4 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart @@ -112,6 +112,10 @@ class _ClockInBodyState extends State { isActionInProgress: state.status == ClockInStatus.actionInProgress, hasClockinError: state.status == ClockInStatus.failure, + isCheckInAllowed: state.isCheckInAllowed, + isCheckOutAllowed: state.isCheckOutAllowed, + checkInAvailabilityTime: state.checkInAvailabilityTime, + checkOutAvailabilityTime: state.checkOutAvailabilityTime, ), // checked-in banner (only when checked in to the selected shift) diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart index 3b4d0d97..5224e922 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart @@ -106,6 +106,10 @@ class _ShiftTimeAndRate extends StatelessWidget { @override Widget build(BuildContext context) { + final TranslationsStaffClockInEn i18n = Translations.of( + context, + ).staff.clock_in; + return Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ @@ -114,7 +118,7 @@ class _ShiftTimeAndRate extends StatelessWidget { style: UiTypography.body3m.textSecondary, ), Text( - '\$${shift.hourlyRate}/hr', + i18n.per_hr(amount: shift.hourlyRate), style: UiTypography.body3m.copyWith(color: UiColors.primary), ), ], From c5b5c146558e9695db570d0caf0af70973b53312 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Mar 2026 01:35:49 -0400 Subject: [PATCH 28/33] feat: Add mobile QA analyst agent for Flutter code analysis and bug detection --- .claude/agents/mobile-qa-analyst.md | 343 ++++++++++++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 .claude/agents/mobile-qa-analyst.md diff --git a/.claude/agents/mobile-qa-analyst.md b/.claude/agents/mobile-qa-analyst.md new file mode 100644 index 00000000..78d4d9bf --- /dev/null +++ b/.claude/agents/mobile-qa-analyst.md @@ -0,0 +1,343 @@ +--- +name: mobile-qa-analyst +description: "Use this agent when you need to identify potential bugs, edge cases, and issues in Flutter mobile code through static analysis. This includes reviewing recently written or modified code for logical flaws, boundary conditions, state management issues, and real-world usage patterns. Use it after implementing features, fixing bugs, or before releases to catch issues that automated tests might miss.\\n\\nExamples:\\n\\n\\nContext: The user just implemented a new checkout flow feature.\\nuser: \"I just finished implementing the create order feature with the review flow\"\\nassistant: \"Let me use the mobile-qa-analyst agent to analyze the create order feature for potential bugs, edge cases, and issues.\"\\n\\nSince a significant feature was just implemented, use the Agent tool to launch the mobile-qa-analyst agent to perform a thorough QA analysis of the new code for edge cases, state management issues, and potential bugs.\\n\\n\\n\\n\\nContext: The user wants to check their BLoC implementation for issues before merging.\\nuser: \"Can you review my new authentication BLoC for potential issues?\"\\nassistant: \"I'll use the mobile-qa-analyst agent to thoroughly analyze your authentication BLoC for edge cases, state management issues, and potential bugs.\"\\n\\nSince the user wants a quality review of their BLoC implementation, use the Agent tool to launch the mobile-qa-analyst agent to identify potential issues from a manual QA perspective.\\n\\n\\n\\n\\nContext: The user modified network handling code and wants to ensure robustness.\\nuser: \"I updated how we handle API errors in the orders feature\"\\nassistant: \"Let me launch the mobile-qa-analyst agent to analyze your updated error handling for edge cases like network timeouts, partial responses, and race conditions.\"\\n\\nSince error handling code was modified, proactively use the Agent tool to launch the mobile-qa-analyst agent to verify robustness against various failure scenarios.\\n\\n" +model: opus +color: pink +memory: project +--- + +You are an expert **Manual QA Engineer specializing in Flutter mobile applications** within the KROW Workforce platform. Your role is to analyze Dart/Flutter code to identify potential bugs, issues, and edge cases that could negatively impact user experience. You act as a thorough manual tester—reviewing code for logical flaws, boundary conditions, state management issues, and real-world usage patterns—without actually executing test suites. + +## Initialization + +Before starting ANY review, you MUST load these skills +- `krow-mobile-architecture` + +and load any additional skills as needed for specific review challenges. + +## Project Context + +You are working within a Flutter monorepo (where features are organized into packages) using: +- **Clean Architecture**: Presentation (Pages, BLoCs, Widgets) → Application (Use Cases) → Domain (Entities, Interfaces, Failures) ← Data (Implementations, Connectors) +- **State Management**: Flutter BLoC/Cubit. BLoCs registered with `i.add()` (transient), never `i.addSingleton()`. `BlocProvider.value()` for shared BLoCs. +- **DI & Routing**: Flutter Modular. Safe navigation via `safeNavigate()`, `safePush()`, `popSafe()`. Never `Navigator.push()` directly. +- **Error Handling**: `BlocErrorHandler` mixin with `_safeEmit()` to prevent StateError on disposed BLoCs. +- **Backend**: Firebase Data Connect through `data_connect` package Connectors. `_service.run(() => connector.().execute())` for auth/token management. +- **Session Management**: `SessionHandlerMixin` + `SessionListener` widget. +- **Localization**: Slang (`t.section.key`), not `context.strings`. +- **Design System**: Tokens from `UiColors`, `UiTypography`, `UiConstants`. No hardcoded values. + +## Primary Responsibilities + +### 1. Code-Based Use Case Derivation +- Read and understand application logic from Dart/Flutter code +- Identify primary user journeys based on UI flows, navigation, and state management +- Map business logic to actual user actions and workflows +- Document expected behaviors based on code implementation +- Trace data flow through the application (input → processing → output) + +### 2. Edge Case & Boundary Condition Discovery +Systematically identify edge cases by analyzing: +- **Input validation**: Missing/null values, extreme values, invalid formats, overflow conditions +- **Network scenarios**: No internet, slow connection, timeout, failed requests, partial responses +- **State management issues**: Race conditions, state inconsistencies, lifecycle conflicts, disposed BLoC emissions +- **Permission handling**: Denied permissions, revoked access, partial permissions +- **Device scenarios**: Low storage, low battery, orientation changes, app backgrounding +- **Data constraints**: Empty lists, max/min values, special characters, Unicode handling +- **Concurrent operations**: Multiple button taps, simultaneous requests, navigation conflicts +- **Error recovery**: Crash scenarios, exception handling, fallback mechanisms + +### 3. Issue Identification & Analysis +Detect potential bugs including: +- **Logic errors**: Incorrect conditions, wrong operators, missing checks +- **UI/UX problems**: Unhandled states, broken navigation, poor error messaging +- **State management flaws**: Lost data, stale state, memory leaks, missing `BlocErrorHandler` usage +- **API integration issues**: Missing error handling, incorrect data mapping, async issues +- **Performance concerns**: Inefficient algorithms, unnecessary rebuilds, memory problems +- **Security vulnerabilities**: Hardcoded credentials, insecure data storage, authentication gaps +- **Architecture violations**: Features importing other features, business logic in BLoCs/widgets, Firebase packages outside `data_connect` +- **Data persistence issues**: Cache invalidation, concurrent access + +## Analysis Methodology + +### Phase 1: Code Exploration & Understanding +1. Map the feature's architecture and key screens +2. Identify critical user flows and navigation paths +3. Review state management implementation (BLoC states, events, transitions) +4. Understand data models and API contracts via Data Connect connectors +5. Document assumptions and expected behaviors + +### Phase 2: Use Case Extraction +1. List **Happy Path scenarios** (normal, expected usage) +2. Identify **Alternative Paths** (valid variations) +3. Define **Error Scenarios** (what can go wrong) +4. Map **Boundary Conditions** (minimum/maximum values, empty states) + +### Phase 3: Edge Case Generation +For each use case, generate edge cases covering: +- Input boundaries and constraints +- Network/connectivity variations +- Permission scenarios +- Device state changes +- Time-dependent behavior +- Concurrent user actions +- Error and exception paths + +### Phase 4: Issue Detection +Analyze code for: +- Missing null safety checks +- Unhandled exceptions +- Race conditions in async code +- Missing validation +- State inconsistencies +- Logic errors +- UI state management issues +- Architecture rule violations per KROW patterns + +## Flutter & KROW-Specific Focus Areas + +### Widget & State Management +- StatefulWidget lifecycle issues (initState, dispose) +- Missing `BlocErrorHandler` mixin or `_safeEmit()` usage +- BLoCs registered as singletons instead of transient +- Provider/BLoC listener memory leaks +- Unhandled state transitions + +### Async/Future Handling +- Uncaught exceptions in Futures +- Missing error handling in `.then()` chains +- Mounted checks missing in async callbacks +- Race conditions in concurrent requests +- Missing `_service.run()` wrapper for Data Connect calls + +### Navigation & Routing (Flutter Modular) +- Direct `Navigator.push()` usage instead of `safeNavigate()`/`safePush()`/`popSafe()` +- Back button behavior edge cases +- Deep link handling +- State loss during navigation +- Duplicate navigation calls + +### Localization +- Hardcoded strings instead of `t.section.key` +- Missing translations in both `en.i18n.json` and `es.i18n.json` +- `context.strings` usage instead of Slang `t.*` + +### Design System +- Hardcoded colors, fonts, or spacing instead of `UiColors`, `UiTypography`, `UiConstants` + +### Architecture Rules +- Features importing other features directly +- Business logic in BLoCs or widgets instead of Use Cases +- Firebase packages used outside `data_connect` package +- `context.read()` instead of `ReadContext(context).read()` + +## Output Format + +For each feature/screen analyzed, provide: + +``` +## [Feature/Screen Name] + +### Use Cases Identified +1. **Primary Path**: [Description of normal usage] +2. **Alternative Path**: [Valid variations] +3. **Error Path**: [What can go wrong] + +### Edge Cases & Boundary Conditions +- **Edge Case 1**: [Scenario] → [Potential Issue] +- **Edge Case 2**: [Scenario] → [Potential Issue] + +### Issues Found +1. **[Issue Category]** - [Severity: Critical/High/Medium/Low] + - **Location**: File path and line number(s) + - **Description**: What the problem is + - **Real-world Impact**: How users would be affected + - **Reproduction Steps**: How to verify the issue (manual testing) + - **Suggested Fix**: Recommended resolution + - **Root Cause**: Why this issue exists in the code + +### Architecture Compliance +- [Any violations of KROW architecture rules] + +### Recommendations +- [Testing recommendations] +- [Code improvements] +- [Best practices] +``` + +## Severity Levels + +- **Critical**: App crashes, data loss, security breach, core feature broken +- **High**: Feature doesn't work as intended, significant UX issue, workaround needed +- **Medium**: Minor feature issue, edge case not handled gracefully, performance concern +- **Low**: Polish issues, non-standard behavior, architecture nitpicks + +## Constraints + +### What You DO +✅ Analyze code statically for logical flaws and edge cases +✅ Identify potential runtime issues without execution +✅ Trace through code flow manually +✅ Recommend manual testing scenarios +✅ Suggest fixes based on KROW best practices +✅ Prioritize issues by severity and impact +✅ Check architecture rule compliance +✅ Consider real user behaviors and edge cases + +### What You DON'T Do +❌ Execute code or run applications +❌ Run automated test suites +❌ Compile or build the project +❌ Access runtime logs or crash reports +❌ Measure performance metrics +❌ Test on actual devices/emulators + +## Key Principles + +1. **Think Like a User**: Consider real-world usage patterns and mistakes users make +2. **Assume Worst Case**: Network fails, permissions denied, storage full, etc. +3. **Test the Happy Path AND Everything Else**: Don't just verify normal behavior +4. **Check State Management Thoroughly**: State bugs are the most common in Flutter apps +5. **Consider Timing Issues**: Race conditions, async operations, lifecycle events +6. **Platform Awareness**: Remember iOS and Android behave differently +7. **Be Specific**: Point to exact code locations and provide reproducible scenarios +8. **Respect Architecture**: Flag violations of KROW's Clean Architecture and patterns +9. **Practical Focus**: Prioritize issues users will actually encounter + +## Getting Started + +When analyzing Flutter code, begin by: +1. Reading the feature's module file to understand routing and DI setup +2. Reviewing BLoC/Cubit states and events to understand state management +3. Tracing user flows through pages and widgets +4. Checking data flow from UI through use cases to repositories +5. Identifying all async operations and error handling paths +6. Verifying compliance with KROW architecture rules + +Then systematically work through the code, building use cases and edge cases, documenting findings as you identify potential issues. + +**Update your agent memory** as you discover common bug patterns, recurring issues, architecture violations, and feature-specific quirks in this codebase. This builds up institutional knowledge across conversations. Write concise notes about what you found and where. + +Examples of what to record: +- Common patterns that lead to bugs (e.g., missing dispose cleanup in specific feature areas) +- Recurring architecture violations and their locations +- Features with complex state management that need extra attention +- Known edge cases specific to KROW's business logic (order types, session handling, etc.) +- Patterns of missing error handling in Data Connect calls + +# Persistent Agent Memory + +You have a persistent, file-based memory system at `/Users/achinthaisuru/Documents/GitHub/krow-workforce/.claude/agent-memory/mobile-qa-analyst/`. This directory already exists — write to it directly with the Write tool (do not run mkdir or check for its existence). + +You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you. + +If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry. + +## Types of memory + +There are several discrete types of memory that you can store in your memory system: + + + + user + Contain information about the user's role, goals, responsibilities, and knowledge. Great user memories help you tailor your future behavior to the user's preferences and perspective. Your goal in reading and writing these memories is to build up an understanding of who the user is and how you can be most helpful to them specifically. For example, you should collaborate with a senior software engineer differently than a student who is coding for the very first time. Keep in mind, that the aim here is to be helpful to the user. Avoid writing memories about the user that could be viewed as a negative judgement or that are not relevant to the work you're trying to accomplish together. + When you learn any details about the user's role, preferences, responsibilities, or knowledge + When your work should be informed by the user's profile or perspective. For example, if the user is asking you to explain a part of the code, you should answer that question in a way that is tailored to the specific details that they will find most valuable or that helps them build their mental model in relation to domain knowledge they already have. + + user: I'm a data scientist investigating what logging we have in place + assistant: [saves user memory: user is a data scientist, currently focused on observability/logging] + + user: I've been writing Go for ten years but this is my first time touching the React side of this repo + assistant: [saves user memory: deep Go expertise, new to React and this project's frontend — frame frontend explanations in terms of backend analogues] + + + + feedback + Guidance or correction the user has given you. These are a very important type of memory to read and write as they allow you to remain coherent and responsive to the way you should approach work in the project. Without these memories, you will repeat the same mistakes and the user will have to correct you over and over. + Any time the user corrects or asks for changes to your approach in a way that could be applicable to future conversations – especially if this feedback is surprising or not obvious from the code. These often take the form of "no not that, instead do...", "lets not...", "don't...". when possible, make sure these memories include why the user gave you this feedback so that you know when to apply it later. + Let these memories guide your behavior so that the user does not need to offer the same guidance twice. + Lead with the rule itself, then a **Why:** line (the reason the user gave — often a past incident or strong preference) and a **How to apply:** line (when/where this guidance kicks in). Knowing *why* lets you judge edge cases instead of blindly following the rule. + + user: don't mock the database in these tests — we got burned last quarter when mocked tests passed but the prod migration failed + assistant: [saves feedback memory: integration tests must hit a real database, not mocks. Reason: prior incident where mock/prod divergence masked a broken migration] + + user: stop summarizing what you just did at the end of every response, I can read the diff + assistant: [saves feedback memory: this user wants terse responses with no trailing summaries] + + + + project + Information that you learn about ongoing work, goals, initiatives, bugs, or incidents within the project that is not otherwise derivable from the code or git history. Project memories help you understand the broader context and motivation behind the work the user is doing within this working directory. + When you learn who is doing what, why, or by when. These states change relatively quickly so try to keep your understanding of this up to date. Always convert relative dates in user messages to absolute dates when saving (e.g., "Thursday" → "2026-03-05"), so the memory remains interpretable after time passes. + Use these memories to more fully understand the details and nuance behind the user's request and make better informed suggestions. + Lead with the fact or decision, then a **Why:** line (the motivation — often a constraint, deadline, or stakeholder ask) and a **How to apply:** line (how this should shape your suggestions). Project memories decay fast, so the why helps future-you judge whether the memory is still load-bearing. + + user: we're freezing all non-critical merges after Thursday — mobile team is cutting a release branch + assistant: [saves project memory: merge freeze begins 2026-03-05 for mobile release cut. Flag any non-critical PR work scheduled after that date] + + user: the reason we're ripping out the old auth middleware is that legal flagged it for storing session tokens in a way that doesn't meet the new compliance requirements + assistant: [saves project memory: auth middleware rewrite is driven by legal/compliance requirements around session token storage, not tech-debt cleanup — scope decisions should favor compliance over ergonomics] + + + + reference + Stores pointers to where information can be found in external systems. These memories allow you to remember where to look to find up-to-date information outside of the project directory. + When you learn about resources in external systems and their purpose. For example, that bugs are tracked in a specific project in Linear or that feedback can be found in a specific Slack channel. + When the user references an external system or information that may be in an external system. + + user: check the Linear project "INGEST" if you want context on these tickets, that's where we track all pipeline bugs + assistant: [saves reference memory: pipeline bugs are tracked in Linear project "INGEST"] + + user: the Grafana board at grafana.internal/d/api-latency is what oncall watches — if you're touching request handling, that's the thing that'll page someone + assistant: [saves reference memory: grafana.internal/d/api-latency is the oncall latency dashboard — check it when editing request-path code] + + + + +## What NOT to save in memory + +- Code patterns, conventions, architecture, file paths, or project structure — these can be derived by reading the current project state. +- Git history, recent changes, or who-changed-what — `git log` / `git blame` are authoritative. +- Debugging solutions or fix recipes — the fix is in the code; the commit message has the context. +- Anything already documented in CLAUDE.md files. +- Ephemeral task details: in-progress work, temporary state, current conversation context. + +## How to save memories + +Saving a memory is a two-step process: + +**Step 1** — write the memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format: + +```markdown +--- +name: {{memory name}} +description: {{one-line description — used to decide relevance in future conversations, so be specific}} +type: {{user, feedback, project, reference}} +--- + +{{memory content — for feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines}} +``` + +**Step 2** — add a pointer to that file in `MEMORY.md`. `MEMORY.md` is an index, not a memory — it should contain only links to memory files with brief descriptions. It has no frontmatter. Never write memory content directly into `MEMORY.md`. + +- `MEMORY.md` is always loaded into your conversation context — lines after 200 will be truncated, so keep the index concise +- Keep the name, description, and type fields in memory files up-to-date with the content +- Organize memory semantically by topic, not chronologically +- Update or remove memories that turn out to be wrong or outdated +- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one. + +## When to access memories +- When specific known memories seem relevant to the task at hand. +- When the user seems to be referring to work you may have done in a prior conversation. +- You MUST access memory when the user explicitly asks you to check your memory, recall, or remember. + +## Memory and other forms of persistence +Memory is one of several persistence mechanisms available to you as you assist the user in a given conversation. The distinction is often that memory can be recalled in future conversations and should not be used for persisting information that is only useful within the scope of the current conversation. +- When to use or update a plan instead of memory: If you are about to start a non-trivial implementation task and would like to reach alignment with the user on your approach you should use a Plan rather than saving this information to memory. Similarly, if you already have a plan within the conversation and you have changed your approach persist that change by updating the plan rather than saving a memory. +- When to use or update tasks instead of memory: When you need to break your work in current conversation into discrete steps or keep track of your progress use tasks instead of saving to memory. Tasks are great for persisting information about the work that needs to be done in the current conversation, but memory should be reserved for information that will be useful in future conversations. + +- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project + +## MEMORY.md + +Your MEMORY.md is currently empty. When you save new memories, they will appear here. From 31f03aa52a2b0e97f21349bd7911865737158994 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Mar 2026 01:55:20 -0400 Subject: [PATCH 29/33] feat: Enhance clock-in feature with time window auto-refresh and lunch break data handling --- .../agent-memory/mobile-qa-analyst/MEMORY.md | 4 ++ .../clock_in_repository_impl.dart | 14 ++-- .../bloc/clock_in/clock_in_bloc.dart | 64 +++++++++++++++++-- .../bloc/clock_in/clock_in_event.dart | 9 +++ .../bloc/clock_in/clock_in_state.dart | 19 ++++-- .../bloc/geofence/geofence_bloc.dart | 24 +++++++ .../bloc/geofence/geofence_state.dart | 28 ++++++-- .../src/presentation/pages/clock_in_page.dart | 9 ++- .../widgets/clock_in_action_section.dart | 6 +- .../widgets/lunch_break_modal.dart | 49 ++++++++++++-- .../widgets/swipe_to_check_in.dart | 1 + 11 files changed, 195 insertions(+), 32 deletions(-) create mode 100644 .claude/agent-memory/mobile-qa-analyst/MEMORY.md diff --git a/.claude/agent-memory/mobile-qa-analyst/MEMORY.md b/.claude/agent-memory/mobile-qa-analyst/MEMORY.md new file mode 100644 index 00000000..9bfe7a71 --- /dev/null +++ b/.claude/agent-memory/mobile-qa-analyst/MEMORY.md @@ -0,0 +1,4 @@ +# Mobile QA Analyst Memory Index + +## Project Context +- [project_clock_in_feature_issues.md](project_clock_in_feature_issues.md) — Critical bugs in staff clock_in feature: BLoC lifecycle leak, stale geofence override, dead lunch break data, non-functional date selector diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart index ea0e990f..c2509429 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart @@ -183,12 +183,12 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface { final fdc.Timestamp checkInTs = _service.toTimestamp(DateTime.now()); - await _service.run(() => _service.connector + await _service.connector .updateApplicationStatus( - id: app!.id, + id: app.id, ) .checkInTime(checkInTs) - .execute()); + .execute(); _activeApplicationId = app.id; return getAttendanceStatus(); @@ -210,9 +210,9 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface { } final fdc.QueryResult appResult = - await _service.run(() => _service.connector + await _service.connector .getApplicationById(id: targetAppId) - .execute()); + .execute(); final dc.GetApplicationByIdApplication? app = appResult.data.application; if (app == null) { @@ -222,12 +222,12 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface { throw Exception('No active shift found to clock out'); } - await _service.run(() => _service.connector + await _service.connector .updateApplicationStatus( id: targetAppId, ) .checkOutTime(_service.toTimestamp(DateTime.now())) - .execute()); + .execute(); return getAttendanceStatus(); }); diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart index 7b84b7df..a7846548 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -49,8 +51,7 @@ class ClockInBloc extends Bloc on(_onCheckIn); on(_onCheckOut); on(_onModeChanged); - - add(ClockInPageLoaded()); + on(_onTimeWindowRefresh); } final GetTodaysShiftUseCase _getTodaysShift; @@ -64,6 +65,10 @@ class ClockInBloc extends Bloc /// Composite validator for clock-in preconditions. final CompositeClockInValidator _validator; + /// Periodic timer that re-evaluates time window flags every 30 seconds + /// so the "too early" banner updates without user interaction. + Timer? _timeWindowTimer; + /// Loads today's shifts and the current attendance status. Future _onLoaded( ClockInPageLoaded event, @@ -101,6 +106,9 @@ class ClockInBloc extends Bloc checkInAvailabilityTime: timeFlags.checkInAvailabilityTime, checkOutAvailabilityTime: timeFlags.checkOutAvailabilityTime, )); + + // Start periodic timer so time-window banners auto-update. + _startTimeWindowTimer(); }, onError: (String errorKey) => state.copyWith( status: ClockInStatus.failure, @@ -124,12 +132,18 @@ class ClockInBloc extends Bloc )); } - /// Updates the selected date for shift viewing. - void _onDateSelected( + /// Updates the selected date and re-fetches shifts. + /// + /// Currently the repository always fetches today's shifts regardless of + /// the selected date. Re-loading ensures the UI stays in sync after a + /// date change. + // TODO(clock_in): Pass selected date to repository for date-based filtering. + Future _onDateSelected( DateSelected event, Emitter emit, - ) { + ) async { emit(state.copyWith(selectedDate: event.date)); + await _onLoaded(ClockInPageLoaded(), emit); } /// Updates the check-in interaction mode. @@ -222,7 +236,7 @@ class ClockInBloc extends Bloc final AttendanceStatus newStatus = await _clockOut( ClockOutArguments( notes: event.notes, - breakTimeMinutes: 0, + breakTimeMinutes: event.breakTimeMinutes ?? 0, applicationId: state.attendance.activeApplicationId, ), ); @@ -246,6 +260,44 @@ class ClockInBloc extends Bloc ); } + /// Re-evaluates time window flags for the currently selected shift. + /// + /// Fired periodically by [_timeWindowTimer] so banners like "too early" + /// automatically disappear once the check-in window opens. + void _onTimeWindowRefresh( + TimeWindowRefreshRequested event, + Emitter emit, + ) { + if (state.status != ClockInStatus.success) return; + final _TimeWindowFlags timeFlags = _computeTimeWindowFlags( + state.selectedShift, + ); + emit(state.copyWith( + isCheckInAllowed: timeFlags.isCheckInAllowed, + isCheckOutAllowed: timeFlags.isCheckOutAllowed, + checkInAvailabilityTime: timeFlags.checkInAvailabilityTime, + clearCheckInAvailabilityTime: timeFlags.checkInAvailabilityTime == null, + checkOutAvailabilityTime: timeFlags.checkOutAvailabilityTime, + clearCheckOutAvailabilityTime: + timeFlags.checkOutAvailabilityTime == null, + )); + } + + /// Starts the periodic time-window refresh timer. + void _startTimeWindowTimer() { + _timeWindowTimer?.cancel(); + _timeWindowTimer = Timer.periodic( + const Duration(seconds: 30), + (_) => add(const TimeWindowRefreshRequested()), + ); + } + + @override + Future close() { + _timeWindowTimer?.cancel(); + return super.close(); + } + /// Safely parses a time string into a [DateTime], returning `null` on failure. static DateTime? _tryParseDateTime(String? value) { if (value == null || value.isEmpty) return null; diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart index 27c2aa44..36652f26 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_event.dart @@ -118,3 +118,12 @@ class CheckInModeChanged extends ClockInEvent { @override List get props => [mode]; } + +/// Periodically emitted by a timer to re-evaluate time window flags. +/// +/// Ensures banners like "too early to check in" disappear once the +/// time window opens, without requiring user interaction. +class TimeWindowRefreshRequested extends ClockInEvent { + /// Creates a [TimeWindowRefreshRequested] event. + const TimeWindowRefreshRequested(); +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_state.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_state.dart index 3febeaf2..ddda84ed 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_state.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_state.dart @@ -58,10 +58,14 @@ class ClockInState extends Equatable { final String? checkOutAvailabilityTime; /// Creates a copy of this state with the given fields replaced. + /// + /// Use the `clearX` flags to explicitly set nullable fields to `null`, + /// since the `??` fallback otherwise prevents clearing. ClockInState copyWith({ ClockInStatus? status, List? todayShifts, Shift? selectedShift, + bool clearSelectedShift = false, AttendanceStatus? attendance, DateTime? selectedDate, String? checkInMode, @@ -69,22 +73,27 @@ class ClockInState extends Equatable { bool? isCheckInAllowed, bool? isCheckOutAllowed, String? checkInAvailabilityTime, + bool clearCheckInAvailabilityTime = false, String? checkOutAvailabilityTime, + bool clearCheckOutAvailabilityTime = false, }) { return ClockInState( status: status ?? this.status, todayShifts: todayShifts ?? this.todayShifts, - selectedShift: selectedShift ?? this.selectedShift, + selectedShift: + clearSelectedShift ? null : (selectedShift ?? this.selectedShift), attendance: attendance ?? this.attendance, selectedDate: selectedDate ?? this.selectedDate, checkInMode: checkInMode ?? this.checkInMode, errorMessage: errorMessage, isCheckInAllowed: isCheckInAllowed ?? this.isCheckInAllowed, isCheckOutAllowed: isCheckOutAllowed ?? this.isCheckOutAllowed, - checkInAvailabilityTime: - checkInAvailabilityTime ?? this.checkInAvailabilityTime, - checkOutAvailabilityTime: - checkOutAvailabilityTime ?? this.checkOutAvailabilityTime, + checkInAvailabilityTime: clearCheckInAvailabilityTime + ? null + : (checkInAvailabilityTime ?? this.checkInAvailabilityTime), + checkOutAvailabilityTime: clearCheckOutAvailabilityTime + ? null + : (checkOutAvailabilityTime ?? this.checkOutAvailabilityTime), ); } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart index 5290a008..d9c6a260 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart @@ -39,6 +39,10 @@ class GeofenceBloc extends Bloc on(_onOverrideApproved); on(_onStopped); } + /// Generation counter to discard stale geofence results when a new + /// [GeofenceStarted] event arrives before the previous check completes. + int _generation = 0; + /// The geofence service for foreground proximity checks. final GeofenceServiceInterface _geofenceService; @@ -60,10 +64,23 @@ class GeofenceBloc extends Bloc GeofenceStarted event, Emitter emit, ) async { + // Increment generation so in-flight results from previous shifts are + // discarded when they complete after a new GeofenceStarted fires. + _generation++; + final int currentGeneration = _generation; + + // Reset override state from any previous shift and clear stale location + // data so the new shift starts with a clean geofence verification. emit(state.copyWith( isVerifying: true, targetLat: event.targetLat, targetLng: event.targetLng, + isGeofenceOverridden: false, + clearOverrideNotes: true, + isLocationVerified: false, + isLocationTimedOut: false, + clearCurrentLocation: true, + clearDistanceFromTarget: true, )); await handleError( @@ -71,6 +88,10 @@ class GeofenceBloc extends Bloc action: () async { // Check permission first. final LocationPermissionStatus permission = await _geofenceService.ensurePermission(); + + // Discard if a newer GeofenceStarted has fired while awaiting. + if (_generation != currentGeneration) return; + emit(state.copyWith(permissionStatus: permission)); if (permission == LocationPermissionStatus.denied || @@ -97,6 +118,9 @@ class GeofenceBloc extends Bloc targetLng: event.targetLng, ); + // Discard if a newer GeofenceStarted has fired while awaiting. + if (_generation != currentGeneration) return; + if (result == null) { add(const GeofenceTimeoutReached()); } else { diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_state.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_state.dart index a4ab8ed7..d73a4b2e 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_state.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_state.dart @@ -59,35 +59,51 @@ class GeofenceState extends Equatable { final double? targetLng; /// Creates a copy with the given fields replaced. + /// + /// Use the `clearX` flags to explicitly set nullable fields to `null`, + /// since the `??` fallback otherwise prevents clearing. GeofenceState copyWith({ LocationPermissionStatus? permissionStatus, + bool clearPermissionStatus = false, bool? isLocationServiceEnabled, DeviceLocation? currentLocation, + bool clearCurrentLocation = false, double? distanceFromTarget, + bool clearDistanceFromTarget = false, bool? isLocationVerified, bool? isLocationTimedOut, bool? isVerifying, bool? isBackgroundTrackingActive, bool? isGeofenceOverridden, String? overrideNotes, + bool clearOverrideNotes = false, double? targetLat, + bool clearTargetLat = false, double? targetLng, + bool clearTargetLng = false, }) { return GeofenceState( - permissionStatus: permissionStatus ?? this.permissionStatus, + permissionStatus: clearPermissionStatus + ? null + : (permissionStatus ?? this.permissionStatus), isLocationServiceEnabled: isLocationServiceEnabled ?? this.isLocationServiceEnabled, - currentLocation: currentLocation ?? this.currentLocation, - distanceFromTarget: distanceFromTarget ?? this.distanceFromTarget, + currentLocation: clearCurrentLocation + ? null + : (currentLocation ?? this.currentLocation), + distanceFromTarget: clearDistanceFromTarget + ? null + : (distanceFromTarget ?? this.distanceFromTarget), isLocationVerified: isLocationVerified ?? this.isLocationVerified, isLocationTimedOut: isLocationTimedOut ?? this.isLocationTimedOut, isVerifying: isVerifying ?? this.isVerifying, isBackgroundTrackingActive: isBackgroundTrackingActive ?? this.isBackgroundTrackingActive, isGeofenceOverridden: isGeofenceOverridden ?? this.isGeofenceOverridden, - overrideNotes: overrideNotes ?? this.overrideNotes, - targetLat: targetLat ?? this.targetLat, - targetLng: targetLng ?? this.targetLng, + overrideNotes: + clearOverrideNotes ? null : (overrideNotes ?? this.overrideNotes), + targetLat: clearTargetLat ? null : (targetLat ?? this.targetLat), + targetLng: clearTargetLng ? null : (targetLng ?? this.targetLng), ); } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart index 279749a0..c6b1ffa6 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart @@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import '../bloc/clock_in/clock_in_bloc.dart'; +import '../bloc/clock_in/clock_in_event.dart'; import '../bloc/clock_in/clock_in_state.dart'; import '../bloc/geofence/geofence_bloc.dart'; import '../widgets/clock_in_body.dart'; @@ -32,8 +33,12 @@ class ClockInPage extends StatelessWidget { BlocProvider.value( value: Modular.get(), ), - BlocProvider.value( - value: Modular.get(), + BlocProvider( + create: (BuildContext _) { + final ClockInBloc bloc = Modular.get(); + bloc.add(ClockInPageLoaded()); + return bloc; + }, ), ], child: BlocListener( 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 2a34900a..5a5ec04d 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 @@ -163,7 +163,10 @@ class ClockInActionSection extends StatelessWidget { /// Triggers the check-in flow, passing notification strings and /// override notes from geofence state. + /// + /// Returns early if [selectedShift] is null to avoid force-unwrap errors. void _handleCheckIn(BuildContext context) { + if (selectedShift == null) return; final GeofenceState geofenceState = ReadContext( context, ).read().state; @@ -192,10 +195,11 @@ class ClockInActionSection extends StatelessWidget { showDialog( context: context, builder: (BuildContext dialogContext) => LunchBreakDialog( - onComplete: () { + onComplete: (int breakTimeMinutes) { Modular.to.popSafe(); ReadContext(context).read().add( CheckOutRequested( + breakTimeMinutes: breakTimeMinutes, clockOutTitle: geofenceI18n.clock_out_title, clockOutBody: geofenceI18n.clock_out_body, ), diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart index 47ceb80d..7aac190d 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart @@ -3,9 +3,16 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +/// Dialog that collects lunch break information during the check-out flow. +/// +/// Returns the break duration in minutes via [onComplete]. If the user +/// indicates they did not take a lunch break, the value will be `0`. class LunchBreakDialog extends StatefulWidget { + /// Creates a [LunchBreakDialog] with the required [onComplete] callback. const LunchBreakDialog({super.key, required this.onComplete}); - final VoidCallback onComplete; + + /// Called when the user finishes the dialog, passing break time in minutes. + final ValueChanged onComplete; @override State createState() => _LunchBreakDialogState(); @@ -25,6 +32,36 @@ class _LunchBreakDialogState extends State { final List _timeOptions = _generateTimeOptions(); + /// Computes the break duration in minutes from [_breakStart] and [_breakEnd]. + /// + /// Returns `0` when the user did not take lunch or the times are invalid. + int _computeBreakMinutes() { + if (_tookLunch != true || _breakStart == null || _breakEnd == null) { + return 0; + } + final int? startMinutes = _parseTimeToMinutes(_breakStart!); + final int? endMinutes = _parseTimeToMinutes(_breakEnd!); + if (startMinutes == null || endMinutes == null) return 0; + final int diff = endMinutes - startMinutes; + return diff > 0 ? diff : 0; + } + + /// Parses a time string like "12:30pm" into total minutes since midnight. + static int? _parseTimeToMinutes(String time) { + final String lower = time.toLowerCase().trim(); + final bool isPm = lower.endsWith('pm'); + final String cleaned = lower.replaceAll(RegExp(r'[ap]m'), ''); + final List parts = cleaned.split(':'); + if (parts.length != 2) return null; + final int? hour = int.tryParse(parts[0]); + final int? minute = int.tryParse(parts[1]); + if (hour == null || minute == null) return null; + int hour24 = hour; + if (isPm && hour != 12) hour24 += 12; + if (!isPm && hour == 12) hour24 = 0; + return hour24 * 60 + minute; + } + static List _generateTimeOptions() { final List options = []; for (int h = 0; h < 24; h++) { @@ -258,9 +295,11 @@ class _LunchBreakDialogState extends State { const SizedBox(height: UiConstants.space6), ElevatedButton( - onPressed: () { - setState(() => _step = 3); - }, + onPressed: _noLunchReason != null + ? () { + setState(() => _step = 3); + } + : null, style: ElevatedButton.styleFrom( backgroundColor: UiColors.primary, minimumSize: const Size(double.infinity, 48), @@ -325,7 +364,7 @@ class _LunchBreakDialogState extends State { ), const SizedBox(height: UiConstants.space6), ElevatedButton( - onPressed: widget.onComplete, + onPressed: () => widget.onComplete(_computeBreakMinutes()), style: ElevatedButton.styleFrom( backgroundColor: UiColors.primary, minimumSize: const Size(double.infinity, 48), diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart index 4f0b2fd5..4cac15dc 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart @@ -89,6 +89,7 @@ class _SwipeToCheckInState extends State _isComplete = true; }); Future.delayed(const Duration(milliseconds: 300), () { + if (!mounted) return; if (widget.isCheckedIn) { widget.onCheckOut?.call(); } else { From f4c56cf54744d449ea9c79a410cf338d1fdf1c2b Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Mar 2026 02:25:55 -0400 Subject: [PATCH 30/33] Update clock_in_bloc.dart --- .../lib/src/presentation/bloc/clock_in/clock_in_bloc.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart index a7846548..3a7e0a0e 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart @@ -284,6 +284,7 @@ class ClockInBloc extends Bloc } /// Starts the periodic time-window refresh timer. + // TODO: Change this logic to more comprehensive logic based on the actual shift times instead of a fixed 30-second timer. void _startTimeWindowTimer() { _timeWindowTimer?.cancel(); _timeWindowTimer = Timer.periodic( From c0a7ca13294dd29221711b3beca63c024d55dab5 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Mar 2026 02:31:07 -0400 Subject: [PATCH 31/33] Increase background geofence interval to 15m Update BackgroundGeofenceService to register the periodic geofence task with a 15-minute interval instead of 10 seconds. This reduces frequent wakeups and resource usage and better matches typical platform background scheduling constraints. --- .../lib/src/data/services/background_geofence_service.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart index f13360c7..d22ea458 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart @@ -161,7 +161,7 @@ class BackgroundGeofenceService { await _backgroundTaskService.registerPeriodicTask( uniqueName: taskUniqueName, taskName: taskName, - frequency: const Duration(seconds: 10), + frequency: const Duration(minutes: 15), inputData: { 'targetLat': targetLat, 'targetLng': targetLng, From 1bb1f590c52773acf7da44093ad01a416ee191d5 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Mar 2026 02:34:39 -0400 Subject: [PATCH 32/33] Add WorkManager guidance and navigation exception Add a new "Background Tasks & WorkManager" section with checklist items for reviewing background task code (app background/killed behavior, doze/timing, minimum periodic interval, background location permissions, battery optimization, data passed to background isolates, failure handling, and task cleanup). Also clarify navigation guidance by allowing Navigator.push() when popping a dialog (note added to both Mobile Architecture skill and Mobile QA Analyst agent docs). --- .agent/skills/krow-mobile-architecture/SKILL.md | 2 +- .claude/agents/mobile-qa-analyst.md | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.agent/skills/krow-mobile-architecture/SKILL.md b/.agent/skills/krow-mobile-architecture/SKILL.md index eccc0bb2..febe1686 100644 --- a/.agent/skills/krow-mobile-architecture/SKILL.md +++ b/.agent/skills/krow-mobile-architecture/SKILL.md @@ -511,7 +511,7 @@ Modular.to.popSafe(); // ❌ AVOID Modular.to.navigate('/home'); // No safety -Navigator.push(...); // No Modular integration +Navigator.push(...); // No Modular integration (except when popping a dialog). ``` ### Data Sharing Patterns diff --git a/.claude/agents/mobile-qa-analyst.md b/.claude/agents/mobile-qa-analyst.md index 78d4d9bf..f21ca49b 100644 --- a/.claude/agents/mobile-qa-analyst.md +++ b/.claude/agents/mobile-qa-analyst.md @@ -20,7 +20,7 @@ and load any additional skills as needed for specific review challenges. You are working within a Flutter monorepo (where features are organized into packages) using: - **Clean Architecture**: Presentation (Pages, BLoCs, Widgets) → Application (Use Cases) → Domain (Entities, Interfaces, Failures) ← Data (Implementations, Connectors) - **State Management**: Flutter BLoC/Cubit. BLoCs registered with `i.add()` (transient), never `i.addSingleton()`. `BlocProvider.value()` for shared BLoCs. -- **DI & Routing**: Flutter Modular. Safe navigation via `safeNavigate()`, `safePush()`, `popSafe()`. Never `Navigator.push()` directly. +- **DI & Routing**: Flutter Modular. Safe navigation via `safeNavigate()`, `safePush()`, `popSafe()`. Never `Navigator.push()` directly (except when popping a dialog). - **Error Handling**: `BlocErrorHandler` mixin with `_safeEmit()` to prevent StateError on disposed BLoCs. - **Backend**: Firebase Data Connect through `data_connect` package Connectors. `_service.run(() => connector.().execute())` for auth/token management. - **Session Management**: `SessionHandlerMixin` + `SessionListener` widget. @@ -110,8 +110,20 @@ Analyze code for: - Race conditions in concurrent requests - Missing `_service.run()` wrapper for Data Connect calls +### Background Tasks & WorkManager +When reviewing code that uses WorkManager or background task scheduling, check these edge cases: +- **App backgrounded**: Does the background task work when the app is in the background? WorkManager runs in a separate isolate — verify it doesn't depend on Flutter UI engine or DI container. +- **App killed/swiped away**: WorkManager persists tasks in SQLite and Android's JobScheduler can wake the app. Verify the background dispatcher is a top-level `@pragma('vm:entry-point')` function that doesn't rely on app state. iOS BGTaskScheduler is heavily throttled for killed apps — flag this platform difference. +- **Screen off / Doze mode**: Android batches tasks for battery efficiency. Actual execution intervals may be 15-30+ min regardless of requested frequency. Flag any code that assumes exact timing. +- **Minimum periodic interval**: Android enforces a minimum of 15 minutes for `registerPeriodicTask`. Any frequency below this is silently clamped. Flag code requesting shorter intervals as misleading. +- **Background location permission**: `getCurrentLocation()` in a background isolate requires `ACCESS_BACKGROUND_LOCATION` (Android 10+) / "Always" permission (iOS). Verify the app requests this upgrade before starting background tracking. Check what happens if the user denies "Always" permission — the GPS call will fail silently. +- **Battery optimization**: OEM-specific battery optimization (Xiaomi, Huawei, Samsung) can delay or skip background tasks entirely. Flag if there's no guidance to users about whitelisting the app. +- **Data passed to background isolate**: Background isolates have no DI access. Verify all needed data (coordinates, localized strings, IDs) is passed via `inputData` map or persisted to `SharedPreferences`/`StorageService`. Flag any hardcoded user-facing strings that should be localized. +- **Task failure handling**: Check what happens when the background task throws (GPS unavailable, network error). Verify the catch block returns `true` (reschedule) vs `false` (don't retry) appropriately. Check if repeated failures are tracked or silently swallowed. +- **Task cleanup**: Verify background tasks are properly cancelled on clock-out/logout/session end. Check for orphaned tasks that could run indefinitely if the user force-quits without clocking out. + ### Navigation & Routing (Flutter Modular) -- Direct `Navigator.push()` usage instead of `safeNavigate()`/`safePush()`/`popSafe()` +- Direct `Navigator.push()` usage instead of `safeNavigate()`/`safePush()`/`popSafe()` (except when popping a dialog). - Back button behavior edge cases - Deep link handling - State loss during navigation From 13471629f68d51bde51640ab15d322008b3ca1ab Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:44:32 +0100 Subject: [PATCH 33/33] fix(auth): align demo staff phone identity and clock-in payload --- .../src/services/mobile-query-service.js | 7 +++ .../scripts/ensure-v2-demo-users.mjs | 61 ++++++++++++++++++- .../scripts/live-smoke-v2-unified.mjs | 4 ++ docs/BACKEND/API_GUIDES/V2/authentication.md | 7 +++ docs/BACKEND/API_GUIDES/V2/unified-api.md | 25 ++++++++ 5 files changed, 103 insertions(+), 1 deletion(-) diff --git a/backend/query-api/src/services/mobile-query-service.js b/backend/query-api/src/services/mobile-query-service.js index 4cbb7e53..cad050e9 100644 --- a/backend/query-api/src/services/mobile-query-service.js +++ b/backend/query-api/src/services/mobile-query-service.js @@ -630,8 +630,14 @@ export async function listTodayShifts(actorUid) { SELECT a.id AS "assignmentId", s.id AS "shiftId", + COALESCE(s.title, sr.role_name || ' shift') AS title, + b.business_name AS "clientName", + ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate", sr.role_name AS "roleName", COALESCE(cp.label, s.location_name) AS location, + COALESCE(s.location_address, cp.address) AS "locationAddress", + COALESCE(s.latitude, cp.latitude) AS latitude, + COALESCE(s.longitude, cp.longitude) AS longitude, s.starts_at AS "startTime", s.ends_at AS "endTime", COALESCE(s.clock_in_mode, cp.default_clock_in_mode, 'EITHER') AS "clockInMode", @@ -643,6 +649,7 @@ export async function listTodayShifts(actorUid) { FROM assignments a JOIN shifts s ON s.id = a.shift_id JOIN shift_roles sr ON sr.id = a.shift_role_id + JOIN businesses b ON b.id = s.business_id LEFT JOIN clock_points cp ON cp.id = s.clock_point_id LEFT JOIN attendance_sessions ON attendance_sessions.assignment_id = a.id WHERE a.tenant_id = $1 diff --git a/backend/unified-api/scripts/ensure-v2-demo-users.mjs b/backend/unified-api/scripts/ensure-v2-demo-users.mjs index d5ea6cc1..1a1ff5a6 100644 --- a/backend/unified-api/scripts/ensure-v2-demo-users.mjs +++ b/backend/unified-api/scripts/ensure-v2-demo-users.mjs @@ -1,10 +1,24 @@ import { signInWithPassword, signUpWithPassword } from '../src/services/identity-toolkit.js'; +import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app'; +import { getAuth } from 'firebase-admin/auth'; const ownerEmail = process.env.V2_DEMO_OWNER_EMAIL || 'legendary.owner+v2@krowd.com'; const staffEmail = process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com'; +const staffPhone = process.env.V2_DEMO_STAFF_PHONE || '+15557654321'; const ownerPassword = process.env.V2_DEMO_OWNER_PASSWORD || 'Demo2026!'; const staffPassword = process.env.V2_DEMO_STAFF_PASSWORD || 'Demo2026!'; +function ensureAdminApp() { + if (getApps().length === 0) { + initializeApp({ credential: applicationDefault() }); + } +} + +function getAdminAuth() { + ensureAdminApp(); + return getAuth(); +} + async function ensureUser({ email, password, displayName }) { try { const signedIn = await signInWithPassword({ email, password }); @@ -40,6 +54,44 @@ async function ensureUser({ email, password, displayName }) { } } +async function getUserByPhoneNumber(phoneNumber) { + try { + return await getAdminAuth().getUserByPhoneNumber(phoneNumber); + } catch (error) { + if (error?.code === 'auth/user-not-found') return null; + throw error; + } +} + +async function reconcileStaffPhoneIdentity({ uid, email, displayName, phoneNumber }) { + const auth = getAdminAuth(); + const current = await auth.getUser(uid); + const existingPhoneUser = await getUserByPhoneNumber(phoneNumber); + let deletedConflictingUid = null; + + if (existingPhoneUser && existingPhoneUser.uid !== uid) { + deletedConflictingUid = existingPhoneUser.uid; + await auth.deleteUser(existingPhoneUser.uid); + } + + const updatePayload = {}; + if (current.displayName !== displayName) updatePayload.displayName = displayName; + if (current.email !== email) updatePayload.email = email; + if (current.phoneNumber !== phoneNumber) updatePayload.phoneNumber = phoneNumber; + + if (Object.keys(updatePayload).length > 0) { + await auth.updateUser(uid, updatePayload); + } + + const reconciled = await auth.getUser(uid); + return { + uid: reconciled.uid, + email: reconciled.email, + phoneNumber: reconciled.phoneNumber, + deletedConflictingUid, + }; +} + async function main() { const owner = await ensureUser({ email: ownerEmail, @@ -53,8 +105,15 @@ async function main() { displayName: 'Ana Barista V2', }); + const reconciledStaff = await reconcileStaffPhoneIdentity({ + uid: staff.uid, + email: staff.email, + displayName: staff.displayName, + phoneNumber: staffPhone, + }); + // eslint-disable-next-line no-console - console.log(JSON.stringify({ owner, staff }, null, 2)); + console.log(JSON.stringify({ owner, staff: { ...staff, ...reconciledStaff } }, null, 2)); } main().catch((error) => { diff --git a/backend/unified-api/scripts/live-smoke-v2-unified.mjs b/backend/unified-api/scripts/live-smoke-v2-unified.mjs index b6cd2402..61be8e53 100644 --- a/backend/unified-api/scripts/live-smoke-v2-unified.mjs +++ b/backend/unified-api/scripts/live-smoke-v2-unified.mjs @@ -631,6 +631,10 @@ async function main() { assert.ok(Array.isArray(todaysShifts.items)); const assignedTodayShift = todaysShifts.items.find((shift) => shift.shiftId === fixture.shifts.assigned.id); assert.ok(assignedTodayShift); + assert.equal(assignedTodayShift.clientName, fixture.business.name); + assert.equal(typeof assignedTodayShift.hourlyRate, 'number'); + assert.equal(typeof assignedTodayShift.latitude, 'number'); + assert.equal(typeof assignedTodayShift.longitude, 'number'); assert.equal(assignedTodayShift.clockInMode, fixture.shifts.assigned.clockInMode); assert.equal(assignedTodayShift.allowClockInOverride, fixture.shifts.assigned.allowClockInOverride); logStep('staff.clock-in.shifts-today.ok', { count: todaysShifts.items.length }); diff --git a/docs/BACKEND/API_GUIDES/V2/authentication.md b/docs/BACKEND/API_GUIDES/V2/authentication.md index d90e585a..c45cf5de 100644 --- a/docs/BACKEND/API_GUIDES/V2/authentication.md +++ b/docs/BACKEND/API_GUIDES/V2/authentication.md @@ -182,6 +182,13 @@ Possible response A: This is the normal mobile path when frontend does **not** send recaptcha or integrity tokens. +Current dev demo worker: + +- phone number: `+15557654321` +- email: `ana.barista+v2@krowd.com` + +Those two now resolve to the same Firebase user and the same seeded staff profile in v2. + Possible response B: ```json diff --git a/docs/BACKEND/API_GUIDES/V2/unified-api.md b/docs/BACKEND/API_GUIDES/V2/unified-api.md index c9778dea..aea858ec 100644 --- a/docs/BACKEND/API_GUIDES/V2/unified-api.md +++ b/docs/BACKEND/API_GUIDES/V2/unified-api.md @@ -114,6 +114,31 @@ Full auth behavior, including staff phone flow and refresh rules, is documented - `GET /staff/faqs` - `GET /staff/faqs/search` +Example `GET /staff/clock-in/shifts/today` item: + +```json +{ + "assignmentId": "uuid", + "shiftId": "uuid", + "title": "Assigned espresso shift", + "clientName": "Google Mountain View Cafes", + "hourlyRate": 23, + "roleName": "Barista", + "location": "Google MV Cafe Clock Point", + "locationAddress": "1600 Amphitheatre Pkwy, Mountain View, CA", + "latitude": 37.4221, + "longitude": -122.0841, + "startTime": "2026-03-17T13:48:23.482Z", + "endTime": "2026-03-17T21:48:23.482Z", + "clockInMode": "GEO_REQUIRED", + "allowClockInOverride": true, + "geofenceRadiusMeters": 120, + "nfcTagId": "NFC-DEMO-ANA-001", + "attendanceStatus": "NOT_CLOCKED_IN", + "clockInAt": null +} +``` + ### Staff writes - `POST /staff/profile/setup`