diff --git a/apps/mobile/apps/staff/macos/Flutter/ephemeral/Flutter-Generated.xcconfig b/apps/mobile/apps/staff/macos/Flutter/ephemeral/Flutter-Generated.xcconfig index f1704fe8..cbd978e0 100644 --- a/apps/mobile/apps/staff/macos/Flutter/ephemeral/Flutter-Generated.xcconfig +++ b/apps/mobile/apps/staff/macos/Flutter/ephemeral/Flutter-Generated.xcconfig @@ -1,11 +1,13 @@ // This is a generated file; do not edit or check into version control. -FLUTTER_ROOT=C:\flutter\src\flutter -FLUTTER_APPLICATION_PATH=E:\Krow-google\krow-workforce\apps\mobile\apps\staff +FLUTTER_ROOT=/Users/achinthaisuru/Documents/flutter +FLUTTER_APPLICATION_PATH=/Users/achinthaisuru/Documents/Github/krow-workforce/apps/mobile/apps/staff COCOAPODS_PARALLEL_CODE_SIGN=true +FLUTTER_TARGET=/Users/achinthaisuru/Documents/Github/krow-workforce/apps/mobile/apps/staff/lib/main.dart FLUTTER_BUILD_DIR=build -FLUTTER_BUILD_NAME=1.0.0 +FLUTTER_BUILD_NAME=0.0.13 FLUTTER_BUILD_NUMBER=1 +DART_DEFINES=RkxVVFRFUl9WRVJTSU9OPTMuMzguNw==,RkxVVFRFUl9DSEFOTkVMPXN0YWJsZQ==,RkxVVFRFUl9HSVRfVVJMPWh0dHBzOi8vZ2l0aHViLmNvbS9mbHV0dGVyL2ZsdXR0ZXIuZ2l0,RkxVVFRFUl9GUkFNRVdPUktfUkVWSVNJT049M2I2MmVmYzJhMw==,RkxVVFRFUl9FTkdJTkVfUkVWSVNJT049NzhmYzMwMTJlNA==,RkxVVFRFUl9EQVJUX1ZFUlNJT049My4xMC43 DART_OBFUSCATION=false TRACK_WIDGET_CREATION=true TREE_SHAKE_ICONS=false -PACKAGE_CONFIG=.dart_tool/package_config.json +PACKAGE_CONFIG=/Users/achinthaisuru/Documents/Github/krow-workforce/apps/mobile/.dart_tool/package_config.json diff --git a/apps/mobile/apps/staff/macos/Flutter/ephemeral/flutter_export_environment.sh b/apps/mobile/apps/staff/macos/Flutter/ephemeral/flutter_export_environment.sh index 781de8e3..651a3408 100755 --- a/apps/mobile/apps/staff/macos/Flutter/ephemeral/flutter_export_environment.sh +++ b/apps/mobile/apps/staff/macos/Flutter/ephemeral/flutter_export_environment.sh @@ -1,12 +1,14 @@ #!/bin/sh # This is a generated file; do not edit or check into version control. -export "FLUTTER_ROOT=C:\flutter\src\flutter" -export "FLUTTER_APPLICATION_PATH=E:\Krow-google\krow-workforce\apps\mobile\apps\staff" +export "FLUTTER_ROOT=/Users/achinthaisuru/Documents/flutter" +export "FLUTTER_APPLICATION_PATH=/Users/achinthaisuru/Documents/Github/krow-workforce/apps/mobile/apps/staff" export "COCOAPODS_PARALLEL_CODE_SIGN=true" +export "FLUTTER_TARGET=/Users/achinthaisuru/Documents/Github/krow-workforce/apps/mobile/apps/staff/lib/main.dart" export "FLUTTER_BUILD_DIR=build" -export "FLUTTER_BUILD_NAME=1.0.0" +export "FLUTTER_BUILD_NAME=0.0.13" export "FLUTTER_BUILD_NUMBER=1" +export "DART_DEFINES=RkxVVFRFUl9WRVJTSU9OPTMuMzguNw==,RkxVVFRFUl9DSEFOTkVMPXN0YWJsZQ==,RkxVVFRFUl9HSVRfVVJMPWh0dHBzOi8vZ2l0aHViLmNvbS9mbHV0dGVyL2ZsdXR0ZXIuZ2l0,RkxVVFRFUl9GUkFNRVdPUktfUkVWSVNJT049M2I2MmVmYzJhMw==,RkxVVFRFUl9FTkdJTkVfUkVWSVNJT049NzhmYzMwMTJlNA==,RkxVVFRFUl9EQVJUX1ZFUlNJT049My4xMC43" export "DART_OBFUSCATION=false" export "TRACK_WIDGET_CREATION=true" export "TREE_SHAKE_ICONS=false" -export "PACKAGE_CONFIG=.dart_tool/package_config.json" +export "PACKAGE_CONFIG=/Users/achinthaisuru/Documents/Github/krow-workforce/apps/mobile/.dart_tool/package_config.json" diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/strings.g.dart b/apps/mobile/packages/core_localization/lib/src/l10n/strings.g.dart index ba7ba879..150dd652 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/strings.g.dart +++ b/apps/mobile/packages/core_localization/lib/src/l10n/strings.g.dart @@ -6,7 +6,7 @@ /// Locales: 2 /// Strings: 1044 (522 per locale) /// -/// Built on 2026-01-30 at 23:09 UTC +/// Built on 2026-01-31 at 17:37 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_actions.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_actions.dart index 8d1ac228..a4596b2d 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_actions.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_actions.dart @@ -1,17 +1,10 @@ -import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'package:staff_authentication/staff_authentication.dart'; +import 'package:design_system/design_system.dart'; -/// A widget that displays the primary action buttons (Sign Up and Log In) -/// for the Get Started page. class GetStartedActions extends StatelessWidget { - /// Void callback for when the Sign Up button is pressed. final VoidCallback onSignUpPressed; - - /// Void callback for when the Log In button is pressed. final VoidCallback onLoginPressed; - /// Creates a [GetStartedActions]. const GetStartedActions({ super.key, required this.onSignUpPressed, @@ -22,19 +15,37 @@ class GetStartedActions extends StatelessWidget { Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Sign Up Button - UiButton.primary( - text: t.staff_authentication.get_started_page.sign_up_button, + children: [ + ElevatedButton( onPressed: onSignUpPressed, + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + 'Create Account', + style: UiTypography.buttonL.copyWith(color: Colors.white), + ), ), - - const SizedBox(height: 12), - - // Log In Button - UiButton.secondary( - text: t.staff_authentication.get_started_page.log_in_button, + const SizedBox(height: 16), + OutlinedButton( onPressed: onLoginPressed, + style: OutlinedButton.styleFrom( + foregroundColor: UiColors.primary, + side: const BorderSide(color: UiColors.primary), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + 'Log In', + style: UiTypography.buttonL.copyWith(color: UiColors.primary), + ), ), ], ); diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_background.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_background.dart index e7c33ba6..09836e61 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_background.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_background.dart @@ -1,49 +1,75 @@ -import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:design_system/design_system.dart'; -/// A widget that displays the background for the Get Started page. class GetStartedBackground extends StatelessWidget { - /// Creates a [GetStartedBackground]. const GetStartedBackground({super.key}); @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(top: 24.0), + return Container( + color: Colors.white, child: Column( - children: [ + children: [ + const SizedBox(height: 32), // Logo - Image.asset(UiImageAssets.logoBlue, height: 40), - + Image.asset( + UiImageAssets.logoBlue, + height: 40, + ), Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Hero Image - Container( - width: 288, - height: 288, - margin: const EdgeInsets.only(bottom: 24), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: UiColors.secondaryForeground.withAlpha( - 64, - ), // 0.5 opacity - ), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: ClipOval( - child: Image.network( - 'https://images.unsplash.com/photo-1577219491135-ce391730fb2c?w=400&h=400&fit=crop&crop=faces', - fit: BoxFit.cover, - ), - ), + child: Center( + child: Container( + width: 288, + height: 288, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFF3A4A5A).withOpacity(0.05), + ), + padding: const EdgeInsets.all(8.0), + child: ClipOval( + child: Image.network( + 'https://images.unsplash.com/photo-1577219491135-ce391730fb2c?w=400&h=400&fit=crop&crop=faces', + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Image.asset(UiImageAssets.logoBlue); + }, ), ), - const SizedBox(height: 16), - ], + ), ), ), + // Pagination dots (Visual only) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 24, + height: 8, + decoration: BoxDecoration( + color: UiColors.primary, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(width: 8), + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: UiColors.primary.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(width: 8), + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: UiColors.primary.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), ], ), ); diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_header.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_header.dart index 94528237..f6c940e1 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_header.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_header.dart @@ -1,37 +1,24 @@ import 'package:flutter/material.dart'; import 'package:design_system/design_system.dart'; -import 'package:staff_authentication/staff_authentication.dart'; -/// A widget that displays the welcome text and description on the Get Started page. class GetStartedHeader extends StatelessWidget { - /// Creates a [GetStartedHeader]. const GetStartedHeader({super.key}); @override Widget build(BuildContext context) { return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - RichText( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + 'Krow Workforce', + style: UiTypography.display1b.copyWith(color: UiColors.textPrimary), textAlign: TextAlign.center, - text: TextSpan( - style: UiTypography.displayM, - children: [ - TextSpan( - text: t.staff_authentication.get_started_page.title_part1, - ), - TextSpan( - text: t.staff_authentication.get_started_page.title_part2, - style: UiTypography.displayMb.textLink, - ), - ], - ), ), const SizedBox(height: 16), Text( - t.staff_authentication.get_started_page.subtitle, + 'Find flexible shifts that fit your schedule.', + style: UiTypography.body1r.copyWith(color: UiColors.textSecondary), textAlign: TextAlign.center, - style: UiTypography.body1r.textSecondary, ), ], ); 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 9c1eefc5..763d2943 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,4 +1,5 @@ import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:geolocator/geolocator.dart'; import '../../domain/usecases/get_todays_shift_usecase.dart'; import '../../domain/usecases/get_attendance_status_usecase.dart'; import '../../domain/usecases/clock_in_usecase.dart'; @@ -16,6 +17,11 @@ class ClockInBloc extends Bloc { final ClockOutUseCase _clockOut; final GetActivityLogUseCase _getActivityLog; + // 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; + ClockInBloc({ required GetTodaysShiftUseCase getTodaysShift, required GetAttendanceStatusUseCase getAttendanceStatus, @@ -33,6 +39,9 @@ class ClockInBloc extends Bloc { on(_onCheckIn); on(_onCheckOut); on(_onModeChanged); + on(_onRequestLocationPermission); + on(_onCommuteModeToggled); + on(_onLocationUpdated); add(ClockInPageLoaded()); } @@ -47,12 +56,20 @@ class ClockInBloc extends Bloc { final status = await _getAttendanceStatus(); final activity = await _getActivityLog(); + // Check permissions silently on load? Maybe better to wait for user interaction or specific event + // However, if shift exists, we might want to check permission state + emit(state.copyWith( status: ClockInStatus.success, todayShift: shift, attendance: status, activityLog: activity, )); + + if (shift != null && !status.isCheckedIn) { + add(RequestLocationPermission()); + } + } catch (e) { emit(state.copyWith( status: ClockInStatus.failure, @@ -61,6 +78,69 @@ class ClockInBloc extends Bloc { } } + Future _onRequestLocationPermission( + RequestLocationPermission event, + Emitter emit, + ) async { + try { + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + + final hasConsent = permission == LocationPermission.always || permission == LocationPermission.whileInUse; + + emit(state.copyWith(hasLocationConsent: hasConsent)); + + if (hasConsent) { + _startLocationUpdates(); + } + } catch (e) { + emit(state.copyWith(errorMessage: "Location error: $e")); + } + } + + void _startLocationUpdates() async { + try { + final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high); + final distance = Geolocator.distanceBetween( + position.latitude, + position.longitude, + venueLat, + venueLng, + ); + final isVerified = distance <= allowedRadiusMeters; + + if (!isClosed) { + add(LocationUpdated(position: position, distance: distance, isVerified: isVerified)); + } + } catch (e) { + // Handle error silently or via state + } + } + + 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()); + } + } + void _onDateSelected( DateSelected event, Emitter emit, @@ -79,6 +159,13 @@ class ClockInBloc extends Bloc { CheckInRequested event, Emitter emit, ) async { + // Only verify location if not using NFC (or depending on requirements) - enforcing for swipe + if (state.checkInMode == 'swipe' && !state.isLocationVerified) { + // Allow for now since coordinates are hardcoded and might not match user location + // emit(state.copyWith(errorMessage: "You must be at the location to clock in.")); + // return; + } + emit(state.copyWith(status: ClockInStatus.actionInProgress)); try { final newStatus = await _clockIn( 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 d35647fb..f7e397bf 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,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:geolocator/geolocator.dart'; abstract class ClockInEvent extends Equatable { const ClockInEvent(); @@ -46,3 +47,25 @@ class CheckInModeChanged extends ClockInEvent { @override List get props => [mode]; } + +class CommuteModeToggled extends ClockInEvent { + final bool isEnabled; + + const CommuteModeToggled(this.isEnabled); + + @override + List get props => [isEnabled]; +} + +class RequestLocationPermission extends ClockInEvent {} + +class LocationUpdated extends ClockInEvent { + final Position position; + final double distance; + final bool isVerified; + + const LocationUpdated({required this.position, required this.distance, required this.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 a1f4c876..eadc30a8 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,5 +1,7 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:geolocator/geolocator.dart'; + enum ClockInStatus { initial, loading, success, failure, actionInProgress } @@ -12,6 +14,13 @@ class ClockInState extends Equatable { final String checkInMode; final String? errorMessage; + final Position? currentLocation; + final double? distanceFromVenue; + final bool isLocationVerified; + final bool isCommuteModeOn; + final bool hasLocationConsent; + final int? etaMinutes; + const ClockInState({ this.status = ClockInStatus.initial, this.todayShift, @@ -20,6 +29,12 @@ 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, }); ClockInState copyWith({ @@ -30,6 +45,12 @@ 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, @@ -39,6 +60,12 @@ 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, ); } @@ -51,5 +78,11 @@ 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/pages/clock_in_page.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart index 4be6cc15..33459844 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 @@ -92,10 +92,13 @@ class _ClockInPageState extends State { if (todayShift != null) CommuteTracker( shift: todayShift, - hasLocationConsent: false, // Mock value - isCommuteModeOn: false, // Mock value - distanceMeters: 500, // Mock value for demo - etaMinutes: 8, // Mock value for demo + hasLocationConsent: state.hasLocationConsent, + isCommuteModeOn: state.isCommuteModeOn, + distanceMeters: state.distanceFromVenue, + etaMinutes: state.etaMinutes, + onCommuteToggled: (value) { + _bloc.add(CommuteModeToggled(value)); + }, ), // Date Selector DateSelector( @@ -183,9 +186,9 @@ class _ClockInPageState extends State { Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ - const Text( - "9:00 AM - 5:00 PM", - style: TextStyle( + Text( + "${_formatTime(todayShift.startTime)} - ${_formatTime(todayShift.endTime)}", + style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w500, color: Color(0xFF475569), // slate-600 @@ -207,36 +210,73 @@ class _ClockInPageState extends State { // Swipe To Check In / Checked Out State / No Shift State if (todayShift != null && checkOutTime == null) ...[ - SwipeToCheckIn( - isCheckedIn: isCheckedIn, - mode: state.checkInMode, - 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: todayShift.id), + if (!isCheckedIn && !_isCheckInAllowed(todayShift)) + Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), // slate-100 + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + const Icon( + LucideIcons.clock, + size: 48, + color: Color(0xFF94A3B8), // slate-400 + ), + const SizedBox(height: 16), + const Text( + "You're early!", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF475569), // slate-600 + ), + ), + const SizedBox(height: 4), + Text( + "Check-in available at ${_getCheckInAvailabilityTime(todayShift)}", + style: const TextStyle( + fontSize: 14, + color: Color(0xFF64748B), // slate-500 + ), + textAlign: TextAlign.center, + ), + ], + ), + ) + else + SwipeToCheckIn( + isCheckedIn: isCheckedIn, + mode: state.checkInMode, + 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: todayShift.id), + ); + } + }, + onCheckOut: () { + showDialog( + context: context, + builder: (context) => LunchBreakDialog( + onComplete: () { + Navigator.of( + context, + ).pop(); // Close dialog first + _bloc.add(const CheckOutRequested()); + }, + ), ); - } - }, - onCheckOut: () { - showDialog( - context: context, - builder: (context) => LunchBreakDialog( - onComplete: () { - Navigator.of( - context, - ).pop(); // Close dialog first - _bloc.add(const CheckOutRequested()); - }, - ), - ); - }, - ), + }, + ), ] else if (todayShift != null && checkOutTime != null) ...[ // Shift Completed State @@ -695,4 +735,43 @@ class _ClockInPageState extends State { _bloc.add(CheckInRequested(shiftId: _bloc.state.todayShift!.id)); } } + + // --- Helper Methods --- + + String _formatTime(String timeStr) { + // Expecting HH:mm or HH:mm:ss + try { + if (timeStr.isEmpty) return ''; + final parts = timeStr.split(':'); + final dt = DateTime(2022, 1, 1, int.parse(parts[0]), int.parse(parts[1])); + return DateFormat('h:mm a').format(dt); + } catch (e) { + return timeStr; + } + } + + bool _isCheckInAllowed(dynamic shift) { + if (shift == null) return false; + try { + // Parse shift date (e.g. 2024-01-31T09:00:00) + // The Shift entity has 'date' which is the start DateTime string + final shiftStart = DateTime.parse(shift.date); + final 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(dynamic shift) { + if (shift == null) return ''; + try { + final shiftStart = DateTime.parse(shift.date); + final windowStart = shiftStart.subtract(const Duration(minutes: 15)); + return DateFormat('h:mm a').format(windowStart); + } catch (e) { + return 'soon'; + } + } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart index e9701fe0..9d0b4131 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart @@ -14,6 +14,7 @@ enum CommuteMode { class CommuteTracker extends StatefulWidget { final Shift? shift; final Function(CommuteMode)? onModeChange; + final ValueChanged? onCommuteToggled; final bool hasLocationConsent; final bool isCommuteModeOn; final double? distanceMeters; @@ -23,6 +24,7 @@ class CommuteTracker extends StatefulWidget { super.key, this.shift, this.onModeChange, + this.onCommuteToggled, this.hasLocationConsent = false, this.isCommuteModeOn = false, this.distanceMeters, @@ -44,6 +46,21 @@ class _CommuteTrackerState extends State { _localIsCommuteOn = widget.isCommuteModeOn; } + @override + void didUpdateWidget(CommuteTracker oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isCommuteModeOn != oldWidget.isCommuteModeOn) { + setState(() { + _localIsCommuteOn = widget.isCommuteModeOn; + }); + } + if (widget.hasLocationConsent != oldWidget.hasLocationConsent) { + setState(() { + _localHasConsent = widget.hasLocationConsent; + }); + } + } + CommuteMode _getAppMode() { if (widget.shift == null) return CommuteMode.lockedNoShift; @@ -299,6 +316,7 @@ class _CommuteTrackerState extends State { value: _localIsCommuteOn, onChanged: (value) { setState(() => _localIsCommuteOn = value); + widget.onCommuteToggled?.call(value); }, activeColor: AppColors.krowBlue, ), diff --git a/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart b/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart index d5ec6910..a465db34 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart @@ -2,41 +2,75 @@ import 'package:firebase_data_connect/firebase_data_connect.dart'; import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_data_connect/src/session/staff_session_store.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:firebase_auth/firebase_auth.dart'; import '../../domain/repositories/payments_repository.dart'; class PaymentsRepositoryImpl implements PaymentsRepository { final dc.ExampleConnector _dataConnect; + final FirebaseAuth _auth = FirebaseAuth.instance; PaymentsRepositoryImpl() : _dataConnect = dc.ExampleConnector.instance; + String? _cachedStaffId; + + Future _getStaffId() async { + // 1. Check Session Store + final StaffSession? session = StaffSessionStore.instance.session; + if (session?.staff?.id != null) { + return session!.staff!.id; + } + + // 2. Check Cache + if (_cachedStaffId != null) return _cachedStaffId!; + + // 3. Fetch from Data Connect using Firebase UID + final user = _auth.currentUser; + if (user == null) { + throw Exception('User is not authenticated'); + } + + try { + final response = await _dataConnect.getStaffByUserId(userId: user.uid).execute(); + if (response.data.staffs.isNotEmpty) { + _cachedStaffId = response.data.staffs.first.id; + return _cachedStaffId!; + } + } catch (e) { + // Log or handle error + } + + // 4. Fallback + return user.uid; + } + /// Helper to convert Data Connect Timestamp to DateTime DateTime? _toDateTime(dynamic t) { if (t == null) return null; + if (t is DateTime) return t; + if (t is String) return DateTime.tryParse(t); + try { - // Attempt to deserialize via standard methods - return DateTime.tryParse(t.toJson() as String); - } catch (_) { - try { - return DateTime.tryParse(t.toString()); - } catch (e) { - return null; + if (t is Timestamp) { + return t.toDateTime(); } - } + } catch (_) {} + + try { + if (t.runtimeType.toString().contains('Timestamp')) { + return (t as dynamic).toDate(); + } + } catch (_) {} + + try { + return DateTime.tryParse(t.toString()); + } catch (_) {} + + return null; } @override Future getPaymentSummary() async { - final StaffSession? session = StaffSessionStore.instance.session; - if (session?.staff?.id == null) { - return const PaymentSummary( - weeklyEarnings: 0, - monthlyEarnings: 0, - pendingEarnings: 0, - totalEarnings: 0, - ); - } - - final String currentStaffId = session!.staff!.id; + final String currentStaffId = await _getStaffId(); // Fetch recent payments with a limit // Note: limit is chained on the query builder @@ -82,10 +116,7 @@ class PaymentsRepositoryImpl implements PaymentsRepository { @override Future> getPaymentHistory(String period) async { - final StaffSession? session = StaffSessionStore.instance.session; - if (session?.staff?.id == null) return []; - - final String currentStaffId = session!.staff!.id; + final String currentStaffId = await _getStaffId(); try { final QueryResult response = diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart index 2d867507..0df77068 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart @@ -210,7 +210,7 @@ class _PaymentsPageState extends State { ], ), - const SizedBox(height: 32), + const SizedBox(height: 100), ], ), ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart index b94a748f..4ffc3563 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart @@ -3,6 +3,7 @@ import 'package:krow_data_connect/src/session/staff_session_store.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:intl/intl.dart'; import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_data_connect/firebase_data_connect.dart'; import '../../domain/repositories/shifts_repository_interface.dart'; class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { @@ -16,31 +17,62 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { // Cache: ApplicationID -> RoleID (For Accept/Decline w/ Update mutation) final Map _appToRoleIdMap = {}; - String get _currentStaffId { + String? _cachedStaffId; + + Future _getStaffId() async { + // 1. Check Session Store final StaffSession? session = StaffSessionStore.instance.session; if (session?.staff?.id != null) { - return session!.staff!.id; + return session!.staff!.id; } - // Fallback? Or throw. - // If not logged in, we shouldn't be here. - return _auth.currentUser?.uid ?? 'STAFF_123'; + + // 2. Check Cache + if (_cachedStaffId != null) return _cachedStaffId!; + + // 3. Fetch from Data Connect using Firebase UID + final user = _auth.currentUser; + if (user == null) { + throw Exception('User is not authenticated'); + } + + try { + final response = await _dataConnect.getStaffByUserId(userId: user.uid).execute(); + if (response.data.staffs.isNotEmpty) { + _cachedStaffId = response.data.staffs.first.id; + return _cachedStaffId!; + } + } catch (e) { + // Log or handle error + } + + // 4. Fallback (should ideally not happen if DB is seeded) + return user.uid; } - /// Helper to convert Data Connect Timestamp to DateTime DateTime? _toDateTime(dynamic t) { if (t == null) return null; + if (t is DateTime) return t; + if (t is String) return DateTime.tryParse(t); + + // Data Connect Timestamp handling try { - if (t is String) return DateTime.tryParse(t); - // If it accepts toJson - try { - return DateTime.tryParse(t.toJson() as String); - } catch (_) {} - // If it's a Timestamp object (depends on SDK), usually .toDate() exists but 'dynamic' hides it. - // Assuming toString or toJson covers it, or using helper. - return DateTime.now(); // Placeholder if type unknown, but ideally fetch correct value - } catch (_) { - return null; - } + if (t is Timestamp) { + return t.toDateTime(); + } + } catch (_) {} + + try { + // Fallback for any object that might have a toDate or similar + if (t.runtimeType.toString().contains('Timestamp')) { + return (t as dynamic).toDate(); + } + } catch (_) {} + + try { + return DateTime.tryParse(t.toString()); + } catch (_) {} + + return null; } @override @@ -53,22 +85,49 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { return _fetchApplications(dc.ApplicationStatus.PENDING); } + @override + Future> getCancelledShifts() async { + return _fetchApplications(dc.ApplicationStatus.REJECTED); + } + + @override + Future> getHistoryShifts() async { + return _fetchApplications(dc.ApplicationStatus.CHECKED_OUT); + } + Future> _fetchApplications(dc.ApplicationStatus status) async { try { + final staffId = await _getStaffId(); final response = await _dataConnect - .getApplicationsByStaffId(staffId: _currentStaffId) + .getApplicationsByStaffId(staffId: staffId) .execute(); - final apps = response.data.applications.where((app) => app.status == status); + final apps = response.data.applications.where((app) => app.status.stringValue == status.name); final List shifts = []; for (final app in apps) { _shiftToAppIdMap[app.shift.id] = app.id; _appToRoleIdMap[app.id] = app.shiftRole.id; - final shiftTuple = await _getShiftDetails(app.shift.id); - if (shiftTuple != null) { - shifts.add(shiftTuple); + final shift = await _getShiftDetails(app.shift.id); + if (shift != null) { + // Override status to reflect the application state (e.g., CHECKED_OUT, ACCEPTED) + shifts.add(Shift( + id: shift.id, + title: shift.title, + clientName: shift.clientName, + logoUrl: shift.logoUrl, + hourlyRate: shift.hourlyRate, + location: shift.location, + locationAddress: shift.locationAddress, + date: shift.date, + startTime: shift.startTime, + endTime: shift.endTime, + createdDate: shift.createdDate, + status: _mapStatus(status), + description: shift.description, + durationDays: shift.durationDays, + )); } } return shifts; @@ -77,6 +136,22 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { } } + String _mapStatus(dc.ApplicationStatus status) { + switch (status) { + case dc.ApplicationStatus.ACCEPTED: + case dc.ApplicationStatus.CONFIRMED: + return 'confirmed'; + case dc.ApplicationStatus.PENDING: + return 'pending'; + case dc.ApplicationStatus.CHECKED_OUT: + return 'completed'; + case dc.ApplicationStatus.REJECTED: + return 'cancelled'; + default: + return 'open'; + } + } + @override Future> getAvailableShifts(String query, String type) async { try { @@ -104,8 +179,9 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '', endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '', createdDate: createdDt?.toIso8601String() ?? '', - status: s.status?.stringValue ?? 'OPEN', + status: s.status?.stringValue.toLowerCase() ?? 'open', description: s.description, + durationDays: s.durationDays, )); } @@ -152,6 +228,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { createdDate: createdDt?.toIso8601String() ?? '', status: s.status?.stringValue ?? 'OPEN', description: s.description, + durationDays: s.durationDays, ); } catch (e) { return null; @@ -165,9 +242,10 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { final role = rolesResult.data.shiftRoles.first; + final staffId = await _getStaffId(); await _dataConnect.createApplication( shiftId: shiftId, - staffId: _currentStaffId, + staffId: staffId, roleId: role.id, status: dc.ApplicationStatus.PENDING, origin: dc.ApplicationOrigin.STAFF, @@ -198,7 +276,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { roleId = _appToRoleIdMap[appId]; } else { // Fallback fetch - final apps = await _dataConnect.getApplicationsByStaffId(staffId: _currentStaffId).execute(); + final staffId = await _getStaffId(); + final apps = await _dataConnect.getApplicationsByStaffId(staffId: staffId).execute(); final app = apps.data.applications.where((a) => a.shiftId == shiftId).firstOrNull; if (app != null) { appId = app.id; diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart index e0c36133..f77844e5 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart @@ -25,4 +25,10 @@ abstract interface class ShiftsRepositoryInterface { /// Declines a pending shift assignment. Future declineShift(String shiftId); + + /// Retrieves shifts that were cancelled for the current user. + Future> getCancelledShifts(); + + /// Retrieves completed shifts for the current user. + Future> getHistoryShifts(); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/accept_shift_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/accept_shift_usecase.dart new file mode 100644 index 00000000..d3056716 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/accept_shift_usecase.dart @@ -0,0 +1,12 @@ +import 'package:krow_domain/krow_domain.dart'; +import '../repositories/shifts_repository_interface.dart'; + +class AcceptShiftUseCase { + final ShiftsRepositoryInterface repository; + + AcceptShiftUseCase(this.repository); + + Future call(String shiftId) async { + return repository.acceptShift(shiftId); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/decline_shift_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/decline_shift_usecase.dart new file mode 100644 index 00000000..7dbbee45 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/decline_shift_usecase.dart @@ -0,0 +1,12 @@ +import 'package:krow_domain/krow_domain.dart'; +import '../repositories/shifts_repository_interface.dart'; + +class DeclineShiftUseCase { + final ShiftsRepositoryInterface repository; + + DeclineShiftUseCase(this.repository); + + Future call(String shiftId) async { + return repository.declineShift(shiftId); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_cancelled_shifts_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_cancelled_shifts_usecase.dart new file mode 100644 index 00000000..47b82182 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_cancelled_shifts_usecase.dart @@ -0,0 +1,12 @@ +import 'package:krow_domain/krow_domain.dart'; +import '../repositories/shifts_repository_interface.dart'; + +class GetCancelledShiftsUseCase { + final ShiftsRepositoryInterface repository; + + GetCancelledShiftsUseCase(this.repository); + + Future> call() async { + return repository.getCancelledShifts(); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_history_shifts_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_history_shifts_usecase.dart new file mode 100644 index 00000000..7cb4066d --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_history_shifts_usecase.dart @@ -0,0 +1,12 @@ +import 'package:krow_domain/krow_domain.dart'; +import '../repositories/shifts_repository_interface.dart'; + +class GetHistoryShiftsUseCase { + final ShiftsRepositoryInterface repository; + + GetHistoryShiftsUseCase(this.repository); + + Future> call() async { + return repository.getHistoryShifts(); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_shift_details_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_shift_details_usecase.dart new file mode 100644 index 00000000..944203cf --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_shift_details_usecase.dart @@ -0,0 +1,14 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../repositories/shifts_repository_interface.dart'; + +class GetShiftDetailsUseCase extends UseCase { + final ShiftsRepositoryInterface repository; + + GetShiftDetailsUseCase(this.repository); + + @override + Future call(String params) { + return repository.getShiftDetails(params); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart index 9b33b7c4..d2f26c17 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart @@ -7,6 +7,10 @@ import '../../../domain/usecases/get_available_shifts_usecase.dart'; import '../../../domain/arguments/get_available_shifts_arguments.dart'; import '../../../domain/usecases/get_my_shifts_usecase.dart'; import '../../../domain/usecases/get_pending_assignments_usecase.dart'; +import '../../../domain/usecases/get_cancelled_shifts_usecase.dart'; +import '../../../domain/usecases/get_history_shifts_usecase.dart'; +import '../../../domain/usecases/accept_shift_usecase.dart'; +import '../../../domain/usecases/decline_shift_usecase.dart'; part 'shifts_event.dart'; part 'shifts_state.dart'; @@ -15,14 +19,24 @@ class ShiftsBloc extends Bloc { final GetMyShiftsUseCase getMyShifts; final GetAvailableShiftsUseCase getAvailableShifts; final GetPendingAssignmentsUseCase getPendingAssignments; + final GetCancelledShiftsUseCase getCancelledShifts; + final GetHistoryShiftsUseCase getHistoryShifts; + final AcceptShiftUseCase acceptShift; + final DeclineShiftUseCase declineShift; ShiftsBloc({ required this.getMyShifts, required this.getAvailableShifts, required this.getPendingAssignments, + required this.getCancelledShifts, + required this.getHistoryShifts, + required this.acceptShift, + required this.declineShift, }) : super(ShiftsInitial()) { on(_onLoadShifts); on(_onFilterAvailableShifts); + on(_onAcceptShift); + on(_onDeclineShift); } Future _onLoadShifts( @@ -39,6 +53,8 @@ class ShiftsBloc extends Bloc { try { final myShiftsResult = await getMyShifts(); final pendingResult = await getPendingAssignments(); + final cancelledResult = await getCancelledShifts(); + final historyResult = await getHistoryShifts(); // Initial available with defaults final availableResult = await getAvailableShifts(const GetAvailableShiftsArguments()); @@ -46,7 +62,9 @@ class ShiftsBloc extends Bloc { emit(ShiftsLoaded( myShifts: myShiftsResult, pendingShifts: pendingResult, + cancelledShifts: cancelledResult, availableShifts: availableResult, + historyShifts: historyResult, searchQuery: '', jobType: 'all', )); @@ -80,4 +98,28 @@ class ShiftsBloc extends Bloc { } } } + + Future _onAcceptShift( + AcceptShiftEvent event, + Emitter emit, + ) async { + try { + await acceptShift(event.shiftId); + add(LoadShiftsEvent()); // Reload lists + } catch (_) { + // Handle error + } + } + + Future _onDeclineShift( + DeclineShiftEvent event, + Emitter emit, + ) async { + try { + await declineShift(event.shiftId); + add(LoadShiftsEvent()); // Reload lists + } catch (_) { + // Handle error + } + } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart index 41e01253..0ea1a6b1 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart @@ -19,3 +19,19 @@ class FilterAvailableShiftsEvent extends ShiftsEvent { @override List get props => [query, jobType]; } + +class AcceptShiftEvent extends ShiftsEvent { + final String shiftId; + const AcceptShiftEvent(this.shiftId); + + @override + List get props => [shiftId]; +} + +class DeclineShiftEvent extends ShiftsEvent { + final String shiftId; + const DeclineShiftEvent(this.shiftId); + + @override + List get props => [shiftId]; +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart index c6051cea..31083223 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart @@ -15,14 +15,18 @@ class ShiftsLoading extends ShiftsState {} class ShiftsLoaded extends ShiftsState { final List myShifts; final List pendingShifts; + final List cancelledShifts; final List availableShifts; + final List historyShifts; final String searchQuery; final String jobType; const ShiftsLoaded({ required this.myShifts, required this.pendingShifts, + required this.cancelledShifts, required this.availableShifts, + required this.historyShifts, required this.searchQuery, required this.jobType, }); @@ -30,21 +34,33 @@ class ShiftsLoaded extends ShiftsState { ShiftsLoaded copyWith({ List? myShifts, List? pendingShifts, + List? cancelledShifts, List? availableShifts, + List? historyShifts, String? searchQuery, String? jobType, }) { return ShiftsLoaded( myShifts: myShifts ?? this.myShifts, pendingShifts: pendingShifts ?? this.pendingShifts, + cancelledShifts: cancelledShifts ?? this.cancelledShifts, availableShifts: availableShifts ?? this.availableShifts, + historyShifts: historyShifts ?? this.historyShifts, searchQuery: searchQuery ?? this.searchQuery, jobType: jobType ?? this.jobType, ); } @override - List get props => [myShifts, pendingShifts, availableShifts, searchQuery, jobType]; + List get props => [ + myShifts, + pendingShifts, + cancelledShifts, + availableShifts, + historyShifts, + searchQuery, + jobType, + ]; } class ShiftsError extends ShiftsState { 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 fdb92535..c62b3b15 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 @@ -4,6 +4,10 @@ import 'package:lucide_icons/lucide_icons.dart'; import 'package:intl/intl.dart'; import 'package:design_system/design_system.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; +import '../../domain/usecases/get_shift_details_usecase.dart'; +import '../../domain/usecases/accept_shift_usecase.dart'; +import '../../domain/usecases/decline_shift_usecase.dart'; // Shim to match POC styles locally class AppColors { @@ -32,11 +36,7 @@ class _ShiftDetailsPageState extends State { bool _showDetails = true; bool _isApplying = false; - // Mock Managers - final List> _managers = [ - {'name': 'John Smith', 'phone': '+1 123 456 7890'}, - {'name': 'Jane Doe', 'phone': '+1 123 456 7890'}, - ]; + @override void initState() { @@ -49,27 +49,30 @@ class _ShiftDetailsPageState extends State { _shift = widget.shift!; setState(() => _isLoading = false); } else { - await Future.delayed(const Duration(milliseconds: 500)); - if (mounted) { - // Fallback mock shift - setState(() { - _shift = Shift( - id: widget.shiftId, - title: 'Event Server', - clientName: 'Grand Hotel', - logoUrl: null, - hourlyRate: 25.0, - date: DateFormat('yyyy-MM-dd').format(DateTime.now()), - startTime: '16:00', - endTime: '22:00', - location: 'Downtown', - locationAddress: '123 Main St, New York, NY', - status: 'open', - createdDate: DateTime.now().toIso8601String(), - description: 'Provide exceptional customer service. Respond to guest requests or concerns promptly and professionally.', - ); - _isLoading = false; - }); + try { + final useCase = Modular.get(); + final shift = await useCase(widget.shiftId); + if (mounted) { + if (shift != null) { + setState(() { + _shift = shift; + _isLoading = false; + }); + } else { + // Handle case where shift is not found + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Shift not found')), + ); + } + } + } catch (e) { + if (mounted) { + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error loading shift: $e')), + ); + } } } } @@ -143,6 +146,7 @@ class _ShiftDetailsPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ // Pending Badge + // Status Badge Align( alignment: Alignment.centerRight, child: Container( @@ -151,15 +155,15 @@ class _ShiftDetailsPageState extends State { vertical: 4, ), decoration: BoxDecoration( - color: AppColors.krowYellow.withOpacity(0.3), + color: _getStatusColor(_shift.status ?? 'open').withOpacity(0.1), borderRadius: BorderRadius.circular(20), ), - child: const Text( - 'Pending 6h ago', + child: Text( + (_shift.status ?? 'open').toUpperCase(), style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, - color: AppColors.krowCharcoal, + color: _getStatusColor(_shift.status ?? 'open'), ), ), ), @@ -248,25 +252,7 @@ class _ShiftDetailsPageState extends State { ), const SizedBox(height: 16), - // Tags - Row( - children: [ - _buildTag( - LucideIcons.zap, - 'Immediate start', - AppColors.krowBlue.withOpacity(0.1), - AppColors.krowBlue, - ), - const SizedBox(width: 8), - _buildTag( - LucideIcons.star, - 'No experience', - AppColors.krowYellow.withOpacity(0.3), - AppColors.krowCharcoal, - ), - ], - ), - const SizedBox(height: 24), + // Additional Details Collapsible Container( @@ -310,11 +296,11 @@ class _ShiftDetailsPageState extends State { padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: Column( children: [ - _buildDetailRow('Tips', 'Yes', true), - _buildDetailRow('Travel Time', 'Yes', true), - _buildDetailRow('Meal Provided', 'No', false), - _buildDetailRow('Parking Available', 'Yes', true), - _buildDetailRow('Gas Compensation', 'No', false), + _buildDetailRow('Tips', _shift.tipsAvailable == true ? 'Yes' : 'No', _shift.tipsAvailable == true), + _buildDetailRow('Travel Time', _shift.travelTime == true ? 'Yes' : 'No', _shift.travelTime == true), + _buildDetailRow('Meal Provided', _shift.mealProvided == true ? 'Yes' : 'No', _shift.mealProvided == true), + _buildDetailRow('Parking Available', _shift.parkingAvailable == true ? 'Yes' : 'No', _shift.parkingAvailable == true), + _buildDetailRow('Gas Compensation', _shift.gasCompensation == true ? 'Yes' : 'No', _shift.gasCompensation == true), ], ), ), @@ -550,7 +536,7 @@ class _ShiftDetailsPageState extends State { ), ), const SizedBox(height: 16), - ..._managers + ...(_shift.managers ?? []) .map( (manager) => Padding( padding: const EdgeInsets.only(bottom: 16), @@ -574,13 +560,7 @@ class _ShiftDetailsPageState extends State { 8, ), ), - child: const Center( - child: Icon( - LucideIcons.user, - color: Colors.white, - size: 20, - ), - ), + child: _buildAvatar(manager), ), const SizedBox(width: 12), Column( @@ -588,14 +568,14 @@ class _ShiftDetailsPageState extends State { CrossAxisAlignment.start, children: [ Text( - manager['name']!, + manager.name, style: const TextStyle( fontWeight: FontWeight.w600, color: AppColors.krowCharcoal, ), ), Text( - manager['phone']!, + manager.phone, style: const TextStyle( fontSize: 12, color: AppColors.krowMuted, @@ -611,7 +591,7 @@ class _ShiftDetailsPageState extends State { context, ).showSnackBar( SnackBar( - content: Text(manager['phone']!), + content: Text(manager.phone), duration: const Duration(seconds: 3), ), ); @@ -702,16 +682,32 @@ class _ShiftDetailsPageState extends State { child: ElevatedButton( onPressed: () async { setState(() => _isApplying = true); - await Future.delayed(const Duration(seconds: 1)); - if (mounted) { - setState(() => _isApplying = false); - Modular.to.pop(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Shift Accepted!'), - backgroundColor: Color(0xFF10B981), - ), - ); + try { + final acceptUseCase = Modular.get(); + await acceptUseCase(_shift.id); + + if (mounted) { + setState(() => _isApplying = false); + Modular.to.pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Shift Accepted!'), + backgroundColor: Color(0xFF10B981), + ), + ); + // Ideally, trigger a refresh on the previous screen + Modular.get().add(LoadShiftsEvent()); + } + } catch (e) { + if (mounted) { + setState(() => _isApplying = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to accept shift: $e'), + backgroundColor: const Color(0xFFEF4444), + ), + ); + } } }, style: ElevatedButton.styleFrom( @@ -744,7 +740,33 @@ class _ShiftDetailsPageState extends State { width: double.infinity, height: 48, child: TextButton( - onPressed: () => Modular.to.pop(), + onPressed: () async { + try { + final declineUseCase = Modular.get(); + await declineUseCase(_shift.id); + + if (mounted) { + Modular.to.pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Shift Declined'), + backgroundColor: Color(0xFFEF4444), + ), + ); + // Refresh list + Modular.get().add(LoadShiftsEvent()); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to decline shift: $e'), + backgroundColor: const Color(0xFFEF4444), + ), + ); + } + } + }, child: const Text( 'Decline shift', style: TextStyle( @@ -789,6 +811,39 @@ class _ShiftDetailsPageState extends State { ); } + Color _getStatusColor(String status) { + switch (status.toLowerCase()) { + case 'confirmed': + case 'accepted': + return const Color(0xFF10B981); // Green + case 'pending': + return const Color(0xFFF59E0B); // Yellow + case 'cancelled': + case 'rejected': + return const Color(0xFFEF4444); // Red + case 'completed': + return const Color(0xFF10B981); + default: + return AppColors.krowBlue; + } + } + + Widget _buildAvatar(ShiftManager manager) { + if (manager.avatar != null && manager.avatar!.isNotEmpty) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network(manager.avatar!, fit: BoxFit.cover), + ); + } + return const Center( + child: Icon( + LucideIcons.user, + color: Colors.white, + size: 20, + ), + ); + } + Widget _buildDetailRow(String label, String value, bool isPositive) { return Padding( padding: const EdgeInsets.symmetric(vertical: 6), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart index 0458fd33..adf0e07e 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart @@ -75,16 +75,22 @@ class _ShiftsPageState extends State { } void _confirmShift(String id) { - // TODO: Implement Bloc event + _bloc.add(AcceptShiftEvent(id)); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Shift confirmed! (Placeholder)')), + const SnackBar( + content: Text('Shift confirmed!'), + backgroundColor: Color(0xFF10B981), + ), ); } void _declineShift(String id) { - // TODO: Implement Bloc event + _bloc.add(DeclineShiftEvent(id)); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Shift declined. (Placeholder)')), + const SnackBar( + content: Text('Shift declined.'), + backgroundColor: Color(0xFFEF4444), + ), ); } @@ -97,9 +103,10 @@ class _ShiftsPageState extends State { final List myShifts = (state is ShiftsLoaded) ? state.myShifts : []; final List availableJobs = (state is ShiftsLoaded) ? state.availableShifts : []; final List pendingAssignments = (state is ShiftsLoaded) ? state.pendingShifts : []; - final List historyShifts = []; // Not in state yet, placeholder + final List cancelledShifts = (state is ShiftsLoaded) ? state.cancelledShifts : []; + final List historyShifts = (state is ShiftsLoaded) ? state.historyShifts : []; - // Filter logic from POC + // Filter logic final filteredJobs = availableJobs.where((s) { final matchesSearch = s.title.toLowerCase().contains(_searchQuery.toLowerCase()) || @@ -110,10 +117,9 @@ class _ShiftsPageState extends State { if (_jobType == 'all') return true; if (_jobType == 'one-day') { - return !s.title.contains('Long Term') && !s.title.contains('Multi-Day'); + return s.durationDays == null || s.durationDays! <= 1; } - if (_jobType == 'multi-day') return s.title.contains('Multi-Day'); - if (_jobType == 'long-term') return s.title.contains('Long Term'); + if (_jobType == 'multi-day') return s.durationDays != null && s.durationDays! > 1; return true; }).toList(); @@ -122,13 +128,23 @@ class _ShiftsPageState extends State { final weekEndDate = calendarDays.last; final visibleMyShifts = myShifts.where((s) { - // Primitive check if shift date string compare - // In real app use DateTime logic - final sDateStr = s.date; - final wStartStr = DateFormat('yyyy-MM-dd').format(weekStartDate); - final wEndStr = DateFormat('yyyy-MM-dd').format(weekEndDate); - return sDateStr.compareTo(wStartStr) >= 0 && - sDateStr.compareTo(wEndStr) <= 0; + try { + final date = DateTime.parse(s.date); + return date.isAfter(weekStartDate.subtract(const Duration(seconds: 1))) && + date.isBefore(weekEndDate.add(const Duration(days: 1))); + } catch (_) { + return false; + } + }).toList(); + + final visibleCancelledShifts = cancelledShifts.where((s) { + try { + final date = DateTime.parse(s.date); + return date.isAfter(weekStartDate.subtract(const Duration(seconds: 1))) && + date.isBefore(weekEndDate.add(const Duration(days: 1))); + } catch (_) { + return false; + } }).toList(); return Scaffold( @@ -140,9 +156,9 @@ class _ShiftsPageState extends State { color: AppColors.krowBlue, padding: EdgeInsets.fromLTRB( 20, - MediaQuery.of(context).padding.top + 20, + MediaQuery.of(context).padding.top + 10, + 20, 20, - 24, ), child: Column( children: [ @@ -157,17 +173,28 @@ class _ShiftsPageState extends State { color: Colors.white, ), ), + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Center( + child: Icon(UiIcons.user, size: 20, color: Colors.white), + ), + ), ], ), const SizedBox(height: 16), // Tabs Row( children: [ - _buildTab("myshifts", "My Shifts", LucideIcons.calendar, myShifts.length), + _buildTab("myshifts", "My Shifts", UiIcons.calendar, myShifts.length), const SizedBox(width: 8), - _buildTab("find", "Find Shifts", LucideIcons.search, filteredJobs.length), + _buildTab("find", "Find Shifts", UiIcons.search, filteredJobs.length), const SizedBox(width: 8), - _buildTab("history", "History", LucideIcons.clock, historyShifts.length), + _buildTab("history", "History", UiIcons.clock, historyShifts.length), ], ), ], @@ -178,21 +205,19 @@ class _ShiftsPageState extends State { if (_activeTab == 'myshifts') Container( color: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20), + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), child: Column( children: [ Padding( - padding: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.only(bottom: 12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - InkWell( - onTap: () => setState(() => _weekOffset--), - borderRadius: BorderRadius.circular(20), - child: const Padding( - padding: EdgeInsets.all(8.0), - child: Icon(LucideIcons.chevronLeft, size: 20, color: AppColors.krowCharcoal), - ), + IconButton( + icon: const Icon(UiIcons.chevronLeft, size: 20, color: AppColors.krowCharcoal), + onPressed: () => setState(() => _weekOffset--), + constraints: const BoxConstraints(), + padding: EdgeInsets.zero, ), Text( DateFormat('MMMM yyyy').format(weekStartDate), @@ -202,13 +227,11 @@ class _ShiftsPageState extends State { color: AppColors.krowCharcoal, ), ), - InkWell( - onTap: () => setState(() => _weekOffset++), - borderRadius: BorderRadius.circular(20), - child: const Padding( - padding: EdgeInsets.all(8.0), - child: Icon(LucideIcons.chevronRight, size: 20, color: AppColors.krowCharcoal), - ), + IconButton( + icon: const Icon(UiIcons.chevronRight, size: 20, color: AppColors.krowCharcoal), + onPressed: () => setState(() => _weekOffset++), + constraints: const BoxConstraints(), + padding: EdgeInsets.zero, ), ], ), @@ -219,52 +242,60 @@ class _ShiftsPageState extends State { children: calendarDays.map((date) { final isSelected = _isSameDay(date, _selectedDate); final dateStr = DateFormat('yyyy-MM-dd').format(date); - final hasShifts = myShifts.any((s) => s.date == dateStr); + final hasShifts = myShifts.any((s) { + try { + return _isSameDay(DateTime.parse(s.date), date); + } catch (_) { return false; } + }); return GestureDetector( onTap: () => setState(() => _selectedDate = date), - child: Container( - width: 44, - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: isSelected ? AppColors.krowBlue : Colors.white, - borderRadius: BorderRadius.circular(999), - border: Border.all( - color: isSelected ? AppColors.krowBlue : AppColors.krowBorder, - width: 1, - ), - ), - child: Column( - children: [ - Text( - date.day.toString().padLeft(2, '0'), - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: isSelected ? Colors.white : AppColors.krowCharcoal, + child: Column( + children: [ + Container( + width: 44, + height: 60, + decoration: BoxDecoration( + color: isSelected ? AppColors.krowBlue : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? AppColors.krowBlue : AppColors.krowBorder, + width: 1, ), ), - const SizedBox(height: 2), - Text( - DateFormat('E').format(date), - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w500, - color: isSelected ? Colors.white.withOpacity(0.8) : AppColors.krowMuted, - ), - ), - if (hasShifts) - Container( - margin: const EdgeInsets.only(top: 4), - width: 6, - height: 6, - decoration: BoxDecoration( - color: isSelected ? Colors.white : AppColors.krowBlue, - shape: BoxShape.circle, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + date.day.toString().padLeft(2, '0'), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: isSelected ? Colors.white : AppColors.krowCharcoal, + ), ), - ), - ], - ), + Text( + DateFormat('E').format(date), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: isSelected ? Colors.white.withOpacity(0.8) : AppColors.krowMuted, + ), + ), + if (hasShifts && !isSelected) + Container( + margin: const EdgeInsets.only(top: 4), + width: 4, + height: 4, + decoration: const BoxDecoration( + color: AppColors.krowBlue, + shape: BoxShape.circle, + ), + ), + ], + ), + ), + ], ), ); }).toList(), @@ -276,141 +307,177 @@ class _ShiftsPageState extends State { if (_activeTab == 'myshifts') const Divider(height: 1, color: AppColors.krowBorder), + // Search and Filters for Find Tab (Fixed at top) + if (_activeTab == 'find') + Container( + color: Colors.white, + padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), + child: Column( + children: [ + // Search Bar + Row( + children: [ + Expanded( + child: Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + children: [ + const Icon(UiIcons.search, size: 20, color: Color(0xFF94A3B8)), + const SizedBox(width: 10), + Expanded( + child: TextField( + onChanged: (v) => setState(() => _searchQuery = v), + decoration: const InputDecoration( + border: InputBorder.none, + hintText: "Search jobs, location...", + hintStyle: TextStyle( + color: Color(0xFF94A3B8), + fontSize: 14, + ), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(width: 8), + Container( + height: 48, + width: 48, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: const Icon(UiIcons.filter, size: 18, color: Color(0xFF64748B)), + ), + ], + ), + const SizedBox(height: 16), + // Filter Tabs + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildFilterTab('all', 'All Jobs'), + const SizedBox(width: 8), + _buildFilterTab('one-day', 'One Day'), + const SizedBox(width: 8), + _buildFilterTab('multi-day', 'Multi-Day'), + const SizedBox(width: 8), + _buildFilterTab('long-term', 'Long Term'), + ], + ), + ), + ], + ), + ), + // Body Content Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(20), + child: state is ShiftsLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), child: Column( children: [ - if (_activeTab == 'find') ...[ - // Search & Filter - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Container( - height: 48, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppColors.krowBorder), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: TextField( - onChanged: (val) => setState(() => _searchQuery = val), // Local filter for now - decoration: const InputDecoration( - prefixIcon: Icon(LucideIcons.search, size: 20, color: AppColors.krowMuted), - border: InputBorder.none, - hintText: "Search jobs...", - hintStyle: TextStyle(color: AppColors.krowMuted, fontSize: 14), - contentPadding: EdgeInsets.symmetric(vertical: 12), - ), - ), - ), - ), - Container( - margin: const EdgeInsets.only(bottom: 16), - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: const Color(0xFFF1F3F5), - borderRadius: BorderRadius.circular(999), - ), - child: Row( - children: [ - _buildFilterTab('all', 'All Jobs'), - _buildFilterTab('one-day', 'One Day'), - _buildFilterTab('multi-day', 'Multi-Day'), - _buildFilterTab('long-term', 'Long Term'), - ], - ), - ), - ], - + const SizedBox(height: 20), if (_activeTab == 'myshifts') ...[ if (pendingAssignments.isNotEmpty) ...[ - Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - children: [ - Container(width: 8, height: 8, decoration: const BoxDecoration(color: Color(0xFFF59E0B), shape: BoxShape.circle)), - const SizedBox(width: 8), - const Text("Awaiting Confirmation", style: TextStyle( - fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFFD97706) - )), - ], - ), - ), - ), + _buildSectionHeader("Awaiting Confirmation", const Color(0xFFF59E0B)), ...pendingAssignments.map((shift) => Padding( padding: const EdgeInsets.only(bottom: 16), child: ShiftAssignmentCard( shift: shift, onConfirm: () => _confirmShift(shift.id), onDecline: () => _declineShift(shift.id), + isConfirming: true, ), )), + const SizedBox(height: 12), ], - // Cancelled Shifts Demo (Visual only as per POC) - Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.only(bottom: 12), - child: const Text("Cancelled Shifts", style: TextStyle( - fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.krowMuted - )), - ), - ), - _buildCancelledCard( - title: "Annual Tech Conference", client: "TechCorp Inc.", pay: "\$200", rate: "\$25/hr · 8h", - date: "Today", time: "10:00 AM - 6:00 PM", address: "123 Convention Center Dr", isLastMinute: true, - onTap: () => setState(() => _cancelledShiftDemo = 'lastMinute') - ), - const SizedBox(height: 12), - _buildCancelledCard( - title: "Morning Catering Setup", client: "EventPro Services", pay: "\$120", rate: "\$20/hr · 6h", - date: "Tomorrow", time: "8:00 AM - 2:00 PM", address: "456 Grand Ballroom Ave", isLastMinute: false, - onTap: () => setState(() => _cancelledShiftDemo = 'advance') - ), - const SizedBox(height: 24), + if (visibleCancelledShifts.isNotEmpty) ...[ + _buildSectionHeader("Cancelled Shifts", AppColors.krowMuted), + ...visibleCancelledShifts.map((shift) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _buildCancelledCard( + title: shift.title, + client: shift.clientName, + pay: "\$${(shift.hourlyRate * 8).toStringAsFixed(0)}", + rate: "\$${shift.hourlyRate}/hr · 8h", + date: _formatDateStr(shift.date), + time: "${shift.startTime} - ${shift.endTime}", + address: shift.locationAddress, + isLastMinute: true, + onTap: () {} + ), + )), + const SizedBox(height: 12), + ], // Confirmed Shifts if (visibleMyShifts.isNotEmpty) ...[ - Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.only(bottom: 12), - child: const Text("Confirmed Shifts", style: TextStyle( - fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.krowMuted - )), - ), - ), + _buildSectionHeader("Confirmed Shifts", AppColors.krowMuted), ...visibleMyShifts.map((shift) => Padding( padding: const EdgeInsets.only(bottom: 12), child: MyShiftCard(shift: shift), )), ], + + if (visibleMyShifts.isEmpty && pendingAssignments.isEmpty && cancelledShifts.isEmpty) + _buildEmptyState(UiIcons.calendar, "No shifts this week", "Try finding new jobs in the Find tab", null, null), ], if (_activeTab == 'find') ...[ if (filteredJobs.isEmpty) - _buildEmptyState(LucideIcons.search, "No jobs available", "Check back later", null, null) + _buildEmptyState(UiIcons.search, "No jobs available", "Check back later", null, null) else - ...filteredJobs.map((shift) => MyShiftCard( - shift: shift, - onAccept: () {}, - onDecline: () {}, + ...filteredJobs.map((shift) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: MyShiftCard( + shift: shift, + onAccept: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Shift Booked!'), + backgroundColor: Color(0xFF10B981), + ), + ); + }, + onDecline: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Shift Declined'), + backgroundColor: Color(0xFFEF4444), + ), + ); + }, + ), )), ], - if (_activeTab == 'history') - _buildEmptyState(LucideIcons.clock, "No shift history", "Completed shifts appear here", null, null), + if (_activeTab == 'history') ...[ + if (historyShifts.isEmpty) + _buildEmptyState(UiIcons.clock, "No shift history", "Completed shifts appear here", null, null) + else + ...historyShifts.map((shift) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: MyShiftCard( + shift: shift, + historyMode: true, + ), + )), + ], + + const SizedBox(height: 40), ], ), ), @@ -423,21 +490,57 @@ class _ShiftsPageState extends State { ); } + String _formatDateStr(String dateStr) { + try { + final date = DateTime.parse(dateStr); + final now = DateTime.now(); + if (_isSameDay(date, now)) return "Today"; + final tomorrow = now.add(const Duration(days: 1)); + if (_isSameDay(date, tomorrow)) return "Tomorrow"; + return DateFormat('EEE, MMM d').format(date); + } catch (_) { + return dateStr; + } + } + + Widget _buildSectionHeader(String title, Color dotColor) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + children: [ + Container(width: 8, height: 8, decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle)), + const SizedBox(width: 8), + Text(title, style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: dotColor == AppColors.krowMuted ? AppColors.krowMuted : dotColor + )), + ], + ), + ); + } + Widget _buildFilterTab(String id, String label) { final isSelected = _jobType == id; - return Expanded( - child: GestureDetector( - onTap: () => setState(() => _jobType = id), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 8), - decoration: BoxDecoration( - color: isSelected ? AppColors.krowBlue : Colors.transparent, - borderRadius: BorderRadius.circular(999), - boxShadow: isSelected ? [BoxShadow(color: AppColors.krowBlue.withOpacity(0.2), blurRadius: 4, offset: const Offset(0, 2))] : null, + return GestureDetector( + onTap: () => setState(() => _jobType = id), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: isSelected ? AppColors.krowBlue : Colors.white, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: isSelected ? AppColors.krowBlue : const Color(0xFFE2E8F0), + ), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: isSelected ? Colors.white : const Color(0xFF64748B), ), - child: Text(label, textAlign: TextAlign.center, style: TextStyle( - fontSize: 11, fontWeight: FontWeight.w600, color: isSelected ? Colors.white : AppColors.krowMuted - )), ), ), ); @@ -478,17 +581,6 @@ class _ShiftsPageState extends State { ); } - Widget _buildDemoButton(String label, Color color, VoidCallback onTap) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(4)), - child: Text(label, style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.white)), - ), - ); - } - Widget _buildEmptyState(IconData icon, String title, String subtitle, String? actionLabel, VoidCallback? onAction) { return Center(child: Padding(padding: const EdgeInsets.symmetric(vertical: 64), child: Column(children: [ Container(width: 64, height: 64, decoration: BoxDecoration(color: const Color(0xFFF1F3F5), borderRadius: BorderRadius.circular(12)), child: Icon(icon, size: 32, color: AppColors.krowMuted)), @@ -506,98 +598,68 @@ class _ShiftsPageState extends State { Widget _buildCancelledCard({required String title, required String client, required String pay, required String rate, required String date, required String time, required String address, required bool isLastMinute, required VoidCallback onTap}) { return GestureDetector( onTap: onTap, - child: Container(padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.krowBorder)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [Container(width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFFEF4444), shape: BoxShape.circle)), const SizedBox(width: 6), const Text("CANCELLED", style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Color(0xFFEF4444))), if (isLastMinute) ...[const SizedBox(width: 4), const Text("• 4hr compensation", style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: Color(0xFF10B981)))]]), - const SizedBox(height: 12), - Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container(width: 44, height: 44, decoration: BoxDecoration(gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [AppColors.krowBlue.withAlpha((0.15 * 255).round()), AppColors.krowBlue.withAlpha((0.08 * 255).round())]), borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.krowBlue.withAlpha((0.15 * 255).round()))), child: const Center(child: Icon(LucideIcons.briefcase, color: AppColors.krowBlue, size: 20))), - const SizedBox(width: 12), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.krowCharcoal)), Text(client, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted))])), Column(crossAxisAlignment: CrossAxisAlignment.end, children: [Text(pay, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.krowCharcoal)), Text(rate, style: const TextStyle(fontSize: 10, color: AppColors.krowMuted))])]), - const SizedBox(height: 8), - Row(children: [const Icon(LucideIcons.calendar, size: 12, color: AppColors.krowMuted), const SizedBox(width: 4), Text(date, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted)), const SizedBox(width: 12), const Icon(LucideIcons.clock, size: 12, color: AppColors.krowMuted), const SizedBox(width: 4), Text(time, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted))]), - const SizedBox(height: 4), - Row(children: [const Icon(LucideIcons.mapPin, size: 12, color: AppColors.krowMuted), const SizedBox(width: 4), Expanded(child: Text(address, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted), overflow: TextOverflow.ellipsis))]), - ])), - ]), - ])), - ); - } - - void _showCancelledModal(String type) { - final isLastMinute = type == 'lastMinute'; - showDialog( - context: context, - builder: (context) => AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - title: Row( - children: [ - const Icon(LucideIcons.xCircle, color: Color(0xFFEF4444)), - const SizedBox(width: 8), - const Text("Shift Cancelled"), - ], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - "We're sorry, but the following shift has been cancelled by the client:", - style: TextStyle(fontSize: 14), - ), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.krowBorder) + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Container(width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFFEF4444), shape: BoxShape.circle)), + const SizedBox(width: 6), + const Text("CANCELLED", style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Color(0xFFEF4444))), + if (isLastMinute) ...[ + const SizedBox(width: 4), + const Text("• 4hr compensation", style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: Color(0xFF10B981))) + ] + ]), const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.grey.shade50, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade200), - ), - child: const Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("Annual Tech Conference", style: TextStyle(fontWeight: FontWeight.bold)), - Text("Today, 10:00 AM - 6:00 PM"), - ], - ), - ), - const SizedBox(height: 16), - if (isLastMinute) + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - padding: const EdgeInsets.all(12), + width: 44, + height: 44, decoration: BoxDecoration( - color: const Color(0xFFECFDF5), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFF10B981)), - ), - child: const Row( - children: [ - Icon(LucideIcons.checkCircle, color: Color(0xFF10B981), size: 16), - SizedBox(width: 8), - Expanded( - child: Text( - "You are eligible for 4hr cancellation compensation.", - style: TextStyle( - fontSize: 12, color: Color(0xFF065F46), fontWeight: FontWeight.w500), - ), - ), - ], - ), - ) - else - const Text( - "Reduced schedule at the venue. No compensation is due as this was cancelled more than 4 hours in advance.", - style: TextStyle(fontSize: 12, color: AppColors.krowMuted), + color: AppColors.krowBlue.withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + ), + child: const Center(child: Icon(LucideIcons.briefcase, color: AppColors.krowBlue, size: 20)) ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text("Close"), - ), - ], - ), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.krowCharcoal)), + Text(client, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted)) + ])), + Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Text(pay, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.krowCharcoal)), + Text(rate, style: const TextStyle(fontSize: 10, color: AppColors.krowMuted)) + ]) + ]), + const SizedBox(height: 8), + Row(children: [ + const Icon(LucideIcons.calendar, size: 12, color: AppColors.krowMuted), + const SizedBox(width: 4), + Text(date, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted)), + const SizedBox(width: 12), + const Icon(LucideIcons.clock, size: 12, color: AppColors.krowMuted), + const SizedBox(width: 4), + Text(time, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted)) + ]), + const SizedBox(height: 4), + Row(children: [ + const Icon(LucideIcons.mapPin, size: 12, color: AppColors.krowMuted), + const SizedBox(width: 4), + Expanded(child: Text(address, style: const TextStyle(fontSize: 12, color: AppColors.krowMuted), overflow: TextOverflow.ellipsis)) + ]), + ])), + ]), + ]), + ), ); } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart index e9e99b35..c24fa6c1 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart @@ -75,16 +75,19 @@ class _MyShiftCardState extends State { } String _getShiftType() { - // Check title for type indicators (for mock data) - if (widget.shift.title.contains('Long Term')) return t.staff_shifts.filter.long_term; - if (widget.shift.title.contains('Multi-Day')) return t.staff_shifts.filter.multi_day; + if (widget.shift.durationDays != null && widget.shift.durationDays! > 30) { + return t.staff_shifts.filter.long_term; + } + if (widget.shift.durationDays != null && widget.shift.durationDays! > 1) { + return t.staff_shifts.filter.multi_day; + } return t.staff_shifts.filter.one_day; } @override Widget build(BuildContext context) { - // ignore: unused_local_variable final duration = _calculateDuration(); + final estimatedTotal = (widget.shift.hourlyRate) * duration; // Status Logic String? status = widget.shift.status; @@ -168,7 +171,7 @@ class _MyShiftCardState extends State { ), Text( statusText, - style: UiTypography.display3r.copyWith( + style: UiTypography.footnote2b.copyWith( color: statusColor, letterSpacing: 0.5, ), @@ -187,9 +190,8 @@ class _MyShiftCardState extends State { ), child: Text( _getShiftType(), - style: UiTypography.display3r.copyWith( + style: UiTypography.footnote2m.copyWith( color: UiColors.primary, - fontWeight: FontWeight.w500, ), ), ), @@ -197,59 +199,167 @@ class _MyShiftCardState extends State { ], ), ), - - // Main Content + Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Date/Time Column + // Logo + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + UiColors.primary.withOpacity(0.09), + UiColors.primary.withOpacity(0.03), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: UiColors.primary.withOpacity(0.09), + ), + ), + child: widget.shift.logoUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.network( + widget.shift.logoUrl!, + fit: BoxFit.contain, + ), + ) + : const Center( + child: Icon( + UiIcons.briefcase, + color: UiColors.primary, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + + // Details Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - _formatDate(widget.shift.date), - style: UiTypography.display2m.copyWith( - color: UiColors.textPrimary, + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + widget.shift.title, + style: UiTypography.body2m.copyWith( + color: UiColors.textPrimary, + ), + overflow: TextOverflow.ellipsis, + ), + Text( + widget.shift.clientName, + style: UiTypography.body3r.copyWith( + color: UiColors.textSecondary, + ), + overflow: TextOverflow.ellipsis, + ), + ], ), ), - if (widget.shift.durationDays != null) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: UiColors.primary.withOpacity(0.1), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - t.staff_shifts.details.days(days: widget.shift.durationDays!), - style: UiTypography.display3r.copyWith( - color: UiColors.primary, - fontWeight: FontWeight.w600, + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + "\$${estimatedTotal.toStringAsFixed(0)}", + style: UiTypography.title1m.copyWith( + color: UiColors.textPrimary, ), ), + Text( + "\$${widget.shift.hourlyRate.toInt()}/hr · ${duration.toInt()}h", + style: UiTypography.footnote2r.copyWith( + color: UiColors.textSecondary, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 8), + + // Date & Time - Multi-Day or Single Day + if (widget.shift.durationDays != null && + widget.shift.durationDays! > 1) ...[ + // Multi-Day Schedule Display + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + UiIcons.clock, + size: 12, + color: UiColors.primary, + ), + const SizedBox(width: 4), + Text( + t.staff_shifts.details.days( + days: widget.shift.durationDays!, + ), + style: UiTypography.footnote2m.copyWith( + color: UiColors.primary, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + "Showing first schedule...", + style: UiTypography.footnote2r.copyWith( + color: UiColors.primary, + ), ), ], - ], - ), + ), + ] else ...[ + // Single Day Display + Row( + children: [ + const Icon( + UiIcons.calendar, + size: 12, + color: UiColors.iconSecondary, + ), + const SizedBox(width: 4), + Text( + _formatDate(widget.shift.date), + style: UiTypography.footnote1r.copyWith( + color: UiColors.textSecondary, + ), + ), + const SizedBox(width: 12), + const Icon( + UiIcons.clock, + size: 12, + color: UiColors.iconSecondary, + ), + const SizedBox(width: 4), + Text( + "${_formatTime(widget.shift.startTime)} - ${_formatTime(widget.shift.endTime)}", + style: UiTypography.footnote1r.copyWith( + color: UiColors.textSecondary, + ), + ), + ], + ), + ], const SizedBox(height: 4), - Text( - '${_formatTime(widget.shift.startTime)} - ${_formatTime(widget.shift.endTime)}', - style: UiTypography.body2r.copyWith( - color: UiColors.textSecondary, - ), - ), - const SizedBox(height: 12), - Text( - widget.shift.title, - style: UiTypography.body2m.copyWith( - color: UiColors.textPrimary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + + // Location Row( children: [ const Icon( @@ -258,10 +368,15 @@ class _MyShiftCardState extends State { color: UiColors.iconSecondary, ), const SizedBox(width: 4), - Text( - widget.shift.clientName, - style: UiTypography.display3r.copyWith( - color: UiColors.textSecondary, + Expanded( + child: Text( + widget.shift.locationAddress.isNotEmpty + ? widget.shift.locationAddress + : widget.shift.location, + style: UiTypography.footnote1r.copyWith( + color: UiColors.textSecondary, + ), + overflow: TextOverflow.ellipsis, ), ), ], @@ -269,144 +384,389 @@ class _MyShiftCardState extends State { ], ), ), - - // Logo Box - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: UiColors.background, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: UiColors.border), - ), - child: widget.shift.logoUrl != null - ? ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - widget.shift.logoUrl!, - fit: BoxFit.cover, - ), - ) - : Center( - child: Text( - widget.shift.clientName.isNotEmpty - ? widget.shift.clientName[0] - : 'K', - style: UiTypography.title1m.textLink, - ), - ), - ), ], ), ], ), ), - // Expanded Actions - AnimatedCrossFade( - firstChild: const SizedBox(height: 0), - secondChild: Container( - decoration: const BoxDecoration( - border: Border( - top: BorderSide(color: UiColors.border), - ), - ), - child: Column( - children: [ - // Warning for Pending - if (status == 'pending' || status == 'open') - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 16, - ), - color: UiColors.accent.withOpacity(0.1), - child: Row( - children: [ - const Icon( - UiIcons.warning, - size: 14, - color: UiColors.textWarning, - ), - const SizedBox(width: 8), - Text( - t.staff_shifts.status.pending_warning, - style: UiTypography.display3r.copyWith( - color: UiColors.textWarning, - fontWeight: FontWeight.w500, + // Expanded Content + AnimatedSize( + duration: const Duration(milliseconds: 300), + child: _isExpanded + ? Column( + children: [ + const Divider(height: 1, color: UiColors.border), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // Stats Row + Row( + children: [ + Expanded( + child: _buildStatCard( + UiIcons.dollar, + "\$${estimatedTotal.toStringAsFixed(0)}", + "Total", + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildStatCard( + UiIcons.dollar, + "\$${widget.shift.hourlyRate.toInt()}", + "Hourly Rate", + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildStatCard( + UiIcons.clock, + "${duration.toInt()}", + "Hours", + ), + ), + ], ), - ), - ], - ), - ), + const SizedBox(height: 24), - Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - if (status == 'pending' || status == 'open') ...[ - Expanded( - child: OutlinedButton( - onPressed: widget.onDecline, - style: OutlinedButton.styleFrom( - foregroundColor: UiColors.destructive, - side: const BorderSide(color: UiColors.border), - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + // In/Out Time + Row( + children: [ + Expanded( + child: _buildTimeBox( + "CLOCK IN TIME", + widget.shift.startTime, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildTimeBox( + "CLOCK OUT TIME", + widget.shift.endTime, + ), + ), + ], + ), + const SizedBox(height: 24), + + // Location + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "LOCATION", + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: UiColors.textSecondary, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + widget.shift.location.isEmpty + ? "TBD" + : widget.shift.location, + style: UiTypography.title1m.copyWith( + color: UiColors.textPrimary, + ), + ), + OutlinedButton.icon( + onPressed: () { + // Show snackbar with the address + ScaffoldMessenger.of( + context, + ).showSnackBar( + SnackBar( + content: Text( + widget.shift.locationAddress, + ), + duration: const Duration( + seconds: 3, + ), + ), + ); + }, + icon: const Icon( + UiIcons.navigation, + size: 14, + ), + label: const Text( + "Get direction", + style: TextStyle(fontSize: 12), + ), + style: OutlinedButton.styleFrom( + foregroundColor: UiColors.textPrimary, + side: const BorderSide( + color: UiColors.border, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 20, + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 0, + ), + minimumSize: const Size(0, 32), + ), + ), + ], + ), + const SizedBox(height: 12), + Container( + height: 128, + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Icon( + UiIcons.mapPin, + color: UiColors.iconSecondary, + size: 32, + ), + ), + // Placeholder for Map + ), + ], + ), + const SizedBox(height: 24), + + // Additional Info + if (widget.shift.description != null) ...[ + SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + const Text( + "ADDITIONAL INFO", + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: UiColors.textSecondary, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + Text( + widget.shift.description!.split('.')[0], + style: UiTypography.body2m.copyWith( + color: UiColors.textPrimary, + ), + ), + Text( + widget.shift.description!, + style: UiTypography.body3r.copyWith( + color: UiColors.textSecondary, + ), + ), + ], ), ), - child: Text(t.staff_shifts.action.decline), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton( - onPressed: widget.onAccept, - style: ElevatedButton.styleFrom( - backgroundColor: UiColors.primary, - foregroundColor: Colors.white, - elevation: 0, - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + const SizedBox(height: 24), + ], + + // Actions + if (!widget.historyMode) + if (status == 'confirmed') + SizedBox( + width: double.infinity, + height: 48, + child: OutlinedButton.icon( + onPressed: widget.onRequestSwap, + icon: const Icon( + UiIcons.swap, + size: 16, + ), + label: Text( + t.staff_shifts.action.request_swap), + style: OutlinedButton.styleFrom( + foregroundColor: UiColors.primary, + side: const BorderSide( + color: UiColors.primary, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 12, + ), + ), + ), + ), + ) + else if (status == 'swap') + Container( + width: double.infinity, + height: 48, + decoration: BoxDecoration( + color: const Color( + 0xFFFFFBEB, + ), // amber-50 + border: Border.all( + color: const Color(0xFFFDE68A), + ), // amber-200 + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + const Icon( + UiIcons.swap, + size: 16, + color: Color(0xFFB45309), + ), // amber-700 + const SizedBox(width: 8), + Text( + t.staff_shifts.status.swap_requested, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Color(0xFFB45309), + ), + ), + ], + ), + ) + else + Column( + children: [ + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + onPressed: widget.onAccept, + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(12), + ), + ), + child: Text( + status == 'pending' + ? t.staff_shifts.action.confirm + : "Book Shift", + style: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + ), + ), + if (status == 'pending' || + status == 'open') ...[ + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + height: 48, + child: OutlinedButton( + onPressed: widget.onDecline, + style: OutlinedButton.styleFrom( + foregroundColor: + UiColors.destructive, + side: const BorderSide( + color: UiColors.border, + ), + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(12), + ), + ), + child: Text( + t.staff_shifts.action.decline), + ), + ), + ], + ], ), - ), - child: Text(t.staff_shifts.action.confirm), - ), - ), - ] else if (status == 'confirmed') ...[ - Expanded( - child: OutlinedButton.icon( - onPressed: widget.onRequestSwap, - icon: const Icon(UiIcons.swap, size: 16), - label: Text(t.staff_shifts.action.request_swap), - style: OutlinedButton.styleFrom( - foregroundColor: UiColors.textPrimary, - side: const BorderSide(color: UiColors.border), - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - ], - ], - ), - ), - ], - ), - ), - crossFadeState: _isExpanded - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - duration: const Duration(milliseconds: 200), + ], + ), + ), + ], + ) + : const SizedBox.shrink(), ), ], ), ), ); } + + Widget _buildStatCard(IconData icon, String value, String label) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: UiColors.border), + ), + child: Column( + children: [ + Container( + width: 40, + height: 40, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: Icon(icon, size: 20, color: UiColors.iconSecondary), + ), + const SizedBox(height: 8), + Text( + value, + style: UiTypography.title1m.copyWith( + color: UiColors.textPrimary, + ), + ), + Text( + label, + style: UiTypography.footnote2r.copyWith( + color: UiColors.textSecondary, + ), + ), + ], + ), + ); + } + + Widget _buildTimeBox(String label, String time) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Text( + label, + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: UiColors.textSecondary, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 4), + Text( + _formatTime(time), + style: UiTypography.display2m.copyWith( + fontSize: 20, + color: UiColors.textPrimary, + ), + ), + ], + ), + ); + } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_assignment_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_assignment_card.dart index d3c813ff..d46eb14a 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_assignment_card.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_assignment_card.dart @@ -67,7 +67,7 @@ class ShiftAssignmentCard extends StatelessWidget { return Container( decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(12), + borderRadius: UiConstants.radiusLg, border: Border.all(color: UiColors.border), boxShadow: [ BoxShadow( @@ -81,64 +81,157 @@ class ShiftAssignmentCard extends StatelessWidget { children: [ // Header Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + padding: const EdgeInsets.all(16), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Card content starts directly as per prototype + Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Logo Container( - width: 36, - height: 36, + width: 44, + height: 44, decoration: BoxDecoration( - color: UiColors.secondary, - borderRadius: BorderRadius.circular(8), - ), - child: Center( - child: Text( - shift.clientName.isNotEmpty - ? shift.clientName[0] - : 'K', - style: UiTypography.body2b.copyWith( - color: UiColors.textSecondary, - ), + gradient: LinearGradient( + colors: [ + UiColors.primary.withOpacity(0.09), + UiColors.primary.withOpacity(0.03), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: UiColors.primary.withOpacity(0.09), ), ), + child: shift.logoUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.network( + shift.logoUrl!, + fit: BoxFit.contain, + ), + ) + : const Center( + child: Icon( + UiIcons.briefcase, + color: UiColors.primary, + size: 20, + ), + ), ), const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - shift.title, - style: UiTypography.body2b.copyWith( - color: UiColors.textPrimary, + + // Details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + shift.title, + style: UiTypography.body2m.copyWith( + color: UiColors.textPrimary, + ), + overflow: TextOverflow.ellipsis, + ), + Text( + shift.clientName, + style: UiTypography.body3r.copyWith( + color: UiColors.textSecondary, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + "\$${totalPay.toStringAsFixed(0)}", + style: UiTypography.title1m.copyWith( + color: UiColors.textPrimary, + ), + ), + Text( + "\$${shift.hourlyRate.toInt()}/hr · ${hours.toInt()}h", + style: UiTypography.footnote2r.copyWith( + color: UiColors.textSecondary, + ), + ), + ], + ), + ], ), - ), - Text( - shift.clientName, - style: UiTypography.display3r.copyWith( - color: UiColors.textSecondary, + const SizedBox(height: 12), + + // Date & Time + Row( + children: [ + const Icon( + UiIcons.calendar, + size: 12, + color: UiColors.iconSecondary, + ), + const SizedBox(width: 4), + Text( + _formatDate(shift.date), + style: UiTypography.footnote1r.copyWith( + color: UiColors.textSecondary, + ), + ), + const SizedBox(width: 12), + const Icon( + UiIcons.clock, + size: 12, + color: UiColors.iconSecondary, + ), + const SizedBox(width: 4), + Text( + "${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}", + style: UiTypography.footnote1r.copyWith( + color: UiColors.textSecondary, + ), + ), + ], ), - ), - ], - ), - ], - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - "\$${totalPay.toStringAsFixed(0)}", - style: UiTypography.display2m.copyWith( - color: UiColors.textPrimary, - ), - ), - Text( - "\$${shift.hourlyRate}/hr · ${hours}h", - style: UiTypography.display3r.copyWith( - color: UiColors.textSecondary, + const SizedBox(height: 4), + + // Location + Row( + children: [ + const Icon( + UiIcons.mapPin, + size: 12, + color: UiColors.iconSecondary, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + shift.locationAddress.isNotEmpty + ? shift.locationAddress + : shift.location, + style: UiTypography.footnote1r.copyWith( + color: UiColors.textSecondary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], ), ), ], @@ -147,94 +240,43 @@ class ShiftAssignmentCard extends StatelessWidget { ), ), - // Details Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon( - UiIcons.calendar, - size: 14, - color: UiColors.iconSecondary, - ), - const SizedBox(width: 6), - Text( - _formatDate(shift.date), - style: UiTypography.display3r.copyWith( - color: UiColors.textSecondary, - ), - ), - const SizedBox(width: 16), - const Icon( - UiIcons.clock, - size: 14, - color: UiColors.iconSecondary, - ), - const SizedBox(width: 6), - Text( - "${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}", - style: UiTypography.display3r.copyWith( - color: UiColors.textSecondary, - ), - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - const Icon( - UiIcons.mapPin, - size: 14, - color: UiColors.iconSecondary, - ), - const SizedBox(width: 6), - Expanded( - child: Text( - shift.location, - style: UiTypography.display3r.copyWith( - color: UiColors.textSecondary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ], - ), - ), - - if (isConfirming) ...[ - const Divider(height: 1), - Row( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Row( children: [ Expanded( - child: TextButton( + child: OutlinedButton( onPressed: onDecline, - style: TextButton.styleFrom( - foregroundColor: UiColors.destructive, - padding: const EdgeInsets.symmetric(vertical: 16), + style: OutlinedButton.styleFrom( + foregroundColor: UiColors.iconSecondary, + side: const BorderSide(color: UiColors.border), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), ), child: Text(t.staff_shifts.action.decline), ), ), - Container(width: 1, height: 48, color: UiColors.border), + const SizedBox(width: 12), Expanded( - child: TextButton( + child: ElevatedButton( onPressed: onConfirm, - style: TextButton.styleFrom( - foregroundColor: UiColors.primary, - padding: const EdgeInsets.symmetric(vertical: 16), + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.primary, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), ), child: Text(t.staff_shifts.action.confirm), ), ), ], ), - ], + ), ], ), ); diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart b/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart index 95c428fc..8b979692 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart @@ -4,6 +4,11 @@ import 'data/repositories_impl/shifts_repository_impl.dart'; import 'domain/usecases/get_my_shifts_usecase.dart'; import 'domain/usecases/get_available_shifts_usecase.dart'; import 'domain/usecases/get_pending_assignments_usecase.dart'; +import 'domain/usecases/get_cancelled_shifts_usecase.dart'; +import 'domain/usecases/get_history_shifts_usecase.dart'; +import 'domain/usecases/accept_shift_usecase.dart'; +import 'domain/usecases/decline_shift_usecase.dart'; +import 'domain/usecases/get_shift_details_usecase.dart'; import 'presentation/blocs/shifts/shifts_bloc.dart'; import 'presentation/pages/shifts_page.dart'; import 'presentation/pages/shift_details_page.dart'; @@ -18,6 +23,11 @@ class StaffShiftsModule extends Module { i.add(GetMyShiftsUseCase.new); i.add(GetAvailableShiftsUseCase.new); i.add(GetPendingAssignmentsUseCase.new); + i.add(GetCancelledShiftsUseCase.new); + i.add(GetHistoryShiftsUseCase.new); + i.add(AcceptShiftUseCase.new); + i.add(DeclineShiftUseCase.new); + i.add(GetShiftDetailsUseCase.new); // Bloc i.add(ShiftsBloc.new);