From ec880007d0ac89509ae544df01cce94165e61439 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 13 Mar 2026 11:55:59 -0400 Subject: [PATCH] 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), + ], + ), + ); + } +}