From 493891eea03bad3964dae049f150e896da0b581c Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 10:53:37 -0400 Subject: [PATCH 01/24] Add staff reliability stats & shift location Introduce staff reliability statistics and location fields across domain, data, and UI. Changes include: - New API endpoint StaffEndpoints.profileStats ('/staff/profile/stats'). - New domain entity StaffReliabilityStats with JSON (de)serialization and export. - Profile repository: getReliabilityStats implementation and interface addition. - New GetReliabilityStatsUseCase and DI registration in StaffProfileModule. - ProfileCubit/state: load and store reliabilityStats; UI wired to display ReliabilityStatsCard and ReliabilityScoreBar using state values. - Coverage/shift updates: Added AssignedWorker.hasReview to track if a worker was reviewed; added locationName/locationAddress to ShiftWithWorkers and show location in ShiftHeader; hide rate button if worker.hasReview. - Clock-in handling: treat backend ALREADY_CLOCKED_IN (409) as idempotent by re-fetching attendance and emitting success when appropriate. These changes wire backend stats through repository/usecase/cubit to the profile UI and add shift location and review-awareness to client views. --- .../endpoints/staff_endpoints.dart | 4 + .../packages/domain/lib/krow_domain.dart | 1 + .../coverage_domain/assigned_worker.dart | 7 ++ .../coverage_domain/shift_with_workers.dart | 14 ++++ .../ratings/staff_reliability_stats.dart | 84 +++++++++++++++++++ .../widgets/coverage_shift_list.dart | 8 +- .../presentation/widgets/shift_header.dart | 27 ++++++ .../bloc/clock_in/clock_in_bloc.dart | 18 ++-- .../repositories/profile_repository_impl.dart | 9 ++ .../profile_repository_interface.dart | 5 +- .../get_reliability_stats_usecase.dart | 18 ++++ .../src/presentation/blocs/profile_cubit.dart | 19 ++++- .../src/presentation/blocs/profile_state.dart | 46 +++++----- .../pages/staff_profile_page.dart | 17 ++-- .../profile/lib/src/staff_profile_module.dart | 7 ++ 15 files changed, 247 insertions(+), 37 deletions(-) create mode 100644 apps/mobile/packages/domain/lib/src/entities/ratings/staff_reliability_stats.dart create mode 100644 apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_reliability_stats_usecase.dart diff --git a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart index 6955b964..d6fb3634 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart @@ -14,6 +14,10 @@ abstract final class StaffEndpoints { static const ApiEndpoint profileCompletion = ApiEndpoint('/staff/profile-completion'); + /// Staff reliability and performance statistics. + static const ApiEndpoint profileStats = + ApiEndpoint('/staff/profile/stats'); + /// Staff availability schedule. static const ApiEndpoint availability = ApiEndpoint('/staff/availability'); diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index 62f8dd73..c772ba45 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -99,6 +99,7 @@ export 'src/entities/profile/accessibility.dart'; // Ratings export 'src/entities/ratings/staff_rating.dart'; +export 'src/entities/ratings/staff_reliability_stats.dart'; // Home export 'src/entities/home/client_dashboard.dart'; diff --git a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart index 88ee6ebc..a16e8a41 100644 --- a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart +++ b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart @@ -13,6 +13,7 @@ class AssignedWorker extends Equatable { required this.fullName, required this.status, this.checkInAt, + this.hasReview = false, }); /// Deserialises an [AssignedWorker] from a V2 API JSON map. @@ -25,6 +26,7 @@ class AssignedWorker extends Equatable { checkInAt: json['checkInAt'] != null ? DateTime.parse(json['checkInAt'] as String) : null, + hasReview: json['hasReview'] as bool? ?? false, ); } @@ -43,6 +45,9 @@ class AssignedWorker extends Equatable { /// When the worker clocked in (null if not yet). final DateTime? checkInAt; + /// Whether this worker has already been reviewed for this assignment. + final bool hasReview; + /// Serialises this [AssignedWorker] to a JSON map. Map toJson() { return { @@ -51,6 +56,7 @@ class AssignedWorker extends Equatable { 'fullName': fullName, 'status': status.toJson(), 'checkInAt': checkInAt?.toIso8601String(), + 'hasReview': hasReview, }; } @@ -61,5 +67,6 @@ class AssignedWorker extends Equatable { fullName, status, checkInAt, + hasReview, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/shift_with_workers.dart b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/shift_with_workers.dart index 476334f8..9a91f6b6 100644 --- a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/shift_with_workers.dart +++ b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/shift_with_workers.dart @@ -15,6 +15,8 @@ class ShiftWithWorkers extends Equatable { required this.requiredWorkerCount, required this.assignedWorkerCount, this.assignedWorkers = const [], + this.locationName = '', + this.locationAddress = '', }); /// Deserialises a [ShiftWithWorkers] from a V2 API JSON map. @@ -30,6 +32,8 @@ class ShiftWithWorkers extends Equatable { return ShiftWithWorkers( shiftId: json['shiftId'] as String, roleName: json['roleName'] as String? ?? '', + locationName: json['locationName'] as String? ?? '', + locationAddress: json['locationAddress'] as String? ?? '', timeRange: TimeRange.fromJson(json['timeRange'] as Map), requiredWorkerCount: (json['requiredWorkerCount'] as num).toInt(), assignedWorkerCount: (json['assignedWorkerCount'] as num).toInt(), @@ -55,11 +59,19 @@ class ShiftWithWorkers extends Equatable { /// List of assigned workers with their statuses. final List assignedWorkers; + /// Location or hub name for this shift. + final String locationName; + + /// Street address for this shift. + final String locationAddress; + /// Serialises this [ShiftWithWorkers] to a JSON map. Map toJson() { return { 'shiftId': shiftId, 'roleName': roleName, + 'locationName': locationName, + 'locationAddress': locationAddress, 'timeRange': timeRange.toJson(), 'requiredWorkerCount': requiredWorkerCount, 'assignedWorkerCount': assignedWorkerCount, @@ -76,5 +88,7 @@ class ShiftWithWorkers extends Equatable { requiredWorkerCount, assignedWorkerCount, assignedWorkers, + locationName, + locationAddress, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/ratings/staff_reliability_stats.dart b/apps/mobile/packages/domain/lib/src/entities/ratings/staff_reliability_stats.dart new file mode 100644 index 00000000..e0e22601 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/ratings/staff_reliability_stats.dart @@ -0,0 +1,84 @@ +import 'package:equatable/equatable.dart'; + +/// Aggregated reliability and performance statistics for a staff member. +/// +/// Returned by `GET /staff/profile/stats`. +class StaffReliabilityStats extends Equatable { + /// Creates a [StaffReliabilityStats] instance. + const StaffReliabilityStats({ + required this.staffId, + this.totalShifts = 0, + this.averageRating = 0, + this.ratingCount = 0, + this.onTimeRate = 0, + this.noShowCount = 0, + this.cancellationCount = 0, + this.reliabilityScore = 0, + }); + + /// Deserialises from a V2 API JSON map. + factory StaffReliabilityStats.fromJson(Map json) { + return StaffReliabilityStats( + staffId: json['staffId'] as String, + totalShifts: (json['totalShifts'] as num?)?.toInt() ?? 0, + averageRating: (json['averageRating'] as num?)?.toDouble() ?? 0, + ratingCount: (json['ratingCount'] as num?)?.toInt() ?? 0, + onTimeRate: (json['onTimeRate'] as num?)?.toDouble() ?? 0, + noShowCount: (json['noShowCount'] as num?)?.toInt() ?? 0, + cancellationCount: (json['cancellationCount'] as num?)?.toInt() ?? 0, + reliabilityScore: (json['reliabilityScore'] as num?)?.toDouble() ?? 0, + ); + } + + /// The staff member's unique identifier. + final String staffId; + + /// Total completed shifts. + final int totalShifts; + + /// Average rating from client reviews (0-5). + final double averageRating; + + /// Number of ratings received. + final int ratingCount; + + /// Percentage of shifts clocked in on time (0-100). + final double onTimeRate; + + /// Number of no-show incidents. + final int noShowCount; + + /// Number of worker-initiated cancellations. + final int cancellationCount; + + /// Composite reliability score (0-100). + /// + /// Weighted: 45% on-time rate + 35% completion rate + 20% rating score. + final double reliabilityScore; + + /// Serialises to a JSON map. + Map toJson() { + return { + 'staffId': staffId, + 'totalShifts': totalShifts, + 'averageRating': averageRating, + 'ratingCount': ratingCount, + 'onTimeRate': onTimeRate, + 'noShowCount': noShowCount, + 'cancellationCount': cancellationCount, + 'reliabilityScore': reliabilityScore, + }; + } + + @override + List get props => [ + staffId, + totalShifts, + averageRating, + ratingCount, + onTimeRate, + noShowCount, + cancellationCount, + reliabilityScore, + ]; +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart index 8e284dc1..1c305986 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart @@ -161,6 +161,7 @@ class _CoverageShiftListState extends State { children: [ ShiftHeader( title: shift.roleName, + locationName: shift.locationName, startTime: _formatTime(shift.timeRange.startsAt), current: shift.assignedWorkerCount, total: shift.requiredWorkerCount, @@ -226,9 +227,10 @@ class _CoverageShiftListState extends State { worker: worker, shiftStartTime: _formatTime(shift.timeRange.startsAt), showRateButton: - worker.status == AssignmentStatus.checkedIn || - worker.status == AssignmentStatus.checkedOut || - worker.status == AssignmentStatus.completed, + !worker.hasReview && + (worker.status == AssignmentStatus.checkedIn || + worker.status == AssignmentStatus.checkedOut || + worker.status == AssignmentStatus.completed), showCancelButton: DateTime.now().isAfter(shift.timeRange.startsAt) && (worker.status == AssignmentStatus.noShow || diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/shift_header.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/shift_header.dart index b0a81658..3027449b 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/shift_header.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/shift_header.dart @@ -21,6 +21,7 @@ class ShiftHeader extends StatelessWidget { required this.lateCount, required this.isExpanded, required this.onToggle, + this.locationName, super.key, }); @@ -57,6 +58,9 @@ class ShiftHeader extends StatelessWidget { /// Callback invoked when the header is tapped to expand or collapse. final VoidCallback onToggle; + /// Optional location or hub name for the shift. + final String? locationName; + /// Returns the status colour based on [coveragePercent]. /// /// Green for >= 100 %, yellow for >= 80 %, red otherwise. @@ -110,6 +114,29 @@ class ShiftHeader extends StatelessWidget { title, style: UiTypography.body1b.textPrimary, ), + if (locationName != null && + locationName!.isNotEmpty) ...[ + const SizedBox(height: 2), + Row( + children: [ + const Icon( + UiIcons.mapPin, + size: 10, + color: UiColors.textSecondary, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + locationName!, + style: UiTypography.body3r.copyWith( + color: UiColors.textSecondary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], const SizedBox(height: UiConstants.space1), Row( children: [ diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart index 9c107915..f911f200 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart @@ -228,12 +228,20 @@ class ClockInBloc extends Bloc event: event, activeShiftId: newStatus.activeShiftId, ); - } on AppException catch (_) { - // The clock-in API call failed. Re-fetch attendance status to - // reconcile: if the worker is already clocked in (e.g. duplicate - // session from Postgres constraint 23505), treat it as success. + } on AppException catch (e) { + // The backend returns 409 ALREADY_CLOCKED_IN when the worker has + // an active attendance session. This is a normal idempotency + // signal — re-fetch the authoritative status and emit success + // without surfacing an error snackbar. + final bool isAlreadyClockedIn = + e is ApiException && e.apiCode == 'ALREADY_CLOCKED_IN'; + + // Re-fetch attendance status to reconcile local state with + // the backend (handles both ALREADY_CLOCKED_IN and legacy + // Postgres constraint 23505 duplicates). final AttendanceStatus currentStatus = await _getAttendanceStatus(); - if (currentStatus.isClockedIn) { + + if (isAlreadyClockedIn || currentStatus.isClockedIn) { emit(state.copyWith( status: ClockInStatus.success, attendance: currentStatus, diff --git a/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart b/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart index f8d3020d..6d913283 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart @@ -31,6 +31,15 @@ class ProfileRepositoryImpl implements ProfileRepositoryInterface { return ProfileSectionStatus.fromJson(json); } + @override + Future getReliabilityStats() async { + final ApiResponse response = + await _api.get(StaffEndpoints.profileStats); + final Map json = + response.data as Map; + return StaffReliabilityStats.fromJson(json); + } + @override Future signOut() async { await _api.post(AuthEndpoints.signOut); diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository_interface.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository_interface.dart index b28b963d..55cd30de 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository_interface.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository_interface.dart @@ -3,7 +3,7 @@ import 'package:krow_domain/krow_domain.dart'; /// Abstract interface for the staff profile repository. /// /// Defines the contract for fetching staff profile data, -/// section completion statuses, and signing out. +/// section completion statuses, reliability stats, and signing out. abstract interface class ProfileRepositoryInterface { /// Fetches the staff profile from the backend. Future getStaffProfile(); @@ -11,6 +11,9 @@ abstract interface class ProfileRepositoryInterface { /// Fetches the profile section completion statuses. Future getProfileSections(); + /// Fetches reliability and performance statistics for the staff member. + Future getReliabilityStats(); + /// Signs out the current user. Future signOut(); } diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_reliability_stats_usecase.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_reliability_stats_usecase.dart new file mode 100644 index 00000000..5c77f6e4 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_reliability_stats_usecase.dart @@ -0,0 +1,18 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart'; + +/// Use case for retrieving the staff member's reliability statistics. +class GetReliabilityStatsUseCase + implements NoInputUseCase { + /// Creates a [GetReliabilityStatsUseCase] with the required [repository]. + GetReliabilityStatsUseCase(this._repository); + + final ProfileRepositoryInterface _repository; + + @override + Future call() { + return _repository.getReliabilityStats(); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart index 4b68e2ee..2b1b220e 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart @@ -3,6 +3,7 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:staff_profile/src/domain/usecases/get_profile_sections_usecase.dart'; +import 'package:staff_profile/src/domain/usecases/get_reliability_stats_usecase.dart'; import 'package:staff_profile/src/domain/usecases/get_staff_profile_usecase.dart'; import 'package:staff_profile/src/domain/usecases/sign_out_usecase.dart'; import 'package:staff_profile/src/presentation/blocs/profile_state.dart'; @@ -10,21 +11,24 @@ import 'package:staff_profile/src/presentation/blocs/profile_state.dart'; /// Cubit for managing the Profile feature state. /// /// Delegates all data fetching to use cases, following Clean Architecture. -/// Loads the staff profile and section completion statuses in a single flow. +/// Loads the staff profile, section statuses, and reliability stats. class ProfileCubit extends Cubit with BlocErrorHandler { /// Creates a [ProfileCubit] with the required use cases. ProfileCubit({ required GetStaffProfileUseCase getStaffProfileUseCase, required GetProfileSectionsUseCase getProfileSectionsUseCase, + required GetReliabilityStatsUseCase getReliabilityStatsUseCase, required SignOutUseCase signOutUseCase, }) : _getStaffProfileUseCase = getStaffProfileUseCase, _getProfileSectionsUseCase = getProfileSectionsUseCase, + _getReliabilityStatsUseCase = getReliabilityStatsUseCase, _signOutUseCase = signOutUseCase, super(const ProfileState()); final GetStaffProfileUseCase _getStaffProfileUseCase; final GetProfileSectionsUseCase _getProfileSectionsUseCase; + final GetReliabilityStatsUseCase _getReliabilityStatsUseCase; final SignOutUseCase _signOutUseCase; /// Loads the staff member's profile. @@ -62,6 +66,19 @@ class ProfileCubit extends Cubit ); } + /// Loads reliability and performance statistics for the staff member. + Future loadReliabilityStats() async { + await handleError( + emit: emit, + action: () async { + final StaffReliabilityStats stats = + await _getReliabilityStatsUseCase(); + emit(state.copyWith(reliabilityStats: stats)); + }, + onError: (String _) => state, + ); + } + /// Signs out the current user. Future signOut() async { if (state.status == ProfileStatus.loading) { diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_state.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_state.dart index 5c0b3903..f6c76068 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_state.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_state.dart @@ -28,6 +28,7 @@ class ProfileState extends Equatable { const ProfileState({ this.status = ProfileStatus.initial, this.profile, + this.reliabilityStats, this.errorMessage, this.personalInfoComplete, this.emergencyContactsComplete, @@ -37,40 +38,45 @@ class ProfileState extends Equatable { this.documentsComplete, this.certificatesComplete, }); - /// Current status of the profile feature + + /// Current status of the profile feature. final ProfileStatus status; - - /// The staff member's profile object (null if not loaded) + + /// The staff member's profile object (null if not loaded). final Staff? profile; - - /// Error message if status is error + + /// Reliability and performance statistics (null if not loaded). + final StaffReliabilityStats? reliabilityStats; + + /// Error message if status is error. final String? errorMessage; - - /// Whether personal information is complete + + /// Whether personal information is complete. final bool? personalInfoComplete; - - /// Whether emergency contacts are complete + + /// Whether emergency contacts are complete. final bool? emergencyContactsComplete; - - /// Whether experience information is complete + + /// Whether experience information is complete. final bool? experienceComplete; - - /// Whether tax forms are complete + + /// Whether tax forms are complete. final bool? taxFormsComplete; - - /// Whether attire options are complete + + /// Whether attire options are complete. final bool? attireComplete; - - /// Whether documents are complete + + /// Whether documents are complete. final bool? documentsComplete; - - /// Whether certificates are complete + + /// Whether certificates are complete. final bool? certificatesComplete; /// Creates a copy of this state with updated values. ProfileState copyWith({ ProfileStatus? status, Staff? profile, + StaffReliabilityStats? reliabilityStats, String? errorMessage, bool? personalInfoComplete, bool? emergencyContactsComplete, @@ -83,6 +89,7 @@ class ProfileState extends Equatable { return ProfileState( status: status ?? this.status, profile: profile ?? this.profile, + reliabilityStats: reliabilityStats ?? this.reliabilityStats, errorMessage: errorMessage ?? this.errorMessage, personalInfoComplete: personalInfoComplete ?? this.personalInfoComplete, emergencyContactsComplete: emergencyContactsComplete ?? this.emergencyContactsComplete, @@ -98,6 +105,7 @@ class ProfileState extends Equatable { List get props => [ status, profile, + reliabilityStats, errorMessage, personalInfoComplete, emergencyContactsComplete, diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart index 5dc8ef39..97c69e9b 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart @@ -37,10 +37,11 @@ class StaffProfilePage extends StatelessWidget { value: cubit, child: BlocConsumer( listener: (BuildContext context, ProfileState state) { - // Load section statuses when profile loads successfully + // Load section statuses and reliability stats when profile loads if (state.status == ProfileStatus.loaded && state.personalInfoComplete == null) { cubit.loadSectionStatuses(); + cubit.loadReliabilityStats(); } if (state.status == ProfileStatus.signedOut) { @@ -100,16 +101,16 @@ class StaffProfilePage extends StatelessWidget { children: [ // Reliability Stats ReliabilityStatsCard( - totalShifts: 0, - averageRating: profile.averageRating, - onTimeRate: 0, - noShowCount: 0, - cancellationCount: 0, + totalShifts: state.reliabilityStats?.totalShifts, + averageRating: state.reliabilityStats?.averageRating, + onTimeRate: state.reliabilityStats?.onTimeRate.round(), + noShowCount: state.reliabilityStats?.noShowCount, + cancellationCount: state.reliabilityStats?.cancellationCount, ), // Reliability Score Bar - const ReliabilityScoreBar( - reliabilityScore: 0, + ReliabilityScoreBar( + reliabilityScore: state.reliabilityStats?.reliabilityScore.round(), ), // Ordered sections diff --git a/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart index e9854ab8..c118900c 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart @@ -6,6 +6,7 @@ import 'package:krow_domain/krow_domain.dart'; import 'package:staff_profile/src/data/repositories/profile_repository_impl.dart'; import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart'; import 'package:staff_profile/src/domain/usecases/get_profile_sections_usecase.dart'; +import 'package:staff_profile/src/domain/usecases/get_reliability_stats_usecase.dart'; import 'package:staff_profile/src/domain/usecases/get_staff_profile_usecase.dart'; import 'package:staff_profile/src/domain/usecases/sign_out_usecase.dart'; import 'package:staff_profile/src/presentation/blocs/profile_cubit.dart'; @@ -44,12 +45,18 @@ class StaffProfileModule extends Module { i.get(), ), ); + i.addLazySingleton( + () => GetReliabilityStatsUseCase( + i.get(), + ), + ); // Cubit i.addLazySingleton( () => ProfileCubit( getStaffProfileUseCase: i.get(), getProfileSectionsUseCase: i.get(), + getReliabilityStatsUseCase: i.get(), signOutUseCase: i.get(), ), ); From 5792aa6e9855da07a9ae87851b4d7456cb2991e7 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 13:00:17 -0400 Subject: [PATCH 02/24] feat: add UTC parsing utilities and update date handling across entities - Introduced `utc_parser.dart` with functions to convert UTC timestamps to local time. - Updated date parsing in various entities to use the new utility functions for consistency. - Refactored date handling in `BenefitHistory`, `Business`, `AttendanceStatus`, `AssignedWorker`, `TimeRange`, `Invoice`, `PaymentChartPoint`, `StaffPayment`, `TimeCardEntry`, `OrderItem`, `OrderPreview`, `RecentOrder`, `StaffRating`, `CoverageDayPoint`, `ForecastWeek`, `NoShowIncident`, `SpendDataPoint`, `AssignedShift`, `CancelledShift`, `CompletedShift`, `OpenShift`, `PendingAssignment`, `Shift`, `ShiftDetail`, `TodayShift`, `BusinessMembership`, and `Staff`. - Updated `ReorderWidget` and `OrderEditSheet` to handle date formatting correctly. --- .../api_service/inspectors/auth_interceptor.dart | 2 +- .../core/lib/src/utils/date_time_utils.dart | 5 +++++ apps/mobile/packages/domain/lib/krow_domain.dart | 3 +++ .../domain/lib/src/core/utils/utc_parser.dart | 6 ++++++ .../src/entities/benefits/benefit_history.dart | 8 ++++---- .../lib/src/entities/business/business.dart | 10 ++++------ .../src/entities/clock_in/attendance_status.dart | 6 +++--- .../coverage_domain/assigned_worker.dart | 6 +++--- .../src/entities/coverage_domain/time_range.dart | 6 ++++-- .../lib/src/entities/financial/invoice.dart | 10 ++++------ .../entities/financial/payment_chart_point.dart | 4 +++- .../src/entities/financial/staff_payment.dart | 4 +++- .../lib/src/entities/financial/time_card.dart | 12 +++++------- .../lib/src/entities/orders/order_item.dart | 7 ++++--- .../lib/src/entities/orders/order_preview.dart | 14 ++++++-------- .../lib/src/entities/orders/recent_order.dart | 6 +++--- .../lib/src/entities/profile/certificate.dart | 6 ++++-- .../src/entities/profile/profile_document.dart | 4 +++- .../lib/src/entities/ratings/staff_rating.dart | 6 +++--- .../src/entities/reports/coverage_report.dart | 4 +++- .../src/entities/reports/forecast_report.dart | 4 +++- .../lib/src/entities/reports/no_show_report.dart | 4 +++- .../src/entities/reports/spend_data_point.dart | 3 ++- .../lib/src/entities/shifts/assigned_shift.dart | 7 ++++--- .../lib/src/entities/shifts/cancelled_shift.dart | 4 +++- .../lib/src/entities/shifts/completed_shift.dart | 11 +++++++---- .../lib/src/entities/shifts/open_shift.dart | 7 ++++--- .../src/entities/shifts/pending_assignment.dart | 8 +++++--- .../domain/lib/src/entities/shifts/shift.dart | 5 +++-- .../lib/src/entities/shifts/shift_detail.dart | 7 ++++--- .../lib/src/entities/shifts/today_shift.dart | 9 ++++----- .../lib/src/entities/users/biz_member.dart | 6 ++++-- .../domain/lib/src/entities/users/staff.dart | 6 ++++-- .../domain/lib/src/entities/users/user.dart | 6 ++++-- .../src/presentation/widgets/reorder_widget.dart | 16 ++-------------- .../presentation/widgets/order_edit_sheet.dart | 8 ++++---- .../presentation/widgets/view_order_card.dart | 5 ++--- 37 files changed, 136 insertions(+), 109 deletions(-) create mode 100644 apps/mobile/packages/domain/lib/src/core/utils/utc_parser.dart diff --git a/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart b/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart index 2d094d7c..bd1020bd 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart @@ -32,7 +32,7 @@ class AuthInterceptor extends Interceptor { if (!skipAuth) { final User? user = FirebaseAuth.instance.currentUser; if (user != null) { - final String? token = await user.getIdToken(); + final String? token = await user.getIdToken(); if (token != null) { options.headers['Authorization'] = 'Bearer $token'; } diff --git a/apps/mobile/packages/core/lib/src/utils/date_time_utils.dart b/apps/mobile/packages/core/lib/src/utils/date_time_utils.dart index 1d142b33..d18d076d 100644 --- a/apps/mobile/packages/core/lib/src/utils/date_time_utils.dart +++ b/apps/mobile/packages/core/lib/src/utils/date_time_utils.dart @@ -4,4 +4,9 @@ class DateTimeUtils { static DateTime toDeviceTime(DateTime date) { return date.toLocal(); } + + /// Converts a local [DateTime] back to UTC for API payloads. + static String toUtcIso(DateTime local) { + return local.toUtc().toIso8601String(); + } } diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index c772ba45..c1f7814f 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -25,6 +25,9 @@ export 'src/entities/enums/staff_skill.dart'; export 'src/entities/enums/staff_status.dart'; export 'src/entities/enums/user_role.dart'; +// Utils +export 'src/core/utils/utc_parser.dart'; + // Core export 'src/core/services/api_services/api_endpoint.dart'; export 'src/core/services/api_services/api_response.dart'; diff --git a/apps/mobile/packages/domain/lib/src/core/utils/utc_parser.dart b/apps/mobile/packages/domain/lib/src/core/utils/utc_parser.dart new file mode 100644 index 00000000..8ec3572e --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/core/utils/utc_parser.dart @@ -0,0 +1,6 @@ +/// Parses a UTC ISO 8601 timestamp and converts to local device time. +DateTime parseUtcToLocal(String value) => DateTime.parse(value).toLocal(); + +/// Parses a nullable UTC ISO 8601 timestamp. Returns null if input is null. +DateTime? tryParseUtcToLocal(String? value) => + value != null ? DateTime.parse(value).toLocal() : null; diff --git a/apps/mobile/packages/domain/lib/src/entities/benefits/benefit_history.dart b/apps/mobile/packages/domain/lib/src/entities/benefits/benefit_history.dart index f9933a37..6ca1629d 100644 --- a/apps/mobile/packages/domain/lib/src/entities/benefits/benefit_history.dart +++ b/apps/mobile/packages/domain/lib/src/entities/benefits/benefit_history.dart @@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/src/entities/enums/benefit_status.dart'; +import '../../core/utils/utc_parser.dart'; + /// A historical record of a staff benefit accrual period. /// /// Returned by `GET /staff/profile/benefits/history`. @@ -28,10 +30,8 @@ class BenefitHistory extends Equatable { benefitType: json['benefitType'] as String, title: json['title'] as String, status: BenefitStatus.fromJson(json['status'] as String?), - effectiveAt: DateTime.parse(json['effectiveAt'] as String), - endedAt: json['endedAt'] != null - ? DateTime.parse(json['endedAt'] as String) - : null, + effectiveAt: parseUtcToLocal(json['effectiveAt'] as String), + endedAt: tryParseUtcToLocal(json['endedAt'] as String?), trackedHours: (json['trackedHours'] as num).toInt(), targetHours: (json['targetHours'] as num).toInt(), notes: json['notes'] as String?, diff --git a/apps/mobile/packages/domain/lib/src/entities/business/business.dart b/apps/mobile/packages/domain/lib/src/entities/business/business.dart index 36339f32..2c658828 100644 --- a/apps/mobile/packages/domain/lib/src/entities/business/business.dart +++ b/apps/mobile/packages/domain/lib/src/entities/business/business.dart @@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/src/entities/enums/business_status.dart'; +import '../../core/utils/utc_parser.dart'; + /// A client company registered on the platform. /// /// Maps to the V2 `businesses` table. @@ -35,12 +37,8 @@ class Business extends Equatable { metadata: json['metadata'] is Map ? Map.from(json['metadata'] as Map) : const {}, - createdAt: json['createdAt'] != null - ? DateTime.parse(json['createdAt'] as String) - : null, - updatedAt: json['updatedAt'] != null - ? DateTime.parse(json['updatedAt'] as String) - : null, + createdAt: tryParseUtcToLocal(json['createdAt'] as String?), + updatedAt: tryParseUtcToLocal(json['updatedAt'] as String?), ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/clock_in/attendance_status.dart b/apps/mobile/packages/domain/lib/src/entities/clock_in/attendance_status.dart index 043179d7..b71f28e7 100644 --- a/apps/mobile/packages/domain/lib/src/entities/clock_in/attendance_status.dart +++ b/apps/mobile/packages/domain/lib/src/entities/clock_in/attendance_status.dart @@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/src/entities/enums/attendance_status_type.dart'; +import '../../core/utils/utc_parser.dart'; + /// Current clock-in / attendance status of the staff member. /// /// Returned by `GET /staff/clock-in/status`. When no open session exists @@ -20,9 +22,7 @@ class AttendanceStatus extends Equatable { activeShiftId: json['activeShiftId'] as String?, attendanceStatus: AttendanceStatusType.fromJson(json['attendanceStatus'] as String?), - clockInAt: json['clockInAt'] != null - ? DateTime.parse(json['clockInAt'] as String) - : null, + clockInAt: tryParseUtcToLocal(json['clockInAt'] as String?), ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart index a16e8a41..a0d82248 100644 --- a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart +++ b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart @@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/src/entities/enums/assignment_status.dart'; +import '../../core/utils/utc_parser.dart'; + /// A worker assigned to a coverage shift. /// /// Nested within [ShiftWithWorkers]. @@ -23,9 +25,7 @@ class AssignedWorker extends Equatable { staffId: json['staffId'] as String, fullName: json['fullName'] as String, status: AssignmentStatus.fromJson(json['status'] as String?), - checkInAt: json['checkInAt'] != null - ? DateTime.parse(json['checkInAt'] as String) - : null, + checkInAt: tryParseUtcToLocal(json['checkInAt'] as String?), hasReview: json['hasReview'] as bool? ?? false, ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/time_range.dart b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/time_range.dart index 543deccd..144d8194 100644 --- a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/time_range.dart +++ b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/time_range.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// A time range with start and end timestamps. /// /// Used within [ShiftWithWorkers] for shift time windows. @@ -13,8 +15,8 @@ class TimeRange extends Equatable { /// Deserialises a [TimeRange] from a V2 API JSON map. factory TimeRange.fromJson(Map json) { return TimeRange( - startsAt: DateTime.parse(json['startsAt'] as String), - endsAt: DateTime.parse(json['endsAt'] as String), + startsAt: parseUtcToLocal(json['startsAt'] as String), + endsAt: parseUtcToLocal(json['endsAt'] as String), ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/invoice.dart b/apps/mobile/packages/domain/lib/src/entities/financial/invoice.dart index 7d370fd3..27afa489 100644 --- a/apps/mobile/packages/domain/lib/src/entities/financial/invoice.dart +++ b/apps/mobile/packages/domain/lib/src/entities/financial/invoice.dart @@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/src/entities/enums/invoice_status.dart'; +import '../../core/utils/utc_parser.dart'; + /// An invoice issued to a business for services rendered. /// /// Returned by `GET /client/billing/invoices/*`. @@ -25,12 +27,8 @@ class Invoice extends Equatable { invoiceNumber: json['invoiceNumber'] as String, amountCents: (json['amountCents'] as num).toInt(), status: InvoiceStatus.fromJson(json['status'] as String?), - dueDate: json['dueDate'] != null - ? DateTime.parse(json['dueDate'] as String) - : null, - paymentDate: json['paymentDate'] != null - ? DateTime.parse(json['paymentDate'] as String) - : null, + dueDate: tryParseUtcToLocal(json['dueDate'] as String?), + paymentDate: tryParseUtcToLocal(json['paymentDate'] as String?), vendorId: json['vendorId'] as String?, vendorName: json['vendorName'] as String?, ); diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/payment_chart_point.dart b/apps/mobile/packages/domain/lib/src/entities/financial/payment_chart_point.dart index 2e9f92f0..ac5dfbc5 100644 --- a/apps/mobile/packages/domain/lib/src/entities/financial/payment_chart_point.dart +++ b/apps/mobile/packages/domain/lib/src/entities/financial/payment_chart_point.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// A single data point in the staff payment chart. /// /// Returned by `GET /staff/payments/chart`. @@ -13,7 +15,7 @@ class PaymentChartPoint extends Equatable { /// Deserialises a [PaymentChartPoint] from a V2 API JSON map. factory PaymentChartPoint.fromJson(Map json) { return PaymentChartPoint( - bucket: DateTime.parse(json['bucket'] as String), + bucket: parseUtcToLocal(json['bucket'] as String), amountCents: (json['amountCents'] as num).toInt(), ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/staff_payment.dart b/apps/mobile/packages/domain/lib/src/entities/financial/staff_payment.dart index 3df1b383..159a7ef3 100644 --- a/apps/mobile/packages/domain/lib/src/entities/financial/staff_payment.dart +++ b/apps/mobile/packages/domain/lib/src/entities/financial/staff_payment.dart @@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/src/entities/enums/payment_status.dart'; +import '../../core/utils/utc_parser.dart'; + /// A single payment record for a staff member. /// /// Returned by `GET /staff/payments/history`. @@ -23,7 +25,7 @@ class PaymentRecord extends Equatable { return PaymentRecord( paymentId: json['paymentId'] as String, amountCents: (json['amountCents'] as num).toInt(), - date: DateTime.parse(json['date'] as String), + date: parseUtcToLocal(json['date'] as String), status: PaymentStatus.fromJson(json['status'] as String?), shiftName: json['shiftName'] as String?, location: json['location'] as String?, diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/time_card.dart b/apps/mobile/packages/domain/lib/src/entities/financial/time_card.dart index a5d459ec..2447d686 100644 --- a/apps/mobile/packages/domain/lib/src/entities/financial/time_card.dart +++ b/apps/mobile/packages/domain/lib/src/entities/financial/time_card.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// A single time-card entry for a completed shift. /// /// Returned by `GET /staff/profile/time-card`. @@ -19,15 +21,11 @@ class TimeCardEntry extends Equatable { /// Deserialises a [TimeCardEntry] from a V2 API JSON map. factory TimeCardEntry.fromJson(Map json) { return TimeCardEntry( - date: DateTime.parse(json['date'] as String), + date: parseUtcToLocal(json['date'] as String), shiftName: json['shiftName'] as String, location: json['location'] as String?, - clockInAt: json['clockInAt'] != null - ? DateTime.parse(json['clockInAt'] as String) - : null, - clockOutAt: json['clockOutAt'] != null - ? DateTime.parse(json['clockOutAt'] as String) - : null, + clockInAt: tryParseUtcToLocal(json['clockInAt'] as String?), + clockOutAt: tryParseUtcToLocal(json['clockOutAt'] as String?), minutesWorked: (json['minutesWorked'] as num).toInt(), hourlyRateCents: json['hourlyRateCents'] != null ? (json['hourlyRateCents'] as num).toInt() diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart b/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart index 473495ca..dfcd6072 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart @@ -3,6 +3,7 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/src/entities/enums/order_type.dart'; import 'package:krow_domain/src/entities/enums/shift_status.dart'; +import '../../core/utils/utc_parser.dart'; import 'assigned_worker_summary.dart'; /// A line item within an order, representing a role needed for a shift. @@ -42,9 +43,9 @@ class OrderItem extends Equatable { orderId: json['orderId'] as String, orderType: OrderType.fromJson(json['orderType'] as String?), roleName: json['roleName'] as String, - date: DateTime.parse(json['date'] as String), - startsAt: DateTime.parse(json['startsAt'] as String), - endsAt: DateTime.parse(json['endsAt'] as String), + date: parseUtcToLocal(json['date'] as String), + startsAt: parseUtcToLocal(json['startsAt'] as String), + endsAt: parseUtcToLocal(json['endsAt'] as String), requiredWorkerCount: (json['requiredWorkerCount'] as num).toInt(), filledCount: (json['filledCount'] as num).toInt(), hourlyRateCents: (json['hourlyRateCents'] as num).toInt(), diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/order_preview.dart b/apps/mobile/packages/domain/lib/src/entities/orders/order_preview.dart index 3bc41648..0ece8974 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/order_preview.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/order_preview.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// A preview of an order for reordering purposes. /// /// Returned by `GET /client/orders/:id/reorder-preview`. @@ -31,12 +33,8 @@ class OrderPreview extends Equatable { orderId: json['orderId'] as String, title: json['title'] as String, description: json['description'] as String?, - startsAt: json['startsAt'] != null - ? DateTime.parse(json['startsAt'] as String) - : null, - endsAt: json['endsAt'] != null - ? DateTime.parse(json['endsAt'] as String) - : null, + startsAt: tryParseUtcToLocal(json['startsAt'] as String?), + endsAt: tryParseUtcToLocal(json['endsAt'] as String?), locationName: json['locationName'] as String?, locationAddress: json['locationAddress'] as String?, metadata: json['metadata'] is Map @@ -128,8 +126,8 @@ class OrderPreviewShift extends Equatable { shiftId: json['shiftId'] as String, shiftCode: json['shiftCode'] as String, title: json['title'] as String, - startsAt: DateTime.parse(json['startsAt'] as String), - endsAt: DateTime.parse(json['endsAt'] as String), + startsAt: parseUtcToLocal(json['startsAt'] as String), + endsAt: parseUtcToLocal(json['endsAt'] as String), roles: rolesList, ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/recent_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/recent_order.dart index 453a048b..f3096033 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/recent_order.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/recent_order.dart @@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/src/entities/enums/order_type.dart'; +import '../../core/utils/utc_parser.dart'; + /// A recently completed order available for reordering. /// /// Returned by `GET /client/reorders`. @@ -21,9 +23,7 @@ class RecentOrder extends Equatable { return RecentOrder( id: json['id'] as String, title: json['title'] as String, - date: json['date'] != null - ? DateTime.parse(json['date'] as String) - : null, + date: tryParseUtcToLocal(json['date'] as String?), hubName: json['hubName'] as String?, positionCount: (json['positionCount'] as num).toInt(), orderType: OrderType.fromJson(json['orderType'] as String?), diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/certificate.dart b/apps/mobile/packages/domain/lib/src/entities/profile/certificate.dart index 388e154a..7a629fe5 100644 --- a/apps/mobile/packages/domain/lib/src/entities/profile/certificate.dart +++ b/apps/mobile/packages/domain/lib/src/entities/profile/certificate.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// Status of a staff certificate. enum CertificateStatus { /// Certificate uploaded, pending verification. @@ -45,8 +47,8 @@ class StaffCertificate extends Equatable { fileUri: json['fileUri'] as String?, issuer: json['issuer'] as String?, certificateNumber: json['certificateNumber'] as String?, - issuedAt: json['issuedAt'] != null ? DateTime.parse(json['issuedAt'] as String) : null, - expiresAt: json['expiresAt'] != null ? DateTime.parse(json['expiresAt'] as String) : null, + issuedAt: tryParseUtcToLocal(json['issuedAt'] as String?), + expiresAt: tryParseUtcToLocal(json['expiresAt'] as String?), status: _parseStatus(json['status'] as String?), verificationStatus: json['verificationStatus'] as String?, ); diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/profile_document.dart b/apps/mobile/packages/domain/lib/src/entities/profile/profile_document.dart index d044f4e5..97028fcf 100644 --- a/apps/mobile/packages/domain/lib/src/entities/profile/profile_document.dart +++ b/apps/mobile/packages/domain/lib/src/entities/profile/profile_document.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// Status of a profile document. enum ProfileDocumentStatus { /// Document has not been uploaded yet. @@ -59,7 +61,7 @@ class ProfileDocument extends Equatable { staffDocumentId: json['staffDocumentId'] as String?, fileUri: json['fileUri'] as String?, status: _parseStatus(json['status'] as String?), - expiresAt: json['expiresAt'] != null ? DateTime.parse(json['expiresAt'] as String) : null, + expiresAt: tryParseUtcToLocal(json['expiresAt'] as String?), metadata: (json['metadata'] as Map?) ?? const {}, ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/ratings/staff_rating.dart b/apps/mobile/packages/domain/lib/src/entities/ratings/staff_rating.dart index a1a70391..a7977548 100644 --- a/apps/mobile/packages/domain/lib/src/entities/ratings/staff_rating.dart +++ b/apps/mobile/packages/domain/lib/src/entities/ratings/staff_rating.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// A review left for a staff member after an assignment. /// /// Maps to the V2 `staff_reviews` table. @@ -35,9 +37,7 @@ class StaffRating extends Equatable { rating: (json['rating'] as num).toInt(), reviewText: json['reviewText'] as String?, tags: tagsList, - createdAt: json['createdAt'] != null - ? DateTime.parse(json['createdAt'] as String) - : null, + createdAt: tryParseUtcToLocal(json['createdAt'] as String?), ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/coverage_report.dart b/apps/mobile/packages/domain/lib/src/entities/reports/coverage_report.dart index 9ed90277..5930090d 100644 --- a/apps/mobile/packages/domain/lib/src/entities/reports/coverage_report.dart +++ b/apps/mobile/packages/domain/lib/src/entities/reports/coverage_report.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// Coverage report with daily breakdown. /// /// Returned by `GET /client/reports/coverage`. @@ -75,7 +77,7 @@ class CoverageDayPoint extends Equatable { /// Deserialises a [CoverageDayPoint] from a V2 API JSON map. factory CoverageDayPoint.fromJson(Map json) { return CoverageDayPoint( - day: DateTime.parse(json['day'] as String), + day: parseUtcToLocal(json['day'] as String), needed: (json['needed'] as num).toInt(), filled: (json['filled'] as num).toInt(), coveragePercentage: (json['coveragePercentage'] as num).toDouble(), diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/forecast_report.dart b/apps/mobile/packages/domain/lib/src/entities/reports/forecast_report.dart index 20c7a3a1..da7d7c1c 100644 --- a/apps/mobile/packages/domain/lib/src/entities/reports/forecast_report.dart +++ b/apps/mobile/packages/domain/lib/src/entities/reports/forecast_report.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// Staffing and spend forecast report. /// /// Returned by `GET /client/reports/forecast`. @@ -83,7 +85,7 @@ class ForecastWeek extends Equatable { /// Deserialises a [ForecastWeek] from a V2 API JSON map. factory ForecastWeek.fromJson(Map json) { return ForecastWeek( - week: DateTime.parse(json['week'] as String), + week: parseUtcToLocal(json['week'] as String), shiftCount: (json['shiftCount'] as num).toInt(), workerHours: (json['workerHours'] as num).toDouble(), forecastSpendCents: (json['forecastSpendCents'] as num).toInt(), diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/no_show_report.dart b/apps/mobile/packages/domain/lib/src/entities/reports/no_show_report.dart index f4f9047c..a9f13f6b 100644 --- a/apps/mobile/packages/domain/lib/src/entities/reports/no_show_report.dart +++ b/apps/mobile/packages/domain/lib/src/entities/reports/no_show_report.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// No-show report with per-worker incident details. /// /// Returned by `GET /client/reports/no-show`. @@ -143,7 +145,7 @@ class NoShowIncident extends Equatable { shiftId: json['shiftId'] as String, shiftTitle: json['shiftTitle'] as String, roleName: json['roleName'] as String, - date: DateTime.parse(json['date'] as String), + date: parseUtcToLocal(json['date'] as String), ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/spend_data_point.dart b/apps/mobile/packages/domain/lib/src/entities/reports/spend_data_point.dart index 30480fae..0e39e55f 100644 --- a/apps/mobile/packages/domain/lib/src/entities/reports/spend_data_point.dart +++ b/apps/mobile/packages/domain/lib/src/entities/reports/spend_data_point.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; import '../financial/spend_item.dart'; /// Spend report with total, chart data points, and category breakdown. @@ -71,7 +72,7 @@ class SpendDataPoint extends Equatable { /// Deserialises a [SpendDataPoint] from a V2 API JSON map. factory SpendDataPoint.fromJson(Map json) { return SpendDataPoint( - bucket: DateTime.parse(json['bucket'] as String), + bucket: parseUtcToLocal(json['bucket'] as String), amountCents: (json['amountCents'] as num).toInt(), ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/assigned_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/assigned_shift.dart index 11b27bc1..1ab5f69e 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/assigned_shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/assigned_shift.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; +import 'package:krow_domain/src/core/utils/utc_parser.dart'; import 'package:krow_domain/src/entities/enums/assignment_status.dart'; import 'package:krow_domain/src/entities/enums/order_type.dart'; @@ -33,9 +34,9 @@ class AssignedShift extends Equatable { shiftId: json['shiftId'] as String, roleName: json['roleName'] as String, location: json['location'] as String? ?? '', - date: DateTime.parse(json['date'] as String), - startTime: DateTime.parse(json['startTime'] as String), - endTime: DateTime.parse(json['endTime'] as String), + date: parseUtcToLocal(json['date'] as String), + startTime: parseUtcToLocal(json['startTime'] as String), + endTime: parseUtcToLocal(json['endTime'] as String), hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0, totalRateCents: json['totalRateCents'] as int? ?? 0, diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/cancelled_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/cancelled_shift.dart index 6fb4741d..d2cff728 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/cancelled_shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/cancelled_shift.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import 'package:krow_domain/src/core/utils/utc_parser.dart'; + /// A shift whose assignment was cancelled. /// /// Returned by `GET /staff/shifts/cancelled`. Shows past assignments @@ -22,7 +24,7 @@ class CancelledShift extends Equatable { shiftId: json['shiftId'] as String, title: json['title'] as String? ?? '', location: json['location'] as String? ?? '', - date: DateTime.parse(json['date'] as String), + date: parseUtcToLocal(json['date'] as String), cancellationReason: json['cancellationReason'] as String?, ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart index 54f29d7d..8f99fc47 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart @@ -1,5 +1,8 @@ import 'package:equatable/equatable.dart'; -import 'package:krow_domain/krow_domain.dart'; + +import 'package:krow_domain/src/core/utils/utc_parser.dart'; +import 'package:krow_domain/src/entities/enums/assignment_status.dart'; +import 'package:krow_domain/src/entities/enums/payment_status.dart'; /// A shift the staff member has completed. /// @@ -34,12 +37,12 @@ class CompletedShift extends Equatable { title: json['title'] as String? ?? '', location: json['location'] as String? ?? '', clientName: json['clientName'] as String? ?? '', - date: DateTime.parse(json['date'] as String), + date: parseUtcToLocal(json['date'] as String), startTime: json['startTime'] != null - ? DateTime.parse(json['startTime'] as String) + ? parseUtcToLocal(json['startTime'] as String) : DateTime.now(), endTime: json['endTime'] != null - ? DateTime.parse(json['endTime'] as String) + ? parseUtcToLocal(json['endTime'] as String) : DateTime.now(), minutesWorked: json['minutesWorked'] as int? ?? 0, hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/open_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/open_shift.dart index 856deef5..f2b5c9a6 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/open_shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/open_shift.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; +import 'package:krow_domain/src/core/utils/utc_parser.dart'; import 'package:krow_domain/src/entities/enums/order_type.dart'; /// An open shift available for the staff member to apply to. @@ -32,9 +33,9 @@ class OpenShift extends Equatable { roleName: json['roleName'] as String, clientName: json['clientName'] as String? ?? '', location: json['location'] as String? ?? '', - date: DateTime.parse(json['date'] as String), - startTime: DateTime.parse(json['startTime'] as String), - endTime: DateTime.parse(json['endTime'] as String), + date: parseUtcToLocal(json['date'] as String), + startTime: parseUtcToLocal(json['startTime'] as String), + endTime: parseUtcToLocal(json['endTime'] as String), hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0, orderType: OrderType.fromJson(json['orderType'] as String?), diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/pending_assignment.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/pending_assignment.dart index b22d6bc4..c96c5810 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/pending_assignment.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/pending_assignment.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import 'package:krow_domain/src/core/utils/utc_parser.dart'; + /// An assignment awaiting the staff member's acceptance. /// /// Returned by `GET /staff/shifts/pending`. These are assignments with @@ -24,10 +26,10 @@ class PendingAssignment extends Equatable { shiftId: json['shiftId'] as String, title: json['title'] as String? ?? '', roleName: json['roleName'] as String, - startTime: DateTime.parse(json['startTime'] as String), - endTime: DateTime.parse(json['endTime'] as String), + startTime: parseUtcToLocal(json['startTime'] as String), + endTime: parseUtcToLocal(json['endTime'] as String), location: json['location'] as String? ?? '', - responseDeadline: DateTime.parse(json['responseDeadline'] as String), + responseDeadline: parseUtcToLocal(json['responseDeadline'] as String), ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart index 8b45cf75..69900461 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; +import 'package:krow_domain/src/core/utils/utc_parser.dart'; import 'package:krow_domain/src/entities/enums/shift_status.dart'; /// Core shift entity aligned with the V2 `shifts` table. @@ -48,10 +49,10 @@ class Shift extends Equatable { clientName ?? '', status: ShiftStatus.fromJson(json['status'] as String?), - startsAt: DateTime.parse( + startsAt: parseUtcToLocal( json['startsAt'] as String? ?? json['startTime'] as String, ), - endsAt: DateTime.parse( + endsAt: parseUtcToLocal( json['endsAt'] as String? ?? json['endTime'] as String, ), timezone: json['timezone'] as String? ?? 'UTC', diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart index c4082982..38e2dc23 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; +import 'package:krow_domain/src/core/utils/utc_parser.dart'; import 'package:krow_domain/src/entities/enums/application_status.dart'; import 'package:krow_domain/src/entities/enums/assignment_status.dart'; import 'package:krow_domain/src/entities/enums/order_type.dart'; @@ -53,9 +54,9 @@ class ShiftDetail extends Equatable { clientName: json['clientName'] as String? ?? '', latitude: Shift.parseDouble(json['latitude']), longitude: Shift.parseDouble(json['longitude']), - date: DateTime.parse(json['date'] as String), - startTime: DateTime.parse(json['startTime'] as String), - endTime: DateTime.parse(json['endTime'] as String), + date: parseUtcToLocal(json['date'] as String), + startTime: parseUtcToLocal(json['startTime'] as String), + endTime: parseUtcToLocal(json['endTime'] as String), roleId: json['roleId'] as String, roleName: json['roleName'] as String, hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/today_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/today_shift.dart index 01248ff3..cefd2f26 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/today_shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/today_shift.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; +import 'package:krow_domain/src/core/utils/utc_parser.dart'; import 'package:krow_domain/src/entities/enums/attendance_status_type.dart'; /// A shift assigned to the staff member for today. @@ -33,8 +34,8 @@ class TodayShift extends Equatable { shiftId: json['shiftId'] as String, roleName: json['roleName'] as String, location: json['location'] as String? ?? '', - startTime: DateTime.parse(json['startTime'] as String), - endTime: DateTime.parse(json['endTime'] as String), + startTime: parseUtcToLocal(json['startTime'] as String), + endTime: parseUtcToLocal(json['endTime'] as String), attendanceStatus: AttendanceStatusType.fromJson(json['attendanceStatus'] as String?), clientName: json['clientName'] as String? ?? '', hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, @@ -42,9 +43,7 @@ class TodayShift extends Equatable { totalRateCents: json['totalRateCents'] as int? ?? 0, totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0, locationAddress: json['locationAddress'] as String?, - clockInAt: json['clockInAt'] != null - ? DateTime.parse(json['clockInAt'] as String) - : null, + clockInAt: tryParseUtcToLocal(json['clockInAt'] as String?), ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/users/biz_member.dart b/apps/mobile/packages/domain/lib/src/entities/users/biz_member.dart index ee071a75..fce809bc 100644 --- a/apps/mobile/packages/domain/lib/src/entities/users/biz_member.dart +++ b/apps/mobile/packages/domain/lib/src/entities/users/biz_member.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// Membership status within a business. enum BusinessMembershipStatus { /// The user has been invited but has not accepted. @@ -63,8 +65,8 @@ class BusinessMembership extends Equatable { businessName: json['businessName'] as String?, businessSlug: json['businessSlug'] as String?, metadata: (json['metadata'] as Map?) ?? const {}, - createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'] as String) : null, - updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'] as String) : null, + createdAt: tryParseUtcToLocal(json['createdAt'] as String?), + updatedAt: tryParseUtcToLocal(json['updatedAt'] as String?), ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/users/staff.dart b/apps/mobile/packages/domain/lib/src/entities/users/staff.dart index d46849a0..bf2c9d89 100644 --- a/apps/mobile/packages/domain/lib/src/entities/users/staff.dart +++ b/apps/mobile/packages/domain/lib/src/entities/users/staff.dart @@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart' show OnboardingStatus, StaffStatus; +import '../../core/utils/utc_parser.dart'; + /// Represents a worker profile in the KROW platform. /// /// Maps to the V2 `staffs` table. Linked to a [User] via [userId]. @@ -47,8 +49,8 @@ class Staff extends Equatable { workforceId: json['workforceId'] as String?, vendorId: json['vendorId'] as String?, workforceNumber: json['workforceNumber'] as String?, - createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'] as String) : null, - updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'] as String) : null, + createdAt: tryParseUtcToLocal(json['createdAt'] as String?), + updatedAt: tryParseUtcToLocal(json['updatedAt'] as String?), ); } diff --git a/apps/mobile/packages/domain/lib/src/entities/users/user.dart b/apps/mobile/packages/domain/lib/src/entities/users/user.dart index fe2c5785..13eacc21 100644 --- a/apps/mobile/packages/domain/lib/src/entities/users/user.dart +++ b/apps/mobile/packages/domain/lib/src/entities/users/user.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import '../../core/utils/utc_parser.dart'; + /// Account status for a platform user. enum UserStatus { /// User is active and can sign in. @@ -37,8 +39,8 @@ class User extends Equatable { phone: json['phone'] as String?, status: _parseUserStatus(json['status'] as String?), metadata: (json['metadata'] as Map?) ?? const {}, - createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'] as String) : null, - updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'] as String) : null, + createdAt: tryParseUtcToLocal(json['createdAt'] as String?), + updatedAt: tryParseUtcToLocal(json['updatedAt'] as String?), ); } diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart index c3cf54d2..eace8942 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart @@ -75,7 +75,7 @@ class ReorderWidget extends StatelessWidget { borderRadius: UiConstants.radiusLg, ), child: const Icon( - UiIcons.building, + UiIcons.briefcase, size: 16, color: UiColors.primary, ), @@ -104,18 +104,6 @@ class ReorderWidget extends StatelessWidget { ], ), ), - // Column( - // crossAxisAlignment: CrossAxisAlignment.end, - // children: [ - // // ASSUMPTION: No i18n key for 'positions' under - // // reorder section — carrying forward existing - // // hardcoded string pattern for this migration. - // Text( - // '${order.positionCount} positions', - // style: UiTypography.footnote2r.textSecondary, - // ), - // ], - // ), ], ), const SizedBox(height: UiConstants.space3), @@ -130,7 +118,7 @@ class ReorderWidget extends StatelessWidget { ), const SizedBox(width: UiConstants.space2), _Badge( - icon: UiIcons.building, + icon: UiIcons.users, text: '${order.positionCount}', color: UiColors.textSecondary, bg: UiColors.buttonSecondaryStill, diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart index 44add689..7ba84dc8 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart @@ -48,13 +48,13 @@ class OrderEditSheetState extends State { _orderNameController = TextEditingController(text: widget.order.roleName); final String startHH = - widget.order.startsAt.toLocal().hour.toString().padLeft(2, '0'); + widget.order.startsAt.hour.toString().padLeft(2, '0'); final String startMM = - widget.order.startsAt.toLocal().minute.toString().padLeft(2, '0'); + widget.order.startsAt.minute.toString().padLeft(2, '0'); final String endHH = - widget.order.endsAt.toLocal().hour.toString().padLeft(2, '0'); + widget.order.endsAt.hour.toString().padLeft(2, '0'); final String endMM = - widget.order.endsAt.toLocal().minute.toString().padLeft(2, '0'); + widget.order.endsAt.minute.toString().padLeft(2, '0'); _positions = >[ { diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart index fa9fdd1a..969aed43 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart @@ -77,9 +77,8 @@ class _ViewOrderCardState extends State { /// Formats a [DateTime] to a display time string (e.g. "9:00 AM"). String _formatTime({required DateTime dateTime}) { - final DateTime local = dateTime.toLocal(); - final int hour24 = local.hour; - final int minute = local.minute; + final int hour24 = dateTime.hour; + final int minute = dateTime.minute; final String ampm = hour24 >= 12 ? 'PM' : 'AM'; int hour = hour24 % 12; if (hour == 0) hour = 12; From 96056d0170d936cde3c4dbcd54596df8dd8f3ab7 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 13:23:28 -0400 Subject: [PATCH 03/24] feat: Implement available orders feature in staff marketplace - Added `AvailableOrder` and `AvailableOrderSchedule` entities to represent available orders and their schedules. - Introduced `GetAvailableOrdersUseCase` and `BookOrderUseCase` for fetching and booking orders. - Created `AvailableOrdersBloc` to manage the state of available orders and handle booking actions. - Developed UI components including `AvailableOrderCard` to display order details and booking options. - Added necessary events and states for the BLoC architecture to support loading and booking orders. - Integrated new enums and utility functions for handling order types and scheduling. --- .../endpoints/client_endpoints.dart | 2 +- .../endpoints/staff_endpoints.dart | 8 + .../lib/src/l10n/en.i18n.json | 13 + .../lib/src/l10n/es.i18n.json | 13 + .../packages/domain/lib/krow_domain.dart | 7 +- .../lib/src/entities/enums/day_of_week.dart | 46 ++ .../src/entities/orders/available_order.dart | 145 ++++++ .../orders/available_order_schedule.dart | 99 +++++ .../orders/booking_assigned_shift.dart | 92 ++++ .../src/entities/orders/order_booking.dart | 94 ++++ .../shifts_repository_impl.dart | 34 ++ .../shifts_repository_interface.dart | 12 + .../domain/usecases/book_order_usecase.dart | 23 + .../get_available_orders_usecase.dart | 20 + .../available_orders_bloc.dart | 97 ++++ .../available_orders_event.dart | 45 ++ .../available_orders_state.dart | 74 ++++ .../src/presentation/pages/shifts_page.dart | 386 ++++++++-------- .../widgets/available_order_card.dart | 415 ++++++++++++++++++ .../widgets/tabs/find_shifts_tab.dart | 221 ++-------- .../shifts/lib/src/staff_shifts_module.dart | 11 + 21 files changed, 1498 insertions(+), 359 deletions(-) create mode 100644 apps/mobile/packages/domain/lib/src/entities/enums/day_of_week.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/orders/available_order.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/orders/available_order_schedule.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/orders/booking_assigned_shift.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/orders/order_booking.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/book_order_usecase.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_available_orders_usecase.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_bloc.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_event.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_state.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart diff --git a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart index aeb0f45f..d541c8ef 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart @@ -92,7 +92,7 @@ abstract final class ClientEndpoints { /// View orders. static const ApiEndpoint ordersView = - ApiEndpoint('/client/orders/view'); + ApiEndpoint('/client/shifts/scheduled'); /// Order reorder preview. static ApiEndpoint orderReorderPreview(String orderId) => diff --git a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart index d6fb3634..9f0f07aa 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart @@ -130,6 +130,10 @@ abstract final class StaffEndpoints { /// FAQs search. static const ApiEndpoint faqsSearch = ApiEndpoint('/staff/faqs/search'); + /// Available orders for the marketplace. + static const ApiEndpoint ordersAvailable = + ApiEndpoint('/staff/orders/available'); + // ── Write ───────────────────────────────────────────────────────────── /// Staff profile setup. @@ -198,6 +202,10 @@ abstract final class StaffEndpoints { static const ApiEndpoint locationStreams = ApiEndpoint('/staff/location-streams'); + /// Book an available order. + static ApiEndpoint orderBook(String orderId) => + ApiEndpoint('/staff/orders/$orderId/book'); + /// Register or delete device push token (POST to register, DELETE to remove). static const ApiEndpoint devicesPushTokens = ApiEndpoint('/staff/devices/push-tokens'); diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 4b63ce6b..cd8d5e29 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -1877,5 +1877,18 @@ "success_message": "Cash out request submitted!", "fee_notice": "A small fee of \\$1.99 may apply for instant transfers." } + }, + "available_orders": { + "book_order": "Book Order", + "apply": "Apply", + "spots_left": "${count} spot(s) left", + "shifts_count": "${count} shift(s)", + "booking_success": "Order booked successfully!", + "booking_pending": "Your booking is pending approval", + "booking_confirmed": "Your booking has been confirmed!", + "no_orders": "No orders available", + "no_orders_subtitle": "Check back later for new opportunities", + "instant_book": "Instant Book", + "per_hour": "/hr" } } \ No newline at end of file diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 731896fd..b7f47371 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -1877,5 +1877,18 @@ "success_message": "\u00a1Solicitud de retiro enviada!", "fee_notice": "Puede aplicarse una peque\u00f1a tarifa de \\$1.99 para transferencias instant\u00e1neas." } + }, + "available_orders": { + "book_order": "Reservar Orden", + "apply": "Aplicar", + "spots_left": "${count} puesto(s) disponible(s)", + "shifts_count": "${count} turno(s)", + "booking_success": "\u00a1Orden reservada con \u00e9xito!", + "booking_pending": "Tu reserva est\u00e1 pendiente de aprobaci\u00f3n", + "booking_confirmed": "\u00a1Tu reserva ha sido confirmada!", + "no_orders": "No hay \u00f3rdenes disponibles", + "no_orders_subtitle": "Vuelve m\u00e1s tarde para nuevas oportunidades", + "instant_book": "Reserva Instant\u00e1nea", + "per_hour": "/hr" } } \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index c1f7814f..c3e3db24 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -16,6 +16,7 @@ export 'src/entities/enums/benefit_status.dart'; export 'src/entities/enums/business_status.dart'; export 'src/entities/enums/invoice_status.dart'; export 'src/entities/enums/onboarding_status.dart'; +export 'src/entities/enums/day_of_week.dart'; export 'src/entities/enums/order_type.dart'; export 'src/entities/enums/payment_status.dart'; export 'src/entities/enums/review_issue_flag.dart'; @@ -69,8 +70,12 @@ export 'src/entities/shifts/completed_shift.dart'; export 'src/entities/shifts/shift_detail.dart'; // Orders -export 'src/entities/orders/order_item.dart'; +export 'src/entities/orders/available_order.dart'; +export 'src/entities/orders/available_order_schedule.dart'; export 'src/entities/orders/assigned_worker_summary.dart'; +export 'src/entities/orders/booking_assigned_shift.dart'; +export 'src/entities/orders/order_booking.dart'; +export 'src/entities/orders/order_item.dart'; export 'src/entities/orders/order_preview.dart'; export 'src/entities/orders/recent_order.dart'; diff --git a/apps/mobile/packages/domain/lib/src/entities/enums/day_of_week.dart b/apps/mobile/packages/domain/lib/src/entities/enums/day_of_week.dart new file mode 100644 index 00000000..2c4620b6 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/enums/day_of_week.dart @@ -0,0 +1,46 @@ +/// Day of the week for order scheduling. +/// +/// Maps to the `day_of_week` values used in V2 order schedule responses. +enum DayOfWeek { + /// Monday. + mon('MON'), + + /// Tuesday. + tue('TUE'), + + /// Wednesday. + wed('WED'), + + /// Thursday. + thu('THU'), + + /// Friday. + fri('FRI'), + + /// Saturday. + sat('SAT'), + + /// Sunday. + sun('SUN'), + + /// Fallback for unrecognised API values. + unknown('UNKNOWN'); + + const DayOfWeek(this.value); + + /// The V2 API string representation. + final String value; + + /// Deserialises from a V2 API string with safe fallback. + static DayOfWeek fromJson(String? value) { + if (value == null) return DayOfWeek.unknown; + final String upper = value.toUpperCase(); + for (final DayOfWeek day in DayOfWeek.values) { + if (day.value == upper) return day; + } + return DayOfWeek.unknown; + } + + /// Serialises to the V2 API string. + String toJson() => value; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/available_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/available_order.dart new file mode 100644 index 00000000..e2886c3c --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/available_order.dart @@ -0,0 +1,145 @@ +import 'package:equatable/equatable.dart'; + +import 'package:krow_domain/src/entities/enums/order_type.dart'; +import 'package:krow_domain/src/entities/orders/available_order_schedule.dart'; + +/// An available order in the staff marketplace. +/// +/// Returned by `GET /staff/orders/available`. Represents an order-level card +/// that a staff member can book into, containing role, location, pay rate, +/// and schedule details. +class AvailableOrder extends Equatable { + /// Creates an [AvailableOrder]. + const AvailableOrder({ + required this.orderId, + required this.orderType, + required this.roleId, + required this.roleCode, + required this.roleName, + this.clientName = '', + this.location = '', + this.locationAddress = '', + required this.hourlyRateCents, + required this.hourlyRate, + required this.requiredWorkerCount, + required this.filledCount, + required this.instantBook, + this.dispatchTeam = '', + this.dispatchPriority = 0, + required this.schedule, + }); + + /// Deserialises from the V2 API JSON response. + factory AvailableOrder.fromJson(Map json) { + return AvailableOrder( + orderId: json['orderId'] as String, + orderType: OrderType.fromJson(json['orderType'] as String?), + roleId: json['roleId'] as String, + roleCode: json['roleCode'] as String? ?? '', + roleName: json['roleName'] as String? ?? '', + clientName: json['clientName'] as String? ?? '', + location: json['location'] as String? ?? '', + locationAddress: json['locationAddress'] as String? ?? '', + hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, + hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0, + requiredWorkerCount: json['requiredWorkerCount'] as int? ?? 1, + filledCount: json['filledCount'] as int? ?? 0, + instantBook: json['instantBook'] as bool? ?? false, + dispatchTeam: json['dispatchTeam'] as String? ?? '', + dispatchPriority: json['dispatchPriority'] as int? ?? 0, + schedule: AvailableOrderSchedule.fromJson( + json['schedule'] as Map, + ), + ); + } + + /// The order row id. + final String orderId; + + /// Type of order (one-time, recurring, permanent, etc.). + final OrderType orderType; + + /// The shift-role row id. + final String roleId; + + /// Machine-readable role code. + final String roleCode; + + /// Display name of the role. + final String roleName; + + /// Name of the client/business offering this order. + final String clientName; + + /// Human-readable location label. + final String location; + + /// Full street address of the location. + final String locationAddress; + + /// Pay rate in cents per hour. + final int hourlyRateCents; + + /// Pay rate in dollars per hour. + final double hourlyRate; + + /// Total number of workers required for this role. + final int requiredWorkerCount; + + /// Number of positions already filled. + final int filledCount; + + /// Whether the order supports instant booking (no approval needed). + final bool instantBook; + + /// Dispatch team identifier. + final String dispatchTeam; + + /// Priority level for dispatch ordering. + final int dispatchPriority; + + /// Schedule details including recurrence, times, and bounding timestamps. + final AvailableOrderSchedule schedule; + + /// Serialises to JSON. + Map toJson() { + return { + 'orderId': orderId, + 'orderType': orderType.toJson(), + 'roleId': roleId, + 'roleCode': roleCode, + 'roleName': roleName, + 'clientName': clientName, + 'location': location, + 'locationAddress': locationAddress, + 'hourlyRateCents': hourlyRateCents, + 'hourlyRate': hourlyRate, + 'requiredWorkerCount': requiredWorkerCount, + 'filledCount': filledCount, + 'instantBook': instantBook, + 'dispatchTeam': dispatchTeam, + 'dispatchPriority': dispatchPriority, + 'schedule': schedule.toJson(), + }; + } + + @override + List get props => [ + orderId, + orderType, + roleId, + roleCode, + roleName, + clientName, + location, + locationAddress, + hourlyRateCents, + hourlyRate, + requiredWorkerCount, + filledCount, + instantBook, + dispatchTeam, + dispatchPriority, + schedule, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/available_order_schedule.dart b/apps/mobile/packages/domain/lib/src/entities/orders/available_order_schedule.dart new file mode 100644 index 00000000..56c0704c --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/available_order_schedule.dart @@ -0,0 +1,99 @@ +import 'package:equatable/equatable.dart'; + +import 'package:krow_domain/src/core/utils/utc_parser.dart'; +import 'package:krow_domain/src/entities/enums/day_of_week.dart'; + +/// Schedule details for an available order in the marketplace. +/// +/// Contains the recurrence pattern, time window, and bounding timestamps +/// for the order's shifts. +class AvailableOrderSchedule extends Equatable { + /// Creates an [AvailableOrderSchedule]. + const AvailableOrderSchedule({ + required this.totalShifts, + required this.startDate, + required this.endDate, + required this.daysOfWeek, + required this.startTime, + required this.endTime, + required this.timezone, + required this.firstShiftStartsAt, + required this.lastShiftEndsAt, + }); + + /// Deserialises from the V2 API JSON response. + factory AvailableOrderSchedule.fromJson(Map json) { + return AvailableOrderSchedule( + totalShifts: json['totalShifts'] as int? ?? 0, + startDate: json['startDate'] as String? ?? '', + endDate: json['endDate'] as String? ?? '', + daysOfWeek: (json['daysOfWeek'] as List?) + ?.map( + (dynamic e) => DayOfWeek.fromJson(e as String), + ) + .toList() ?? + [], + startTime: json['startTime'] as String? ?? '', + endTime: json['endTime'] as String? ?? '', + timezone: json['timezone'] as String? ?? 'UTC', + firstShiftStartsAt: + parseUtcToLocal(json['firstShiftStartsAt'] as String), + lastShiftEndsAt: parseUtcToLocal(json['lastShiftEndsAt'] as String), + ); + } + + /// Total number of shifts in this schedule. + final int totalShifts; + + /// Date-only start string (e.g. "2026-03-24"). + final String startDate; + + /// Date-only end string. + final String endDate; + + /// Days of the week the order repeats on. + final List daysOfWeek; + + /// Daily start time display string (e.g. "09:00"). + final String startTime; + + /// Daily end time display string (e.g. "15:00"). + final String endTime; + + /// IANA timezone identifier (e.g. "America/Los_Angeles"). + final String timezone; + + /// UTC timestamp of the first shift's start, converted to local time. + final DateTime firstShiftStartsAt; + + /// UTC timestamp of the last shift's end, converted to local time. + final DateTime lastShiftEndsAt; + + /// Serialises to JSON. + Map toJson() => { + 'totalShifts': totalShifts, + 'startDate': startDate, + 'endDate': endDate, + 'daysOfWeek': + daysOfWeek.map((DayOfWeek e) => e.toJson()).toList(), + 'startTime': startTime, + 'endTime': endTime, + 'timezone': timezone, + 'firstShiftStartsAt': + firstShiftStartsAt.toUtc().toIso8601String(), + 'lastShiftEndsAt': lastShiftEndsAt.toUtc().toIso8601String(), + }; + + @override + List get props => [ + totalShifts, + startDate, + endDate, + daysOfWeek, + startTime, + endTime, + timezone, + firstShiftStartsAt, + lastShiftEndsAt, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/booking_assigned_shift.dart b/apps/mobile/packages/domain/lib/src/entities/orders/booking_assigned_shift.dart new file mode 100644 index 00000000..e4b2c8a3 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/booking_assigned_shift.dart @@ -0,0 +1,92 @@ +import 'package:equatable/equatable.dart'; + +import 'package:krow_domain/src/core/utils/utc_parser.dart'; + +/// A shift assigned to a staff member as part of an order booking. +/// +/// Returned within the `assignedShifts` array of the +/// `POST /staff/orders/:orderId/book` response. +class BookingAssignedShift extends Equatable { + /// Creates a [BookingAssignedShift]. + const BookingAssignedShift({ + required this.shiftId, + required this.date, + required this.startsAt, + required this.endsAt, + required this.startTime, + required this.endTime, + required this.timezone, + required this.assignmentId, + this.assignmentStatus = '', + }); + + /// Deserialises from the V2 API JSON response. + factory BookingAssignedShift.fromJson(Map json) { + return BookingAssignedShift( + shiftId: json['shiftId'] as String, + date: json['date'] as String? ?? '', + startsAt: parseUtcToLocal(json['startsAt'] as String), + endsAt: parseUtcToLocal(json['endsAt'] as String), + startTime: json['startTime'] as String? ?? '', + endTime: json['endTime'] as String? ?? '', + timezone: json['timezone'] as String? ?? 'UTC', + assignmentId: json['assignmentId'] as String, + assignmentStatus: json['assignmentStatus'] as String? ?? '', + ); + } + + /// The shift row id. + final String shiftId; + + /// Date-only display string (e.g. "2026-03-24"). + final String date; + + /// UTC start timestamp converted to local time. + final DateTime startsAt; + + /// UTC end timestamp converted to local time. + final DateTime endsAt; + + /// Display start time string (e.g. "09:00"). + final String startTime; + + /// Display end time string (e.g. "15:00"). + final String endTime; + + /// IANA timezone identifier. + final String timezone; + + /// The assignment row id linking staff to this shift. + final String assignmentId; + + /// Current status of the assignment (e.g. "ASSIGNED"). + final String assignmentStatus; + + /// Serialises to JSON. + Map toJson() { + return { + 'shiftId': shiftId, + 'date': date, + 'startsAt': startsAt.toUtc().toIso8601String(), + 'endsAt': endsAt.toUtc().toIso8601String(), + 'startTime': startTime, + 'endTime': endTime, + 'timezone': timezone, + 'assignmentId': assignmentId, + 'assignmentStatus': assignmentStatus, + }; + } + + @override + List get props => [ + shiftId, + date, + startsAt, + endsAt, + startTime, + endTime, + timezone, + assignmentId, + assignmentStatus, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/order_booking.dart b/apps/mobile/packages/domain/lib/src/entities/orders/order_booking.dart new file mode 100644 index 00000000..d4db906a --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/order_booking.dart @@ -0,0 +1,94 @@ +import 'package:equatable/equatable.dart'; + +import 'package:krow_domain/src/entities/orders/booking_assigned_shift.dart'; + +/// Result of booking an order via `POST /staff/orders/:orderId/book`. +/// +/// Contains the booking metadata and the list of shifts assigned to the +/// staff member as part of this booking. +class OrderBooking extends Equatable { + /// Creates an [OrderBooking]. + const OrderBooking({ + required this.bookingId, + required this.orderId, + required this.roleId, + this.roleCode = '', + this.roleName = '', + required this.assignedShiftCount, + this.status = 'PENDING', + this.assignedShifts = const [], + }); + + /// Deserialises from the V2 API JSON response. + factory OrderBooking.fromJson(Map json) { + return OrderBooking( + bookingId: json['bookingId'] as String, + orderId: json['orderId'] as String, + roleId: json['roleId'] as String, + roleCode: json['roleCode'] as String? ?? '', + roleName: json['roleName'] as String? ?? '', + assignedShiftCount: json['assignedShiftCount'] as int? ?? 0, + status: json['status'] as String? ?? 'PENDING', + assignedShifts: (json['assignedShifts'] as List?) + ?.map( + (dynamic e) => BookingAssignedShift.fromJson( + e as Map, + ), + ) + .toList() ?? + [], + ); + } + + /// Unique booking identifier. + final String bookingId; + + /// The order this booking belongs to. + final String orderId; + + /// The role row id within the order. + final String roleId; + + /// Machine-readable role code. + final String roleCode; + + /// Display name of the role. + final String roleName; + + /// Number of shifts assigned in this booking. + final int assignedShiftCount; + + /// Booking status (e.g. "PENDING", "CONFIRMED"). + final String status; + + /// The individual shifts assigned as part of this booking. + final List assignedShifts; + + /// Serialises to JSON. + Map toJson() { + return { + 'bookingId': bookingId, + 'orderId': orderId, + 'roleId': roleId, + 'roleCode': roleCode, + 'roleName': roleName, + 'assignedShiftCount': assignedShiftCount, + 'status': status, + 'assignedShifts': assignedShifts + .map((BookingAssignedShift e) => e.toJson()) + .toList(), + }; + } + + @override + List get props => [ + bookingId, + orderId, + roleId, + roleCode, + roleName, + assignedShiftCount, + status, + assignedShifts, + ]; +} 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 6f474dfd..2ade65ba 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 @@ -165,4 +165,38 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { final ProfileCompletion completion = ProfileCompletion.fromJson(data); return completion.completed; } + + @override + Future> getAvailableOrders({ + String? search, + int limit = 20, + }) async { + final Map params = { + 'limit': limit, + }; + if (search != null && search.isNotEmpty) { + params['search'] = search; + } + final ApiResponse response = await _apiService.get( + StaffEndpoints.ordersAvailable, + params: params, + ); + final List items = _extractItems(response.data); + return items + .map((dynamic json) => + AvailableOrder.fromJson(json as Map)) + .toList(); + } + + @override + Future bookOrder({ + required String orderId, + required String roleId, + }) async { + final ApiResponse response = await _apiService.post( + StaffEndpoints.orderBook(orderId), + data: {'roleId': roleId}, + ); + return OrderBooking.fromJson(response.data as Map); + } } 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 d6583347..7d6cdab9 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 @@ -52,4 +52,16 @@ abstract interface class ShiftsRepositoryInterface { /// /// Only allowed for shifts in CHECKED_OUT or COMPLETED status. Future submitForApproval(String shiftId, {String? note}); + + /// Retrieves available orders from the staff marketplace. + Future> getAvailableOrders({ + String? search, + int limit, + }); + + /// Books an order by placing the staff member into a role. + Future bookOrder({ + required String orderId, + required String roleId, + }); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/book_order_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/book_order_usecase.dart new file mode 100644 index 00000000..697ea030 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/book_order_usecase.dart @@ -0,0 +1,23 @@ +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; + +/// Books an available order for the current staff member. +/// +/// Delegates to [ShiftsRepositoryInterface.bookOrder] with the order and +/// role identifiers. +class BookOrderUseCase { + /// Creates a [BookOrderUseCase]. + BookOrderUseCase(this._repository); + + /// The shifts repository. + final ShiftsRepositoryInterface _repository; + + /// Executes the use case, returning the [OrderBooking] result. + Future call({ + required String orderId, + required String roleId, + }) { + return _repository.bookOrder(orderId: orderId, roleId: roleId); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_available_orders_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_available_orders_usecase.dart new file mode 100644 index 00000000..2e411223 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_available_orders_usecase.dart @@ -0,0 +1,20 @@ +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; + +/// Retrieves available orders from the staff marketplace. +/// +/// Delegates to [ShiftsRepositoryInterface.getAvailableOrders] with an +/// optional search filter. +class GetAvailableOrdersUseCase { + /// Creates a [GetAvailableOrdersUseCase]. + GetAvailableOrdersUseCase(this._repository); + + /// The shifts repository. + final ShiftsRepositoryInterface _repository; + + /// Executes the use case, returning a list of [AvailableOrder]. + Future> call({String? search, int limit = 20}) { + return _repository.getAvailableOrders(search: search, limit: limit); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_bloc.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_bloc.dart new file mode 100644 index 00000000..af857667 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_bloc.dart @@ -0,0 +1,97 @@ +import 'package:bloc/bloc.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_shifts/src/domain/usecases/book_order_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_available_orders_usecase.dart'; + +import 'available_orders_event.dart'; +import 'available_orders_state.dart'; + +/// Manages the state for the available-orders marketplace tab. +/// +/// Loads order-level cards from `GET /staff/orders/available` and handles +/// booking via `POST /staff/orders/:orderId/book`. +class AvailableOrdersBloc + extends Bloc + with BlocErrorHandler { + /// Creates an [AvailableOrdersBloc]. + AvailableOrdersBloc({ + required GetAvailableOrdersUseCase getAvailableOrders, + required BookOrderUseCase bookOrder, + }) : _getAvailableOrders = getAvailableOrders, + _bookOrder = bookOrder, + super(const AvailableOrdersState()) { + on(_onLoadAvailableOrders); + on(_onBookOrder); + on(_onClearBookingResult); + } + + /// Use case for fetching available orders. + final GetAvailableOrdersUseCase _getAvailableOrders; + + /// Use case for booking an order. + final BookOrderUseCase _bookOrder; + + Future _onLoadAvailableOrders( + LoadAvailableOrdersEvent event, + Emitter emit, + ) async { + emit(state.copyWith( + status: AvailableOrdersStatus.loading, + clearErrorMessage: true, + )); + + await handleError( + emit: emit.call, + action: () async { + final List orders = + await _getAvailableOrders(search: event.search); + emit(state.copyWith( + status: AvailableOrdersStatus.loaded, + orders: orders, + clearErrorMessage: true, + )); + }, + onError: (String errorKey) => state.copyWith( + status: AvailableOrdersStatus.error, + errorMessage: errorKey, + ), + ); + } + + Future _onBookOrder( + BookOrderEvent event, + Emitter emit, + ) async { + emit(state.copyWith(bookingInProgress: true, clearErrorMessage: true)); + + await handleError( + emit: emit.call, + action: () async { + final OrderBooking booking = await _bookOrder( + orderId: event.orderId, + roleId: event.roleId, + ); + emit(state.copyWith( + bookingInProgress: false, + lastBooking: booking, + clearErrorMessage: true, + )); + // Reload orders after successful booking. + add(const LoadAvailableOrdersEvent()); + }, + onError: (String errorKey) => state.copyWith( + bookingInProgress: false, + errorMessage: errorKey, + ), + ); + } + + void _onClearBookingResult( + ClearBookingResultEvent event, + Emitter emit, + ) { + emit(state.copyWith(clearLastBooking: true, clearErrorMessage: true)); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_event.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_event.dart new file mode 100644 index 00000000..7958152d --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_event.dart @@ -0,0 +1,45 @@ +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; + +/// Base class for all available-orders events. +@immutable +sealed class AvailableOrdersEvent extends Equatable { + /// Creates an [AvailableOrdersEvent]. + const AvailableOrdersEvent(); + + @override + List get props => []; +} + +/// Loads available orders from the staff marketplace. +class LoadAvailableOrdersEvent extends AvailableOrdersEvent { + /// Creates a [LoadAvailableOrdersEvent]. + const LoadAvailableOrdersEvent({this.search}); + + /// Optional search query to filter orders. + final String? search; + + @override + List get props => [search]; +} + +/// Books the staff member into an order for a specific role. +class BookOrderEvent extends AvailableOrdersEvent { + /// Creates a [BookOrderEvent]. + const BookOrderEvent({required this.orderId, required this.roleId}); + + /// The order to book. + final String orderId; + + /// The role within the order to fill. + final String roleId; + + @override + List get props => [orderId, roleId]; +} + +/// Clears the last booking result so the UI can dismiss confirmation. +class ClearBookingResultEvent extends AvailableOrdersEvent { + /// Creates a [ClearBookingResultEvent]. + const ClearBookingResultEvent(); +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_state.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_state.dart new file mode 100644 index 00000000..dfccd245 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/available_orders/available_orders_state.dart @@ -0,0 +1,74 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Lifecycle status for the available-orders list. +enum AvailableOrdersStatus { + /// No data has been requested yet. + initial, + + /// A load is in progress. + loading, + + /// Data has been loaded successfully. + loaded, + + /// An error occurred during loading. + error, +} + +/// State for the available-orders marketplace tab. +class AvailableOrdersState extends Equatable { + /// Creates an [AvailableOrdersState]. + const AvailableOrdersState({ + this.status = AvailableOrdersStatus.initial, + this.orders = const [], + this.bookingInProgress = false, + this.lastBooking, + this.errorMessage, + }); + + /// Current lifecycle status. + final AvailableOrdersStatus status; + + /// The list of available orders. + final List orders; + + /// Whether a booking request is currently in flight. + final bool bookingInProgress; + + /// The result of the most recent booking, if any. + final OrderBooking? lastBooking; + + /// Error message key for display. + final String? errorMessage; + + /// Creates a copy with the given fields replaced. + AvailableOrdersState copyWith({ + AvailableOrdersStatus? status, + List? orders, + bool? bookingInProgress, + OrderBooking? lastBooking, + bool clearLastBooking = false, + String? errorMessage, + bool clearErrorMessage = false, + }) { + return AvailableOrdersState( + status: status ?? this.status, + orders: orders ?? this.orders, + bookingInProgress: bookingInProgress ?? this.bookingInProgress, + lastBooking: + clearLastBooking ? null : (lastBooking ?? this.lastBooking), + errorMessage: + clearErrorMessage ? null : (errorMessage ?? this.errorMessage), + ); + } + + @override + List get props => [ + status, + orders, + bookingInProgress, + lastBooking, + errorMessage, + ]; +} 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 bb0bc006..7f08bbaa 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 @@ -5,6 +5,9 @@ import 'package:design_system/design_system.dart'; import 'package:core_localization/core_localization.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_bloc.dart'; +import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_event.dart'; +import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_state.dart'; import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; import 'package:staff_shifts/src/presentation/utils/shift_tab_type.dart'; import 'package:staff_shifts/src/presentation/widgets/shifts_page_skeleton.dart'; @@ -14,7 +17,8 @@ import 'package:staff_shifts/src/presentation/widgets/tabs/history_shifts_tab.da /// Tabbed page for browsing staff shifts (My Shifts, Find Work, History). /// -/// Manages tab state locally and delegates data loading to [ShiftsBloc]. +/// Manages tab state locally and delegates data loading to [ShiftsBloc] +/// and [AvailableOrdersBloc]. class ShiftsPage extends StatefulWidget { /// Creates a [ShiftsPage]. /// @@ -45,9 +49,9 @@ class _ShiftsPageState extends State { late ShiftTabType _activeTab; DateTime? _selectedDate; bool _prioritizeFind = false; - bool _refreshAvailable = false; bool _pendingAvailableRefresh = false; final ShiftsBloc _bloc = Modular.get(); + final AvailableOrdersBloc _ordersBloc = Modular.get(); @override void initState() { @@ -55,7 +59,6 @@ class _ShiftsPageState extends State { _activeTab = widget.initialTab ?? ShiftTabType.find; _selectedDate = widget.selectedDate; _prioritizeFind = _activeTab == ShiftTabType.find; - _refreshAvailable = widget.refreshAvailable; _pendingAvailableRefresh = widget.refreshAvailable; if (_prioritizeFind) { _bloc.add(LoadFindFirstEvent()); @@ -66,9 +69,8 @@ class _ShiftsPageState extends State { _bloc.add(LoadHistoryShiftsEvent()); } if (_activeTab == ShiftTabType.find) { - if (!_prioritizeFind) { - _bloc.add(LoadAvailableShiftsEvent(force: _refreshAvailable)); - } + // Load available orders via the new BLoC. + _ordersBloc.add(const LoadAvailableOrdersEvent()); } // Check profile completion @@ -90,160 +92,193 @@ class _ShiftsPageState extends State { }); } if (widget.refreshAvailable) { - _refreshAvailable = true; _pendingAvailableRefresh = true; } } @override Widget build(BuildContext context) { - final t = Translations.of(context); - return BlocProvider.value( - value: _bloc, - child: BlocConsumer( - listener: (context, state) { - if (state.status == ShiftsStatus.error && - state.errorMessage != null) { + final Translations t = Translations.of(context); + return MultiBlocProvider( + providers: >[ + BlocProvider.value(value: _bloc), + BlocProvider.value(value: _ordersBloc), + ], + child: BlocListener( + listener: (BuildContext context, AvailableOrdersState ordersState) { + // Show booking success / error snackbar. + if (ordersState.lastBooking != null) { + final OrderBooking booking = ordersState.lastBooking!; + final String message = + booking.status.toUpperCase() == 'CONFIRMED' + ? t.available_orders.booking_confirmed + : t.available_orders.booking_pending; UiSnackbar.show( context, - message: translateErrorKey(state.errorMessage!), + message: message, + type: UiSnackbarType.success, + ); + _ordersBloc.add(const ClearBookingResultEvent()); + } + if (ordersState.errorMessage != null) { + UiSnackbar.show( + context, + message: translateErrorKey(ordersState.errorMessage!), type: UiSnackbarType.error, ); } }, - builder: (context, state) { - if (_pendingAvailableRefresh && state.status == ShiftsStatus.loaded) { - _pendingAvailableRefresh = false; - _bloc.add(const LoadAvailableShiftsEvent(force: true)); - } - final bool baseLoaded = state.status == ShiftsStatus.loaded; - final List myShifts = state.myShifts; - final List availableJobs = state.availableShifts; - final bool availableLoading = state.availableLoading; - final bool availableLoaded = state.availableLoaded; - final List pendingAssignments = state.pendingShifts; - final List cancelledShifts = state.cancelledShifts; - final List historyShifts = state.historyShifts; - final bool historyLoading = state.historyLoading; - final bool historyLoaded = state.historyLoaded; - final bool myShiftsLoaded = state.myShiftsLoaded; - final bool blockTabsForFind = _prioritizeFind && !availableLoaded; + child: BlocConsumer( + listener: (BuildContext context, ShiftsState state) { + if (state.status == ShiftsStatus.error && + state.errorMessage != null) { + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage!), + type: UiSnackbarType.error, + ); + } + }, + builder: (BuildContext context, ShiftsState state) { + if (_pendingAvailableRefresh && + state.status == ShiftsStatus.loaded) { + _pendingAvailableRefresh = false; + _ordersBloc.add(const LoadAvailableOrdersEvent()); + } + final bool baseLoaded = state.status == ShiftsStatus.loaded; + final List myShifts = state.myShifts; + final List pendingAssignments = + state.pendingShifts; + final List cancelledShifts = state.cancelledShifts; + final List historyShifts = state.historyShifts; + final bool historyLoading = state.historyLoading; + final bool historyLoaded = state.historyLoaded; + final bool myShiftsLoaded = state.myShiftsLoaded; - // Note: "filteredJobs" logic moved to FindShiftsTab - // Note: Calendar logic moved to MyShiftsTab - - return Scaffold( - body: Column( - children: [ - // Header (Blue) - Container( - color: UiColors.primary, - padding: EdgeInsets.fromLTRB( - UiConstants.space5, - MediaQuery.of(context).padding.top + UiConstants.space2, - UiConstants.space5, - UiConstants.space5, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: UiConstants.space4, - children: [ - Text( - t.staff_shifts.title, - style: UiTypography.display1b.white, - ), - - // Tabs - Row( - children: [ - if (state.profileComplete != false) - Expanded( - child: _buildTab( - ShiftTabType.myShifts, - t.staff_shifts.tabs.my_shifts, - UiIcons.calendar, - myShifts.length, - showCount: myShiftsLoaded, - enabled: - !blockTabsForFind && - (state.profileComplete ?? false), - ), - ) - else - const SizedBox.shrink(), - if (state.profileComplete != false) - const SizedBox(width: UiConstants.space2) - else - const SizedBox.shrink(), - _buildTab( - ShiftTabType.find, - t.staff_shifts.tabs.find_work, - UiIcons.search, - availableJobs.length, - showCount: availableLoaded, - enabled: baseLoaded, - ), - if (state.profileComplete != false) - const SizedBox(width: UiConstants.space2) - else - const SizedBox.shrink(), - if (state.profileComplete != false) - Expanded( - child: _buildTab( - ShiftTabType.history, - t.staff_shifts.tabs.history, - UiIcons.clock, - historyShifts.length, - showCount: historyLoaded, - enabled: - !blockTabsForFind && - baseLoaded && - (state.profileComplete ?? false), - ), - ) - else - const SizedBox.shrink(), - ], - ), - ], - ), - ), - - // Body Content - Expanded( - child: state.status == ShiftsStatus.loading - ? const ShiftsPageSkeleton() - : state.status == ShiftsStatus.error - ? Center( - child: Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - translateErrorKey(state.errorMessage ?? ''), - style: UiTypography.body2r.textSecondary, - textAlign: TextAlign.center, - ), - ], - ), - ), - ) - : _buildTabContent( - state, - myShifts, - pendingAssignments, - cancelledShifts, - availableJobs, - historyShifts, - availableLoading, - historyLoading, + return Scaffold( + body: Column( + children: [ + // Header (Blue) + Container( + color: UiColors.primary, + padding: EdgeInsets.fromLTRB( + UiConstants.space5, + MediaQuery.of(context).padding.top + UiConstants.space2, + UiConstants.space5, + UiConstants.space5, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: UiConstants.space4, + children: [ + Text( + t.staff_shifts.title, + style: UiTypography.display1b.white, ), - ), - ], - ), - ); - }, + + // Tabs -- use BlocBuilder on orders bloc for count + BlocBuilder( + builder: (BuildContext context, + AvailableOrdersState ordersState) { + final bool ordersLoaded = ordersState.status == + AvailableOrdersStatus.loaded; + final int ordersCount = ordersState.orders.length; + final bool blockTabsForFind = + _prioritizeFind && !ordersLoaded; + + return Row( + children: [ + if (state.profileComplete != false) + Expanded( + child: _buildTab( + ShiftTabType.myShifts, + t.staff_shifts.tabs.my_shifts, + UiIcons.calendar, + myShifts.length, + showCount: myShiftsLoaded, + enabled: !blockTabsForFind && + (state.profileComplete ?? false), + ), + ) + else + const SizedBox.shrink(), + if (state.profileComplete != false) + const SizedBox(width: UiConstants.space2) + else + const SizedBox.shrink(), + _buildTab( + ShiftTabType.find, + t.staff_shifts.tabs.find_work, + UiIcons.search, + ordersCount, + showCount: ordersLoaded, + enabled: baseLoaded, + ), + if (state.profileComplete != false) + const SizedBox(width: UiConstants.space2) + else + const SizedBox.shrink(), + if (state.profileComplete != false) + Expanded( + child: _buildTab( + ShiftTabType.history, + t.staff_shifts.tabs.history, + UiIcons.clock, + historyShifts.length, + showCount: historyLoaded, + enabled: !blockTabsForFind && + baseLoaded && + (state.profileComplete ?? false), + ), + ) + else + const SizedBox.shrink(), + ], + ); + }, + ), + ], + ), + ), + + // Body Content + Expanded( + child: state.status == ShiftsStatus.loading + ? const ShiftsPageSkeleton() + : state.status == ShiftsStatus.error + ? Center( + child: Padding( + padding: + const EdgeInsets.all(UiConstants.space5), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + translateErrorKey( + state.errorMessage ?? ''), + style: + UiTypography.body2r.textSecondary, + textAlign: TextAlign.center, + ), + ], + ), + ), + ) + : _buildTabContent( + state, + myShifts, + pendingAssignments, + cancelledShifts, + historyShifts, + historyLoading, + ), + ), + ], + ), + ); + }, + ), ), ); } @@ -253,9 +288,7 @@ class _ShiftsPageState extends State { List myShifts, List pendingAssignments, List cancelledShifts, - List availableJobs, List historyShifts, - bool availableLoading, bool historyLoading, ) { switch (_activeTab) { @@ -269,12 +302,23 @@ class _ShiftsPageState extends State { submittingShiftId: state.submittingShiftId, ); case ShiftTabType.find: - if (availableLoading) { - return const ShiftsPageSkeleton(); - } - return FindShiftsTab( - availableJobs: availableJobs, - profileComplete: state.profileComplete ?? true, + return BlocBuilder( + builder: + (BuildContext context, AvailableOrdersState ordersState) { + if (ordersState.status == AvailableOrdersStatus.loading) { + return const ShiftsPageSkeleton(); + } + return FindShiftsTab( + availableOrders: ordersState.orders, + profileComplete: state.profileComplete ?? true, + onBook: (String orderId, String roleId) { + _ordersBloc.add( + BookOrderEvent(orderId: orderId, roleId: roleId), + ); + }, + bookingInProgress: ordersState.bookingInProgress, + ); + }, ); case ShiftTabType.history: if (historyLoading) { @@ -296,7 +340,7 @@ class _ShiftsPageState extends State { bool showCount = true, bool enabled = true, }) { - final isActive = _activeTab == type; + final bool isActive = _activeTab == type; return Expanded( child: GestureDetector( onTap: !enabled @@ -307,7 +351,7 @@ class _ShiftsPageState extends State { _bloc.add(LoadHistoryShiftsEvent()); } if (type == ShiftTabType.find) { - _bloc.add(LoadAvailableShiftsEvent()); + _ordersBloc.add(const LoadAvailableOrdersEvent()); } }, child: Container( @@ -324,35 +368,33 @@ class _ShiftsPageState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, - children: [ + children: [ Icon( icon, size: 14, color: !enabled ? UiColors.white.withValues(alpha: 0.5) : isActive - ? UiColors.primary - : UiColors.white, + ? UiColors.primary + : UiColors.white, ), const SizedBox(width: UiConstants.space1), Flexible( child: Text( label, - style: - (isActive - ? UiTypography.body3m.copyWith( - color: UiColors.primary, - ) - : UiTypography.body3m.white) - .copyWith( - color: !enabled - ? UiColors.white.withValues(alpha: 0.5) - : null, - ), + style: (isActive + ? UiTypography.body3m + .copyWith(color: UiColors.primary) + : UiTypography.body3m.white) + .copyWith( + color: !enabled + ? UiColors.white.withValues(alpha: 0.5) + : null, + ), overflow: TextOverflow.ellipsis, ), ), - if (showCount) ...[ + if (showCount) ...[ const SizedBox(width: UiConstants.space1), Container( padding: const EdgeInsets.symmetric( @@ -368,7 +410,7 @@ class _ShiftsPageState extends State { ), child: Center( child: Text( - "$count", + '$count', style: UiTypography.footnote1b.copyWith( color: isActive ? UiColors.primary : UiColors.white, ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart new file mode 100644 index 00000000..63b6f498 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart @@ -0,0 +1,415 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Card displaying an [AvailableOrder] from the staff marketplace. +/// +/// Shows role, location, schedule, pay rate, and a booking/apply action. +class AvailableOrderCard extends StatelessWidget { + /// Creates an [AvailableOrderCard]. + const AvailableOrderCard({ + super.key, + required this.order, + required this.onBook, + this.bookingInProgress = false, + }); + + /// The available order to display. + final AvailableOrder order; + + /// Callback when the user taps book/apply, providing orderId and roleId. + final void Function(String orderId, String roleId) onBook; + + /// Whether a booking request is currently in progress. + final bool bookingInProgress; + + /// Formats a date-only string (e.g. "2026-03-24") to "Mar 24". + String _formatDateShort(String dateStr) { + if (dateStr.isEmpty) return ''; + try { + final DateTime date = DateTime.parse(dateStr); + return DateFormat('MMM d').format(date); + } catch (_) { + return dateStr; + } + } + + /// Returns a human-readable label for the order type. + String _orderTypeLabel(OrderType type) { + switch (type) { + case OrderType.oneTime: + return t.staff_shifts.filter.one_day; + case OrderType.recurring: + return t.staff_shifts.filter.multi_day; + case OrderType.permanent: + return t.staff_shifts.filter.long_term; + case OrderType.rapid: + return 'Rapid'; + case OrderType.unknown: + return ''; + } + } + + /// Returns a capitalised short label for a dispatch team value. + String _dispatchTeamLabel(String team) { + switch (team.toUpperCase()) { + case 'CORE': + return 'Core'; + case 'CERTIFIED_LOCATION': + return 'Certified'; + case 'MARKETPLACE': + return 'Marketplace'; + default: + return team; + } + } + + @override + Widget build(BuildContext context) { + final AvailableOrderSchedule schedule = order.schedule; + final int spotsLeft = order.requiredWorkerCount - order.filledCount; + final String hourlyDisplay = + '\$${order.hourlyRate.toStringAsFixed(order.hourlyRate.truncateToDouble() == order.hourlyRate ? 0 : 2)}'; + final String dateRange = + '${_formatDateShort(schedule.startDate)} - ${_formatDateShort(schedule.endDate)}'; + final String timeRange = '${schedule.startTime} - ${schedule.endTime}'; + + return Container( + margin: const EdgeInsets.only(bottom: UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + ), + child: Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // -- Badge row: order type, instant book, dispatch team -- + _buildBadgeRow(), + const SizedBox(height: UiConstants.space3), + + // -- Main content row: icon + details + pay -- + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Role icon + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + UiColors.primary.withValues(alpha: 0.09), + UiColors.primary.withValues(alpha: 0.03), + ], + ), + borderRadius: + BorderRadius.circular(UiConstants.radiusBase), + ), + child: const Center( + child: Icon( + UiIcons.briefcase, + color: UiColors.primary, + size: UiConstants.iconMd, + ), + ), + ), + const SizedBox(width: UiConstants.space3), + + // Details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Role name + hourly rate + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + order.roleName, + style: UiTypography.body2m.textPrimary, + overflow: TextOverflow.ellipsis, + ), + if (order.clientName.isNotEmpty) + Text( + order.clientName, + style: UiTypography.body3r.textSecondary, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: UiConstants.space2), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '$hourlyDisplay${t.available_orders.per_hour}', + style: UiTypography.title1m.textPrimary, + ), + Text( + '${order.filledCount}/${order.requiredWorkerCount} ${t.available_orders.spots_left(count: spotsLeft)}', + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), + ], + ), + const SizedBox(height: UiConstants.space2), + + // Location + if (order.location.isNotEmpty) + Padding( + padding: + const EdgeInsets.only(bottom: UiConstants.space1), + child: Row( + children: [ + const Icon( + UiIcons.mapPin, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Expanded( + child: Text( + order.location, + style: UiTypography.footnote1r.textSecondary, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + + // Address + if (order.locationAddress.isNotEmpty) + Padding( + padding: + const EdgeInsets.only(bottom: UiConstants.space2), + child: Padding( + padding: const EdgeInsets.only( + left: UiConstants.iconXs + UiConstants.space1, + ), + child: Text( + order.locationAddress, + style: UiTypography.footnote2r.textSecondary, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ), + + // Schedule: days of week chips + if (schedule.daysOfWeek.isNotEmpty) + Padding( + padding: + const EdgeInsets.only(bottom: UiConstants.space2), + child: Wrap( + spacing: UiConstants.space1, + runSpacing: UiConstants.space1, + children: schedule.daysOfWeek + .map( + (DayOfWeek day) => _buildDayChip(day), + ) + .toList(), + ), + ), + + // Date range + time + shifts count + Row( + children: [ + const Icon( + UiIcons.calendar, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Text( + dateRange, + style: UiTypography.footnote1r.textSecondary, + ), + const SizedBox(width: UiConstants.space3), + const Icon( + UiIcons.clock, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Text( + timeRange, + style: UiTypography.footnote1r.textSecondary, + ), + ], + ), + const SizedBox(height: UiConstants.space1), + + // Total shifts + timezone + Row( + children: [ + Text( + t.available_orders.shifts_count( + count: schedule.totalShifts, + ), + style: UiTypography.footnote2r.textSecondary, + ), + if (schedule.timezone.isNotEmpty) ...[ + const SizedBox(width: UiConstants.space2), + Text( + schedule.timezone, + style: UiTypography.footnote2r.textSecondary, + ), + ], + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: UiConstants.space3), + + // -- Action button -- + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: bookingInProgress + ? null + : () => onBook(order.orderId, order.roleId), + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.primary, + foregroundColor: UiColors.white, + disabledBackgroundColor: + UiColors.primary.withValues(alpha: 0.5), + disabledForegroundColor: UiColors.white, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(UiConstants.radiusMdValue), + ), + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space3, + ), + ), + child: bookingInProgress + ? const SizedBox( + width: UiConstants.iconMd, + height: UiConstants.iconMd, + child: CircularProgressIndicator( + strokeWidth: 2, + color: UiColors.white, + ), + ) + : Text( + order.instantBook + ? t.available_orders.book_order + : t.available_orders.apply, + style: UiTypography.body2m.white, + ), + ), + ), + ], + ), + ), + ); + } + + /// Builds the horizontal row of badge chips at the top of the card. + Widget _buildBadgeRow() { + return Wrap( + spacing: UiConstants.space2, + runSpacing: UiConstants.space1, + children: [ + // Order type badge + _buildBadge( + label: _orderTypeLabel(order.orderType), + backgroundColor: UiColors.background, + textColor: UiColors.textSecondary, + borderColor: UiColors.border, + ), + + // Instant book badge + if (order.instantBook) + _buildBadge( + label: t.available_orders.instant_book, + backgroundColor: UiColors.success.withValues(alpha: 0.1), + textColor: UiColors.success, + borderColor: UiColors.success.withValues(alpha: 0.3), + icon: UiIcons.zap, + ), + + // Dispatch team badge + if (order.dispatchTeam.isNotEmpty) + _buildBadge( + label: _dispatchTeamLabel(order.dispatchTeam), + backgroundColor: UiColors.primary.withValues(alpha: 0.08), + textColor: UiColors.primary, + borderColor: UiColors.primary.withValues(alpha: 0.2), + ), + ], + ); + } + + /// Builds a single badge chip with optional leading icon. + Widget _buildBadge({ + required String label, + required Color backgroundColor, + required Color textColor, + required Color borderColor, + IconData? icon, + }) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: UiConstants.radiusSm, + border: Border.all(color: borderColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 10, color: textColor), + const SizedBox(width: 2), + ], + Text( + label, + style: UiTypography.footnote2m.copyWith(color: textColor), + ), + ], + ), + ); + } + + /// Builds a small chip showing a day-of-week abbreviation. + Widget _buildDayChip(DayOfWeek day) { + // Display as 3-letter capitalised abbreviation (e.g. "MON" -> "Mon"). + final String label = day.value.isNotEmpty + ? '${day.value[0]}${day.value.substring(1).toLowerCase()}' + : ''; + return Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: UiColors.primary.withValues(alpha: 0.08), + borderRadius: UiConstants.radiusSm, + ), + child: Text( + label, + style: UiTypography.footnote2m.copyWith(color: UiColors.primary), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart index 134fe35b..014b561d 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart @@ -2,26 +2,36 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_shifts/src/presentation/widgets/available_order_card.dart'; import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart'; -/// Tab showing open shifts available for the worker to browse and apply. +/// Tab showing available orders for the worker to browse and book. +/// +/// Replaces the former open-shift listing with order-level marketplace cards. class FindShiftsTab extends StatefulWidget { /// Creates a [FindShiftsTab]. const FindShiftsTab({ super.key, - required this.availableJobs, + required this.availableOrders, this.profileComplete = true, + required this.onBook, + this.bookingInProgress = false, }); - /// Open shifts loaded from the V2 API. - final List availableJobs; + /// Available orders loaded from the V2 API. + final List availableOrders; /// Whether the worker's profile is complete. final bool profileComplete; + /// Callback when the worker taps book/apply on an order card. + final void Function(String orderId, String roleId) onBook; + + /// Whether a booking request is currently in flight. + final bool bookingInProgress; + @override State createState() => _FindShiftsTabState(); } @@ -30,18 +40,7 @@ class _FindShiftsTabState extends State { String _searchQuery = ''; String _jobType = 'all'; - String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt); - - String _formatDate(DateTime date) { - final DateTime now = DateTime.now(); - final DateTime today = DateTime(now.year, now.month, now.day); - final DateTime tomorrow = today.add(const Duration(days: 1)); - final DateTime d = DateTime(date.year, date.month, date.day); - if (d == today) return 'Today'; - if (d == tomorrow) return 'Tomorrow'; - return DateFormat('EEE, MMM d').format(date); - } - + /// Builds a filter tab chip. Widget _buildFilterTab(String id, String label) { final bool isSelected = _jobType == id; return GestureDetector( @@ -69,178 +68,27 @@ class _FindShiftsTabState extends State { ); } - List _filterByType(List shifts) { - if (_jobType == 'all') return shifts; - return shifts.where((OpenShift s) { - if (_jobType == 'one-day') return s.orderType == OrderType.oneTime; - if (_jobType == 'multi-day') return s.orderType == OrderType.recurring; - if (_jobType == 'long-term') return s.orderType == OrderType.permanent; + /// Filters orders by the selected order type tab. + List _filterByType(List orders) { + if (_jobType == 'all') return orders; + return orders.where((AvailableOrder o) { + if (_jobType == 'one-day') return o.orderType == OrderType.oneTime; + if (_jobType == 'multi-day') return o.orderType == OrderType.recurring; + if (_jobType == 'long-term') return o.orderType == OrderType.permanent; return true; }).toList(); } - /// Builds an open shift card. - Widget _buildOpenShiftCard(BuildContext context, OpenShift shift) { - final double hourlyRate = shift.hourlyRateCents / 100; - final int minutes = shift.endTime.difference(shift.startTime).inMinutes; - final double duration = minutes / 60; - final double estimatedTotal = hourlyRate * duration; - - String typeLabel; - switch (shift.orderType) { - case OrderType.permanent: - typeLabel = t.staff_shifts.filter.long_term; - case OrderType.recurring: - typeLabel = t.staff_shifts.filter.multi_day; - case OrderType.oneTime: - default: - typeLabel = t.staff_shifts.filter.one_day; - } - - return GestureDetector( - onTap: () => Modular.to.toShiftDetailsById(shift.shiftId), - child: Container( - margin: const EdgeInsets.only(bottom: UiConstants.space3), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border), - ), - child: Padding( - padding: const EdgeInsets.all(UiConstants.space4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Type badge - Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space2), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space2, - vertical: 2, - ), - decoration: BoxDecoration( - color: UiColors.background, - borderRadius: UiConstants.radiusSm, - border: Border.all(color: UiColors.border), - ), - child: Text( - typeLabel, - style: UiTypography.footnote2m - .copyWith(color: UiColors.textSecondary), - ), - ), - ), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - UiColors.primary.withValues(alpha: 0.09), - UiColors.primary.withValues(alpha: 0.03), - ], - ), - borderRadius: - BorderRadius.circular(UiConstants.radiusBase), - ), - child: const Center( - child: Icon(UiIcons.briefcase, - color: UiColors.primary, size: UiConstants.iconMd), - ), - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(shift.roleName, - style: UiTypography.body2m.textPrimary, - overflow: TextOverflow.ellipsis), - Text(shift.location, - style: UiTypography.body3r.textSecondary, - overflow: TextOverflow.ellipsis), - ], - ), - ), - const SizedBox(width: UiConstants.space2), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text('\$${estimatedTotal.toStringAsFixed(0)}', - style: UiTypography.title1m.textPrimary), - Text( - '\$${hourlyRate.toInt()}/hr \u00b7 ${duration.toInt()}h', - style: - UiTypography.footnote2r.textSecondary), - ], - ), - ], - ), - const SizedBox(height: UiConstants.space2), - Row( - children: [ - const Icon(UiIcons.calendar, - size: UiConstants.iconXs, - color: UiColors.iconSecondary), - const SizedBox(width: UiConstants.space1), - Text(_formatDate(shift.date), - style: UiTypography.footnote1r.textSecondary), - const SizedBox(width: UiConstants.space3), - const Icon(UiIcons.clock, - size: UiConstants.iconXs, - color: UiColors.iconSecondary), - const SizedBox(width: UiConstants.space1), - Text( - '${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}', - style: UiTypography.footnote1r.textSecondary), - ], - ), - const SizedBox(height: UiConstants.space1), - Row( - children: [ - const Icon(UiIcons.mapPin, - size: UiConstants.iconXs, - color: UiColors.iconSecondary), - const SizedBox(width: UiConstants.space1), - Expanded( - child: Text(shift.location, - style: UiTypography.footnote1r.textSecondary, - overflow: TextOverflow.ellipsis), - ), - ], - ), - ], - ), - ), - ], - ), - ], - ), - ), - ), - ); - } - @override Widget build(BuildContext context) { - // Client-side filter by order type - final List filteredJobs = - _filterByType(widget.availableJobs).where((OpenShift s) { + // Client-side filter by order type and search query + final List filteredOrders = + _filterByType(widget.availableOrders).where((AvailableOrder o) { if (_searchQuery.isEmpty) return true; final String q = _searchQuery.toLowerCase(); - return s.roleName.toLowerCase().contains(q) || - s.location.toLowerCase().contains(q); + return o.roleName.toLowerCase().contains(q) || + o.clientName.toLowerCase().contains(q) || + o.location.toLowerCase().contains(q); }).toList(); return Column( @@ -322,7 +170,7 @@ class _FindShiftsTabState extends State { ), ), Expanded( - child: filteredJobs.isEmpty + child: filteredOrders.isEmpty ? EmptyStateView( icon: UiIcons.search, title: context.t.staff_shifts.find_shifts.no_jobs_title, @@ -335,9 +183,12 @@ class _FindShiftsTabState extends State { child: Column( children: [ const SizedBox(height: UiConstants.space5), - ...filteredJobs.map( - (OpenShift shift) => - _buildOpenShiftCard(context, shift), + ...filteredOrders.map( + (AvailableOrder order) => AvailableOrderCard( + order: order, + onBook: widget.onBook, + bookingInProgress: widget.bookingInProgress, + ), ), const SizedBox(height: UiConstants.space32), ], 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 98a51de7..8bb5f36d 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 @@ -14,7 +14,10 @@ import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase. import 'package:staff_shifts/src/domain/usecases/get_shift_details_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/accept_shift_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/decline_shift_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/book_order_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_available_orders_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/submit_for_approval_usecase.dart'; +import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_bloc.dart'; import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_bloc.dart'; import 'package:staff_shifts/src/presentation/utils/shift_tab_type.dart'; @@ -49,6 +52,8 @@ class StaffShiftsModule extends Module { i.addLazySingleton( () => SubmitForApprovalUseCase(i.get()), ); + i.addLazySingleton(GetAvailableOrdersUseCase.new); + i.addLazySingleton(BookOrderUseCase.new); // BLoC i.add( @@ -72,6 +77,12 @@ class StaffShiftsModule extends Module { getProfileCompletion: i.get(), ), ); + i.add( + () => AvailableOrdersBloc( + getAvailableOrders: i.get(), + bookOrder: i.get(), + ), + ); } @override From 833cb99f6bef835beb56c92aba98d1520c009323 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 13:43:39 -0400 Subject: [PATCH 04/24] feat: Enhance AvailableOrderCard layout and time formatting --- .../widgets/available_order_card.dart | 80 +++++++++---------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart index 63b6f498..fa64c9af 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart @@ -74,14 +74,15 @@ class AvailableOrderCard extends StatelessWidget { '\$${order.hourlyRate.toStringAsFixed(order.hourlyRate.truncateToDouble() == order.hourlyRate ? 0 : 2)}'; final String dateRange = '${_formatDateShort(schedule.startDate)} - ${_formatDateShort(schedule.endDate)}'; - final String timeRange = '${schedule.startTime} - ${schedule.endTime}'; + final String timeRange = + '${DateFormat('h:mm a').format(schedule.firstShiftStartsAt)} - ${DateFormat('h:mm a').format(schedule.lastShiftEndsAt)}'; return Container( margin: const EdgeInsets.only(bottom: UiConstants.space3), decoration: BoxDecoration( color: UiColors.white, borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border), + border: Border.all(color: UiColors.border, width: 0.5), ), child: Padding( padding: const EdgeInsets.all(UiConstants.space4), @@ -194,7 +195,7 @@ class AvailableOrderCard extends StatelessWidget { if (order.locationAddress.isNotEmpty) Padding( padding: - const EdgeInsets.only(bottom: UiConstants.space2), + const EdgeInsets.only(bottom: UiConstants.space4), child: Padding( padding: const EdgeInsets.only( left: UiConstants.iconXs + UiConstants.space1, @@ -208,6 +209,7 @@ class AvailableOrderCard extends StatelessWidget { ), ), + // Schedule: days of week chips if (schedule.daysOfWeek.isNotEmpty) Padding( @@ -225,50 +227,48 @@ class AvailableOrderCard extends StatelessWidget { ), // Date range + time + shifts count - Row( + Column( children: [ - const Icon( - UiIcons.calendar, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, + const SizedBox(height: UiConstants.space2), + Row( + children: [ + const Icon( + UiIcons.calendar, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Text( + dateRange, + style: UiTypography.footnote1r.textSecondary, + ), + ], ), - const SizedBox(width: UiConstants.space1), - Text( - dateRange, - style: UiTypography.footnote1r.textSecondary, - ), - const SizedBox(width: UiConstants.space3), - const Icon( - UiIcons.clock, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space1), - Text( - timeRange, - style: UiTypography.footnote1r.textSecondary, + const SizedBox(width: UiConstants.space2), + Row( + children: [ + const Icon( + UiIcons.clock, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Text( + timeRange, + style: UiTypography.footnote1r.textSecondary, + ), + ], ), ], ), const SizedBox(height: UiConstants.space1), - // Total shifts + timezone - Row( - children: [ - Text( - t.available_orders.shifts_count( - count: schedule.totalShifts, - ), - style: UiTypography.footnote2r.textSecondary, - ), - if (schedule.timezone.isNotEmpty) ...[ - const SizedBox(width: UiConstants.space2), - Text( - schedule.timezone, - style: UiTypography.footnote2r.textSecondary, - ), - ], - ], + // Total shifts count + Text( + t.available_orders.shifts_count( + count: schedule.totalShifts, + ), + style: UiTypography.footnote2r.textSecondary, ), ], ), From 24ff8816d2baf903fe68d4cdc2e67984cb3cfaf8 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 13:46:22 -0400 Subject: [PATCH 05/24] feat: Update AvailableOrderCard to include client name, address, and estimated total pay --- .../widgets/available_order_card.dart | 293 +++++++++--------- 1 file changed, 140 insertions(+), 153 deletions(-) diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart index fa64c9af..81bc8cf9 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart @@ -6,7 +6,8 @@ import 'package:krow_domain/krow_domain.dart'; /// Card displaying an [AvailableOrder] from the staff marketplace. /// -/// Shows role, location, schedule, pay rate, and a booking/apply action. +/// Shows role, pay (total + hourly), time, date, client, location, +/// schedule chips, and a booking/apply action. class AvailableOrderCard extends StatelessWidget { /// Creates an [AvailableOrderCard]. const AvailableOrderCard({ @@ -25,6 +26,10 @@ class AvailableOrderCard extends StatelessWidget { /// Whether a booking request is currently in progress. final bool bookingInProgress; + String _formatTime(DateTime time) { + return DateFormat('h:mma').format(time).toLowerCase(); + } + /// Formats a date-only string (e.g. "2026-03-24") to "Mar 24". String _formatDateShort(String dateStr) { if (dateStr.isEmpty) return ''; @@ -36,6 +41,16 @@ class AvailableOrderCard extends StatelessWidget { } } + /// Computes the duration in hours from the first shift start to end. + double _durationHours() { + final int minutes = order.schedule.lastShiftEndsAt + .difference(order.schedule.firstShiftStartsAt) + .inMinutes; + double hours = minutes / 60; + if (hours < 0) hours += 24; + return hours.roundToDouble(); + } + /// Returns a human-readable label for the order type. String _orderTypeLabel(OrderType type) { switch (type) { @@ -70,12 +85,12 @@ class AvailableOrderCard extends StatelessWidget { Widget build(BuildContext context) { final AvailableOrderSchedule schedule = order.schedule; final int spotsLeft = order.requiredWorkerCount - order.filledCount; - final String hourlyDisplay = - '\$${order.hourlyRate.toStringAsFixed(order.hourlyRate.truncateToDouble() == order.hourlyRate ? 0 : 2)}'; + final double durationHours = _durationHours(); + final double estimatedTotal = order.hourlyRate * durationHours; final String dateRange = '${_formatDateShort(schedule.startDate)} - ${_formatDateShort(schedule.endDate)}'; final String timeRange = - '${DateFormat('h:mm a').format(schedule.firstShiftStartsAt)} - ${DateFormat('h:mm a').format(schedule.lastShiftEndsAt)}'; + '${_formatTime(schedule.firstShiftStartsAt)} - ${_formatTime(schedule.lastShiftEndsAt)}'; return Container( margin: const EdgeInsets.only(bottom: UiConstants.space3), @@ -89,8 +104,8 @@ class AvailableOrderCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // -- Badge row: order type, instant book, dispatch team -- - _buildBadgeRow(), + // -- Badge row -- + _buildBadgeRow(spotsLeft), const SizedBox(height: UiConstants.space3), // -- Main content row: icon + details + pay -- @@ -99,176 +114,59 @@ class AvailableOrderCard extends StatelessWidget { children: [ // Role icon Container( - width: 44, - height: 44, + width: UiConstants.space10, + height: UiConstants.space10, decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - UiColors.primary.withValues(alpha: 0.09), - UiColors.primary.withValues(alpha: 0.03), - ], - ), - borderRadius: - BorderRadius.circular(UiConstants.radiusBase), + color: UiColors.tagInProgress, + borderRadius: UiConstants.radiusLg, ), child: const Center( child: Icon( UiIcons.briefcase, color: UiColors.primary, - size: UiConstants.iconMd, + size: UiConstants.space5, ), ), ), const SizedBox(width: UiConstants.space3), - // Details + // Details + pay Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Role name + hourly rate + // Role name + estimated total Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, + spacing: UiConstants.space1, children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - order.roleName, - style: UiTypography.body2m.textPrimary, - overflow: TextOverflow.ellipsis, - ), - if (order.clientName.isNotEmpty) - Text( - order.clientName, - style: UiTypography.body3r.textSecondary, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - const SizedBox(width: UiConstants.space2), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '$hourlyDisplay${t.available_orders.per_hour}', - style: UiTypography.title1m.textPrimary, - ), - Text( - '${order.filledCount}/${order.requiredWorkerCount} ${t.available_orders.spots_left(count: spotsLeft)}', - style: UiTypography.footnote2r.textSecondary, - ), - ], - ), - ], - ), - const SizedBox(height: UiConstants.space2), - - // Location - if (order.location.isNotEmpty) - Padding( - padding: - const EdgeInsets.only(bottom: UiConstants.space1), - child: Row( - children: [ - const Icon( - UiIcons.mapPin, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space1), - Expanded( - child: Text( - order.location, - style: UiTypography.footnote1r.textSecondary, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - - // Address - if (order.locationAddress.isNotEmpty) - Padding( - padding: - const EdgeInsets.only(bottom: UiConstants.space4), - child: Padding( - padding: const EdgeInsets.only( - left: UiConstants.iconXs + UiConstants.space1, - ), + Flexible( child: Text( - order.locationAddress, - style: UiTypography.footnote2r.textSecondary, + order.roleName, + style: UiTypography.body1m.textPrimary, overflow: TextOverflow.ellipsis, - maxLines: 1, ), ), - ), - - - // Schedule: days of week chips - if (schedule.daysOfWeek.isNotEmpty) - Padding( - padding: - const EdgeInsets.only(bottom: UiConstants.space2), - child: Wrap( - spacing: UiConstants.space1, - runSpacing: UiConstants.space1, - children: schedule.daysOfWeek - .map( - (DayOfWeek day) => _buildDayChip(day), - ) - .toList(), - ), - ), - - // Date range + time + shifts count - Column( - children: [ - const SizedBox(height: UiConstants.space2), - Row( - children: [ - const Icon( - UiIcons.calendar, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space1), - Text( - dateRange, - style: UiTypography.footnote1r.textSecondary, - ), - ], - ), - const SizedBox(width: UiConstants.space2), - Row( - children: [ - const Icon( - UiIcons.clock, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space1), - Text( - timeRange, - style: UiTypography.footnote1r.textSecondary, - ), - ], + Text( + '\$${estimatedTotal.toStringAsFixed(0)}', + style: UiTypography.title1m.textPrimary, ), ], ), - const SizedBox(height: UiConstants.space1), - - // Total shifts count - Text( - t.available_orders.shifts_count( - count: schedule.totalShifts, - ), - style: UiTypography.footnote2r.textSecondary, + // Time subtitle + hourly rate + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: UiConstants.space1, + children: [ + Text( + timeRange, + style: UiTypography.body3r.textSecondary, + ), + Text( + '\$${order.hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h', + style: UiTypography.footnote2r.textSecondary, + ), + ], ), ], ), @@ -277,6 +175,87 @@ class AvailableOrderCard extends StatelessWidget { ), const SizedBox(height: UiConstants.space3), + // -- Date -- + Row( + children: [ + const Icon( + UiIcons.calendar, + size: UiConstants.space3, + color: UiColors.mutedForeground, + ), + const SizedBox(width: UiConstants.space1), + Text( + dateRange, + style: UiTypography.body3r.textSecondary, + ), + ], + ), + const SizedBox(height: UiConstants.space1), + + // -- Client name -- + if (order.clientName.isNotEmpty) + Row( + children: [ + const Icon( + UiIcons.building, + size: UiConstants.space3, + color: UiColors.mutedForeground, + ), + const SizedBox(width: UiConstants.space1), + Expanded( + child: Text( + order.clientName, + style: UiTypography.body3r.textSecondary, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + + // -- Address -- + if (order.locationAddress.isNotEmpty) ...[ + const SizedBox(height: UiConstants.space1), + Row( + children: [ + const Icon( + UiIcons.mapPin, + size: UiConstants.space3, + color: UiColors.mutedForeground, + ), + const SizedBox(width: UiConstants.space1), + Expanded( + child: Text( + order.locationAddress, + style: UiTypography.body3r.textSecondary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + + // -- Schedule: days of week chips -- + if (schedule.daysOfWeek.isNotEmpty) ...[ + const SizedBox(height: UiConstants.space3), + Wrap( + spacing: UiConstants.space1, + runSpacing: UiConstants.space1, + children: schedule.daysOfWeek + .map((DayOfWeek day) => _buildDayChip(day)) + .toList(), + ), + const SizedBox(height: UiConstants.space1), + Text( + t.available_orders.shifts_count( + count: schedule.totalShifts, + ), + style: UiTypography.footnote2r.textSecondary, + ), + ], + + const SizedBox(height: UiConstants.space3), + // -- Action button -- SizedBox( width: double.infinity, @@ -322,7 +301,7 @@ class AvailableOrderCard extends StatelessWidget { } /// Builds the horizontal row of badge chips at the top of the card. - Widget _buildBadgeRow() { + Widget _buildBadgeRow(int spotsLeft) { return Wrap( spacing: UiConstants.space2, runSpacing: UiConstants.space1, @@ -335,6 +314,15 @@ class AvailableOrderCard extends StatelessWidget { borderColor: UiColors.border, ), + // Spots left badge + if (spotsLeft > 0) + _buildBadge( + label: t.available_orders.spots_left(count: spotsLeft), + backgroundColor: UiColors.tagPending, + textColor: UiColors.textWarning, + borderColor: UiColors.textWarning.withValues(alpha: 0.3), + ), + // Instant book badge if (order.instantBook) _buildBadge( @@ -393,7 +381,6 @@ class AvailableOrderCard extends StatelessWidget { /// Builds a small chip showing a day-of-week abbreviation. Widget _buildDayChip(DayOfWeek day) { - // Display as 3-letter capitalised abbreviation (e.g. "MON" -> "Mon"). final String label = day.value.isNotEmpty ? '${day.value[0]}${day.value.substring(1).toLowerCase()}' : ''; From 742c8c75c5bce091a2f2b9fbdec1ff367f4011f1 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 13:49:22 -0400 Subject: [PATCH 06/24] feat: Update AvailableOrderCard to display pay details for long-term orders --- .../widgets/available_order_card.dart | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart index 81bc8cf9..8bc220e3 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart @@ -85,6 +85,7 @@ class AvailableOrderCard extends StatelessWidget { Widget build(BuildContext context) { final AvailableOrderSchedule schedule = order.schedule; final int spotsLeft = order.requiredWorkerCount - order.filledCount; + final bool isLongTerm = order.orderType == OrderType.permanent; final double durationHours = _durationHours(); final double estimatedTotal = order.hourlyRate * durationHours; final String dateRange = @@ -135,7 +136,7 @@ class AvailableOrderCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Role name + estimated total + // Role name + pay headline Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, spacing: UiConstants.space1, @@ -148,12 +149,14 @@ class AvailableOrderCard extends StatelessWidget { ), ), Text( - '\$${estimatedTotal.toStringAsFixed(0)}', + isLongTerm + ? '\$${order.hourlyRate.toInt()}/hr' + : '\$${estimatedTotal.toStringAsFixed(0)}', style: UiTypography.title1m.textPrimary, ), ], ), - // Time subtitle + hourly rate + // Time subtitle + pay detail Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, spacing: UiConstants.space1, @@ -162,10 +165,11 @@ class AvailableOrderCard extends StatelessWidget { timeRange, style: UiTypography.body3r.textSecondary, ), - Text( - '\$${order.hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h', - style: UiTypography.footnote2r.textSecondary, - ), + if (!isLongTerm) + Text( + '\$${order.hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h', + style: UiTypography.footnote2r.textSecondary, + ), ], ), ], From 9c71acb96a2a0a7dab2f5600becb1b0fbbb19f29 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 14:08:34 -0400 Subject: [PATCH 07/24] feat: Implement order details page and navigation for available orders --- .../core/lib/src/routing/staff/navigator.dart | 7 + .../lib/src/routing/staff/route_paths.dart | 5 + .../lib/src/l10n/en.i18n.json | 14 +- .../lib/src/l10n/es.i18n.json | 14 +- .../shifts/lib/src/order_details_module.dart | 50 ++++ .../pages/order_details_page.dart | 253 ++++++++++++++++++ .../src/presentation/pages/shifts_page.dart | 35 +-- .../widgets/available_order_card.dart | 74 ++--- .../order_details_bottom_bar.dart | 130 +++++++++ .../order_details/order_details_header.dart | 185 +++++++++++++ .../order_details/order_schedule_section.dart | 131 +++++++++ .../widgets/tabs/find_shifts_tab.dart | 12 +- .../staff/shifts/lib/staff_shifts.dart | 1 + .../staff_main/lib/src/staff_main_module.dart | 4 + 14 files changed, 813 insertions(+), 102 deletions(-) create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/order_details_module.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/order_details_page.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_schedule_section.dart diff --git a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart index 9a536a65..fbab15a2 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart @@ -112,6 +112,13 @@ extension StaffNavigator on IModularNavigator { safeNavigate(StaffPaths.shiftDetails(shift.id), arguments: shift); } + /// Navigates to the order details page for a given [AvailableOrder]. + /// + /// The order is passed as a data argument to the route. + void toOrderDetails(AvailableOrder order) { + safePush(StaffPaths.orderDetailsRoute, arguments: order); + } + /// Navigates to shift details by ID only (no pre-fetched [Shift] object). /// /// Used when only the shift ID is available (e.g. from dashboard list items). diff --git a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart index c3ebff23..a6146f8b 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart @@ -107,6 +107,11 @@ class StaffPaths { /// View detailed information for a specific shift. static const String shiftDetailsRoute = '/worker-main/shift-details'; + /// Order details route. + /// + /// View detailed information for an available order and book/apply. + static const String orderDetailsRoute = '/worker-main/order-details'; + /// Shift details page (dynamic). /// /// View detailed information for a specific shift. diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index cd8d5e29..423ea826 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -1881,14 +1881,26 @@ "available_orders": { "book_order": "Book Order", "apply": "Apply", + "fully_staffed": "Fully Staffed", "spots_left": "${count} spot(s) left", "shifts_count": "${count} shift(s)", + "schedule_label": "SCHEDULE", "booking_success": "Order booked successfully!", "booking_pending": "Your booking is pending approval", "booking_confirmed": "Your booking has been confirmed!", "no_orders": "No orders available", "no_orders_subtitle": "Check back later for new opportunities", "instant_book": "Instant Book", - "per_hour": "/hr" + "per_hour": "/hr", + "book_dialog": { + "title": "Book this order?", + "message": "This will book you for all ${count} shift(s) in this order.", + "confirm": "Confirm Booking" + }, + "booking_dialog": { + "title": "Booking order..." + }, + "order_booked_pending": "Order booking submitted! Awaiting approval.", + "order_booked_confirmed": "Order booked and confirmed!" } } \ No newline at end of file diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index b7f47371..927d701c 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -1881,14 +1881,26 @@ "available_orders": { "book_order": "Reservar Orden", "apply": "Aplicar", + "fully_staffed": "Completamente dotado", "spots_left": "${count} puesto(s) disponible(s)", "shifts_count": "${count} turno(s)", + "schedule_label": "HORARIO", "booking_success": "\u00a1Orden reservada con \u00e9xito!", "booking_pending": "Tu reserva est\u00e1 pendiente de aprobaci\u00f3n", "booking_confirmed": "\u00a1Tu reserva ha sido confirmada!", "no_orders": "No hay \u00f3rdenes disponibles", "no_orders_subtitle": "Vuelve m\u00e1s tarde para nuevas oportunidades", "instant_book": "Reserva Instant\u00e1nea", - "per_hour": "/hr" + "per_hour": "/hr", + "book_dialog": { + "title": "\u00bfReservar esta orden?", + "message": "Esto te reservar\u00e1 para los ${count} turno(s) de esta orden.", + "confirm": "Confirmar Reserva" + }, + "booking_dialog": { + "title": "Reservando orden..." + }, + "order_booked_pending": "\u00a1Reserva de orden enviada! Esperando aprobaci\u00f3n.", + "order_booked_confirmed": "\u00a1Orden reservada y confirmada!" } } \ No newline at end of file diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/order_details_module.dart b/apps/mobile/packages/features/staff/shifts/lib/src/order_details_module.dart new file mode 100644 index 00000000..16935eb3 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/order_details_module.dart @@ -0,0 +1,50 @@ +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_shifts/src/data/repositories_impl/shifts_repository_impl.dart'; +import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; +import 'package:staff_shifts/src/domain/usecases/book_order_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_available_orders_usecase.dart'; +import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_bloc.dart'; +import 'package:staff_shifts/src/presentation/pages/order_details_page.dart'; + +/// DI module for the order details page. +/// +/// Registers the repository, use cases, and BLoC needed to display +/// and book an [AvailableOrder] via the V2 API. +class OrderDetailsModule extends Module { + @override + List get imports => [CoreModule()]; + + @override + void binds(Injector i) { + // Repository + i.add( + () => ShiftsRepositoryImpl(apiService: i.get()), + ); + + // Use cases + i.addLazySingleton(GetAvailableOrdersUseCase.new); + i.addLazySingleton(BookOrderUseCase.new); + + // BLoC + i.add( + () => AvailableOrdersBloc( + getAvailableOrders: i.get(), + bookOrder: i.get(), + ), + ); + } + + @override + void routes(RouteManager r) { + r.child( + '/', + child: (_) { + final AvailableOrder order = r.args.data as AvailableOrder; + return OrderDetailsPage(order: order); + }, + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/order_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/order_details_page.dart new file mode 100644 index 00000000..ffc0debd --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/order_details_page.dart @@ -0,0 +1,253 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:intl/intl.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_bloc.dart'; +import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_event.dart'; +import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_state.dart'; +import 'package:staff_shifts/src/presentation/widgets/order_details/order_details_bottom_bar.dart'; +import 'package:staff_shifts/src/presentation/widgets/order_details/order_details_header.dart'; +import 'package:staff_shifts/src/presentation/widgets/order_details/order_schedule_section.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_location_section.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_stats_row.dart'; + +/// Page displaying full details for an available order. +/// +/// Allows the staff member to review order details and book/apply. +/// Uses [AvailableOrdersBloc] for the booking flow. +class OrderDetailsPage extends StatefulWidget { + /// Creates an [OrderDetailsPage]. + const OrderDetailsPage({super.key, required this.order}); + + /// The available order to display. + final AvailableOrder order; + + @override + State createState() => _OrderDetailsPageState(); +} + +class _OrderDetailsPageState extends State { + /// Whether the action (booking) dialog is currently showing. + bool _actionDialogOpen = false; + + /// Whether a booking request has been initiated. + bool _isBooking = false; + + /// Formats a date-only string (e.g. "2026-03-24") to "Mar 24". + String _formatDateShort(String dateStr) { + if (dateStr.isEmpty) return ''; + try { + final DateTime date = DateTime.parse(dateStr); + return DateFormat('MMM d').format(date); + } catch (_) { + return dateStr; + } + } + + /// Computes the duration in hours from the first shift start to end. + double _durationHours() { + final int minutes = widget.order.schedule.lastShiftEndsAt + .difference(widget.order.schedule.firstShiftStartsAt) + .inMinutes; + double hours = minutes / 60; + if (hours < 0) hours += 24; + return hours.roundToDouble(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => Modular.get(), + child: BlocConsumer( + listener: _onStateChanged, + builder: (BuildContext context, AvailableOrdersState state) { + return _buildScaffold(context, state); + }, + ), + ); + } + + void _onStateChanged(BuildContext context, AvailableOrdersState state) { + // Booking succeeded + if (state.lastBooking != null) { + _closeActionDialog(context); + final bool isPending = state.lastBooking!.status == 'PENDING'; + UiSnackbar.show( + context, + message: isPending + ? t.available_orders.order_booked_pending + : t.available_orders.order_booked_confirmed, + type: UiSnackbarType.success, + ); + Modular.to.toShifts(initialTab: 'find', refreshAvailable: true); + } + + // Booking failed + if (state.errorMessage != null && _isBooking) { + _closeActionDialog(context); + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage!), + type: UiSnackbarType.error, + ); + setState(() { + _isBooking = false; + }); + } + } + + Widget _buildScaffold(BuildContext context, AvailableOrdersState state) { + final AvailableOrder order = widget.order; + final bool isLongTerm = order.orderType == OrderType.permanent; + final double durationHours = _durationHours(); + final double estimatedTotal = order.hourlyRate * durationHours; + final int spotsLeft = order.requiredWorkerCount - order.filledCount; + + return Scaffold( + appBar: UiAppBar( + centerTitle: false, + onLeadingPressed: () => Modular.to.toShifts(), + ), + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + OrderDetailsHeader(order: order), + const Divider(height: 1, thickness: 0.5), + ShiftStatsRow( + estimatedTotal: + isLongTerm ? order.hourlyRate : estimatedTotal, + hourlyRate: order.hourlyRate, + duration: isLongTerm ? 0 : durationHours, + totalLabel: isLongTerm + ? context.t.staff_shifts.shift_details.hourly_rate + : context.t.staff_shifts.shift_details.est_total, + hourlyRateLabel: + context.t.staff_shifts.shift_details.hourly_rate, + hoursLabel: context.t.staff_shifts.shift_details.hours, + ), + const Divider(height: 1, thickness: 0.5), + OrderScheduleSection( + schedule: order.schedule, + scheduleLabel: + context.t.available_orders.schedule_label, + shiftsCountLabel: t.available_orders.shifts_count( + count: order.schedule.totalShifts, + ), + ), + const Divider(height: 1, thickness: 0.5), + ShiftLocationSection( + location: order.location, + address: order.locationAddress, + locationLabel: + context.t.staff_shifts.shift_details.location, + tbdLabel: context.t.staff_shifts.shift_details.tbd, + getDirectionLabel: + context.t.staff_shifts.shift_details.get_direction, + ), + ], + ), + ), + ), + OrderDetailsBottomBar( + instantBook: order.instantBook, + spotsLeft: spotsLeft, + bookingInProgress: state.bookingInProgress, + onBook: () => _bookOrder(context), + ), + ], + ), + ); + } + + /// Shows the confirmation dialog before booking. + void _bookOrder(BuildContext context) { + showDialog( + context: context, + builder: (BuildContext ctx) => AlertDialog( + title: Text(t.available_orders.book_dialog.title), + content: Text( + t.available_orders.book_dialog.message( + count: widget.order.schedule.totalShifts, + ), + ), + actions: [ + TextButton( + onPressed: () => Modular.to.popSafe(), + child: Text(Translations.of(context).common.cancel), + ), + TextButton( + onPressed: () { + Modular.to.popSafe(); + _showBookingDialog(context); + BlocProvider.of(context).add( + BookOrderEvent( + orderId: widget.order.orderId, + roleId: widget.order.roleId, + ), + ); + }, + style: TextButton.styleFrom(foregroundColor: UiColors.success), + child: Text(t.available_orders.book_dialog.confirm), + ), + ], + ), + ); + } + + /// Shows a non-dismissible dialog while the booking is in progress. + void _showBookingDialog(BuildContext context) { + if (_actionDialogOpen) return; + _actionDialogOpen = true; + _isBooking = true; + showDialog( + context: context, + useRootNavigator: true, + barrierDismissible: false, + builder: (BuildContext ctx) => AlertDialog( + title: Text(t.available_orders.booking_dialog.title), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + height: 36, + width: 36, + child: CircularProgressIndicator(), + ), + const SizedBox(height: UiConstants.space4), + Text( + widget.order.roleName, + style: UiTypography.body2b.textPrimary, + textAlign: TextAlign.center, + ), + const SizedBox(height: UiConstants.space1), + Text( + '${_formatDateShort(widget.order.schedule.startDate)} - ' + '${_formatDateShort(widget.order.schedule.endDate)} ' + '\u2022 ${widget.order.schedule.totalShifts} shifts', + style: UiTypography.body3r.textSecondary, + textAlign: TextAlign.center, + ), + ], + ), + ), + ).then((_) { + _actionDialogOpen = false; + }); + } + + /// Closes the action dialog if it is open. + void _closeActionDialog(BuildContext context) { + if (!_actionDialogOpen) return; + Navigator.of(context, rootNavigator: true).pop(); + _actionDialogOpen = false; + } +} 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 7f08bbaa..7ae32917 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 @@ -104,31 +104,7 @@ class _ShiftsPageState extends State { BlocProvider.value(value: _bloc), BlocProvider.value(value: _ordersBloc), ], - child: BlocListener( - listener: (BuildContext context, AvailableOrdersState ordersState) { - // Show booking success / error snackbar. - if (ordersState.lastBooking != null) { - final OrderBooking booking = ordersState.lastBooking!; - final String message = - booking.status.toUpperCase() == 'CONFIRMED' - ? t.available_orders.booking_confirmed - : t.available_orders.booking_pending; - UiSnackbar.show( - context, - message: message, - type: UiSnackbarType.success, - ); - _ordersBloc.add(const ClearBookingResultEvent()); - } - if (ordersState.errorMessage != null) { - UiSnackbar.show( - context, - message: translateErrorKey(ordersState.errorMessage!), - type: UiSnackbarType.error, - ); - } - }, - child: BlocConsumer( + child: BlocConsumer( listener: (BuildContext context, ShiftsState state) { if (state.status == ShiftsStatus.error && state.errorMessage != null) { @@ -279,8 +255,7 @@ class _ShiftsPageState extends State { ); }, ), - ), - ); + ); } Widget _buildTabContent( @@ -311,12 +286,6 @@ class _ShiftsPageState extends State { return FindShiftsTab( availableOrders: ordersState.orders, profileComplete: state.profileComplete ?? true, - onBook: (String orderId, String roleId) { - _ordersBloc.add( - BookOrderEvent(orderId: orderId, roleId: roleId), - ); - }, - bookingInProgress: ordersState.bookingInProgress, ); }, ); diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart index 8bc220e3..351f99e1 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart @@ -7,25 +7,22 @@ import 'package:krow_domain/krow_domain.dart'; /// Card displaying an [AvailableOrder] from the staff marketplace. /// /// Shows role, pay (total + hourly), time, date, client, location, -/// schedule chips, and a booking/apply action. +/// and schedule chips. Tapping the card navigates to the order details page. class AvailableOrderCard extends StatelessWidget { /// Creates an [AvailableOrderCard]. const AvailableOrderCard({ super.key, required this.order, - required this.onBook, - this.bookingInProgress = false, + required this.onTap, }); /// The available order to display. final AvailableOrder order; - /// Callback when the user taps book/apply, providing orderId and roleId. - final void Function(String orderId, String roleId) onBook; - - /// Whether a booking request is currently in progress. - final bool bookingInProgress; + /// Callback when the user taps the card. + final VoidCallback onTap; + /// Formats a DateTime to a time string like "3:30pm". String _formatTime(DateTime time) { return DateFormat('h:mma').format(time).toLowerCase(); } @@ -93,14 +90,16 @@ class AvailableOrderCard extends StatelessWidget { final String timeRange = '${_formatTime(schedule.firstShiftStartsAt)} - ${_formatTime(schedule.lastShiftEndsAt)}'; - return Container( - margin: const EdgeInsets.only(bottom: UiConstants.space3), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border, width: 0.5), - ), - child: Padding( + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border, width: 0.5), + ), + child: Padding( padding: const EdgeInsets.all(UiConstants.space4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -136,7 +135,7 @@ class AvailableOrderCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Role name + pay headline + // Role name + pay headline + chevron Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, spacing: UiConstants.space1, @@ -258,49 +257,10 @@ class AvailableOrderCard extends StatelessWidget { ), ], - const SizedBox(height: UiConstants.space3), - - // -- Action button -- - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: bookingInProgress - ? null - : () => onBook(order.orderId, order.roleId), - style: ElevatedButton.styleFrom( - backgroundColor: UiColors.primary, - foregroundColor: UiColors.white, - disabledBackgroundColor: - UiColors.primary.withValues(alpha: 0.5), - disabledForegroundColor: UiColors.white, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(UiConstants.radiusMdValue), - ), - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space3, - ), - ), - child: bookingInProgress - ? const SizedBox( - width: UiConstants.iconMd, - height: UiConstants.iconMd, - child: CircularProgressIndicator( - strokeWidth: 2, - color: UiColors.white, - ), - ) - : Text( - order.instantBook - ? t.available_orders.book_order - : t.available_orders.apply, - style: UiTypography.body2m.white, - ), - ), - ), ], ), ), + ), ); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart new file mode 100644 index 00000000..3f98df84 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart @@ -0,0 +1,130 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A bottom action bar for the order details page. +/// +/// Displays a contextual CTA button based on order booking state: +/// fully staffed, instant book, or standard apply. +class OrderDetailsBottomBar extends StatelessWidget { + /// Creates an [OrderDetailsBottomBar]. + const OrderDetailsBottomBar({ + super.key, + required this.instantBook, + required this.spotsLeft, + required this.bookingInProgress, + required this.onBook, + }); + + /// Whether the order supports instant booking (no approval needed). + final bool instantBook; + + /// Number of spots still available. + final int spotsLeft; + + /// Whether a booking request is currently in flight. + final bool bookingInProgress; + + /// Callback when the user taps the book/apply button. + final VoidCallback onBook; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space4, + UiConstants.space5, + MediaQuery.of(context).padding.bottom + UiConstants.space4, + ), + decoration: const BoxDecoration( + color: UiColors.white, + border: Border(top: BorderSide(color: UiColors.border)), + ), + child: _buildButton(context), + ); + } + + Widget _buildButton(BuildContext context) { + // Loading state + if (bookingInProgress) { + return SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: null, + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.primary.withValues(alpha: 0.5), + disabledBackgroundColor: UiColors.primary.withValues(alpha: 0.5), + disabledForegroundColor: UiColors.white, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(UiConstants.radiusMdValue), + ), + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space3, + ), + ), + child: const SizedBox( + width: UiConstants.iconMd, + height: UiConstants.iconMd, + child: CircularProgressIndicator( + strokeWidth: 2, + color: UiColors.white, + ), + ), + ), + ); + } + + // Fully staffed + if (spotsLeft <= 0) { + return SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: null, + style: ElevatedButton.styleFrom( + disabledBackgroundColor: UiColors.bgThird, + disabledForegroundColor: UiColors.textSecondary, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(UiConstants.radiusMdValue), + ), + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space3, + ), + ), + child: Text( + t.available_orders.fully_staffed, + style: UiTypography.body2m.textSecondary, + ), + ), + ); + } + + // Instant book or standard apply + final bool isInstant = instantBook; + return SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: onBook, + style: ElevatedButton.styleFrom( + backgroundColor: isInstant ? UiColors.success : UiColors.primary, + foregroundColor: UiColors.white, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(UiConstants.radiusMdValue), + ), + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space3, + ), + ), + child: Text( + isInstant + ? t.available_orders.book_order + : t.available_orders.apply, + style: UiTypography.body2m.white, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart new file mode 100644 index 00000000..0b8e8022 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart @@ -0,0 +1,185 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Size of the role icon container in the order details header. +const double _kIconContainerSize = 68.0; + +/// A header widget for the order details page. +/// +/// Displays the role icon, role name, client name, and a row of status badges +/// (order type, spots left, instant book, dispatch team). +class OrderDetailsHeader extends StatelessWidget { + /// Creates an [OrderDetailsHeader]. + const OrderDetailsHeader({super.key, required this.order}); + + /// The available order entity. + final AvailableOrder order; + + /// Returns a human-readable label for the order type. + String _orderTypeLabel(OrderType type) { + switch (type) { + case OrderType.oneTime: + return t.staff_shifts.filter.one_day; + case OrderType.recurring: + return t.staff_shifts.filter.multi_day; + case OrderType.permanent: + return t.staff_shifts.filter.long_term; + case OrderType.rapid: + return 'Rapid'; + case OrderType.unknown: + return ''; + } + } + + /// Returns a capitalised short label for a dispatch team value. + String _dispatchTeamLabel(String team) { + switch (team.toUpperCase()) { + case 'CORE': + return 'Core'; + case 'CERTIFIED_LOCATION': + return 'Certified'; + case 'MARKETPLACE': + return 'Marketplace'; + default: + return team; + } + } + + @override + Widget build(BuildContext context) { + final int spotsLeft = order.requiredWorkerCount - order.filledCount; + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space6, + vertical: UiConstants.space4, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: UiConstants.space4, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + spacing: UiConstants.space4, + children: [ + Container( + width: _kIconContainerSize, + height: _kIconContainerSize, + decoration: BoxDecoration( + color: UiColors.tagInProgress, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.primary, width: 0.5), + ), + child: const Center( + child: Icon( + UiIcons.briefcase, + color: UiColors.primary, + size: 20, + ), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + order.roleName, + style: UiTypography.body1m.textPrimary, + ), + if (order.clientName.isNotEmpty) + Text( + order.clientName, + style: UiTypography.body3r.textSecondary, + ), + ], + ), + ), + ], + ), + _buildBadgeRow(spotsLeft), + ], + ), + ); + } + + /// Builds the horizontal row of badge chips below the header. + Widget _buildBadgeRow(int spotsLeft) { + return Wrap( + spacing: UiConstants.space2, + runSpacing: UiConstants.space1, + children: [ + // Order type badge + _buildBadge( + label: _orderTypeLabel(order.orderType), + backgroundColor: UiColors.background, + textColor: UiColors.textSecondary, + borderColor: UiColors.border, + ), + + // Spots left badge + if (spotsLeft > 0) + _buildBadge( + label: t.available_orders.spots_left(count: spotsLeft), + backgroundColor: UiColors.tagPending, + textColor: UiColors.textWarning, + borderColor: UiColors.textWarning.withValues(alpha: 0.3), + ), + + // Instant book badge + if (order.instantBook) + _buildBadge( + label: t.available_orders.instant_book, + backgroundColor: UiColors.success.withValues(alpha: 0.1), + textColor: UiColors.success, + borderColor: UiColors.success.withValues(alpha: 0.3), + icon: UiIcons.zap, + ), + + // Dispatch team badge + if (order.dispatchTeam.isNotEmpty) + _buildBadge( + label: _dispatchTeamLabel(order.dispatchTeam), + backgroundColor: UiColors.primary.withValues(alpha: 0.08), + textColor: UiColors.primary, + borderColor: UiColors.primary.withValues(alpha: 0.2), + ), + ], + ); + } + + /// Builds a single badge chip with optional leading icon. + Widget _buildBadge({ + required String label, + required Color backgroundColor, + required Color textColor, + required Color borderColor, + IconData? icon, + }) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: UiConstants.radiusSm, + border: Border.all(color: borderColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 10, color: textColor), + const SizedBox(width: 2), + ], + Text( + label, + style: UiTypography.footnote2m.copyWith(color: textColor), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_schedule_section.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_schedule_section.dart new file mode 100644 index 00000000..a7cbdfda --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_schedule_section.dart @@ -0,0 +1,131 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// A section displaying the schedule for an available order. +/// +/// Shows the days-of-week chips, date range, time range, and total shift count. +class OrderScheduleSection extends StatelessWidget { + /// Creates an [OrderScheduleSection]. + const OrderScheduleSection({ + super.key, + required this.schedule, + required this.scheduleLabel, + required this.shiftsCountLabel, + }); + + /// The order schedule data. + final AvailableOrderSchedule schedule; + + /// Localised section title (e.g. "SCHEDULE"). + final String scheduleLabel; + + /// Localised shifts count text (e.g. "3 shift(s)"). + final String shiftsCountLabel; + + /// Formats a date-only string (e.g. "2026-03-24") to "Mar 24". + String _formatDateShort(String dateStr) { + if (dateStr.isEmpty) return ''; + try { + final DateTime date = DateTime.parse(dateStr); + return DateFormat('MMM d').format(date); + } catch (_) { + return dateStr; + } + } + + /// Formats a DateTime to a time string (e.g. "9:00am"). + String _formatTime(DateTime dt) { + return DateFormat('h:mma').format(dt).toLowerCase(); + } + + @override + Widget build(BuildContext context) { + final String dateRange = + '${_formatDateShort(schedule.startDate)} - ${_formatDateShort(schedule.endDate)}'; + final String timeRange = + '${_formatTime(schedule.firstShiftStartsAt)} - ${_formatTime(schedule.lastShiftEndsAt)}'; + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space6, + vertical: UiConstants.space4, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + scheduleLabel, + style: UiTypography.titleUppercase4b.textSecondary, + ), + const SizedBox(height: UiConstants.space3), + + // Days of week chips + if (schedule.daysOfWeek.isNotEmpty) ...[ + Wrap( + spacing: UiConstants.space1, + runSpacing: UiConstants.space1, + children: schedule.daysOfWeek + .map((DayOfWeek day) => _buildDayChip(day)) + .toList(), + ), + const SizedBox(height: UiConstants.space3), + ], + + // Date range row + Row( + children: [ + const Icon( + UiIcons.calendar, + size: 20, + color: UiColors.primary, + ), + const SizedBox(width: UiConstants.space2), + Text(dateRange, style: UiTypography.headline5m.textPrimary), + ], + ), + const SizedBox(height: UiConstants.space2), + + // Time range row + Row( + children: [ + const Icon( + UiIcons.clock, + size: 20, + color: UiColors.primary, + ), + const SizedBox(width: UiConstants.space2), + Text(timeRange, style: UiTypography.headline5m.textPrimary), + ], + ), + const SizedBox(height: UiConstants.space2), + + // Shifts count + Text(shiftsCountLabel, style: UiTypography.footnote2r.textSecondary), + ], + ), + ); + } + + /// Builds a small chip showing a day-of-week abbreviation. + Widget _buildDayChip(DayOfWeek day) { + final String label = day.value.isNotEmpty + ? '${day.value[0]}${day.value.substring(1).toLowerCase()}' + : ''; + return Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: UiColors.primary.withValues(alpha: 0.08), + borderRadius: UiConstants.radiusSm, + ), + child: Text( + label, + style: UiTypography.footnote2m.copyWith(color: UiColors.primary), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart index 014b561d..ef13605e 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart @@ -10,14 +10,13 @@ import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.da /// Tab showing available orders for the worker to browse and book. /// /// Replaces the former open-shift listing with order-level marketplace cards. +/// Tapping a card navigates to the order details page. class FindShiftsTab extends StatefulWidget { /// Creates a [FindShiftsTab]. const FindShiftsTab({ super.key, required this.availableOrders, this.profileComplete = true, - required this.onBook, - this.bookingInProgress = false, }); /// Available orders loaded from the V2 API. @@ -26,12 +25,6 @@ class FindShiftsTab extends StatefulWidget { /// Whether the worker's profile is complete. final bool profileComplete; - /// Callback when the worker taps book/apply on an order card. - final void Function(String orderId, String roleId) onBook; - - /// Whether a booking request is currently in flight. - final bool bookingInProgress; - @override State createState() => _FindShiftsTabState(); } @@ -186,8 +179,7 @@ class _FindShiftsTabState extends State { ...filteredOrders.map( (AvailableOrder order) => AvailableOrderCard( order: order, - onBook: widget.onBook, - bookingInProgress: widget.bookingInProgress, + onTap: () => Modular.to.toOrderDetails(order), ), ), const SizedBox(height: UiConstants.space32), diff --git a/apps/mobile/packages/features/staff/shifts/lib/staff_shifts.dart b/apps/mobile/packages/features/staff/shifts/lib/staff_shifts.dart index fd3484ea..f9e4a32e 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/staff_shifts.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/staff_shifts.dart @@ -2,4 +2,5 @@ library; export 'src/staff_shifts_module.dart'; export 'src/shift_details_module.dart'; +export 'src/order_details_module.dart'; diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart index 32aa3711..13d1b7ba 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart @@ -133,6 +133,10 @@ class StaffMainModule extends Module { StaffPaths.childRoute(StaffPaths.main, StaffPaths.shiftDetailsRoute), module: ShiftDetailsModule(), ); + r.module( + StaffPaths.childRoute(StaffPaths.main, StaffPaths.orderDetailsRoute), + module: OrderDetailsModule(), + ); r.module( StaffPaths.childRoute(StaffPaths.main, StaffPaths.faqs), module: FaqsModule(), From 8121a718bb00a35a1c395172a103ec053c0aab7b Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 14:22:57 -0400 Subject: [PATCH 08/24] feat: Refactor OrderDetailsBottomBar to use UiButton for improved styling and consistency --- .../order_details_bottom_bar.dart | 54 ++----------------- 1 file changed, 5 insertions(+), 49 deletions(-) diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart index 3f98df84..e97de4c2 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart @@ -50,20 +50,8 @@ class OrderDetailsBottomBar extends StatelessWidget { if (bookingInProgress) { return SizedBox( width: double.infinity, - child: ElevatedButton( + child: UiButton.primary( onPressed: null, - style: ElevatedButton.styleFrom( - backgroundColor: UiColors.primary.withValues(alpha: 0.5), - disabledBackgroundColor: UiColors.primary.withValues(alpha: 0.5), - disabledForegroundColor: UiColors.white, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(UiConstants.radiusMdValue), - ), - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space3, - ), - ), child: const SizedBox( width: UiConstants.iconMd, height: UiConstants.iconMd, @@ -80,50 +68,18 @@ class OrderDetailsBottomBar extends StatelessWidget { if (spotsLeft <= 0) { return SizedBox( width: double.infinity, - child: ElevatedButton( + child: UiButton.primary( onPressed: null, - style: ElevatedButton.styleFrom( - disabledBackgroundColor: UiColors.bgThird, - disabledForegroundColor: UiColors.textSecondary, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(UiConstants.radiusMdValue), - ), - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space3, - ), - ), - child: Text( - t.available_orders.fully_staffed, - style: UiTypography.body2m.textSecondary, - ), + text: t.available_orders.fully_staffed, ), ); } - // Instant book or standard apply - final bool isInstant = instantBook; return SizedBox( width: double.infinity, - child: ElevatedButton( + child: UiButton.primary( onPressed: onBook, - style: ElevatedButton.styleFrom( - backgroundColor: isInstant ? UiColors.success : UiColors.primary, - foregroundColor: UiColors.white, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(UiConstants.radiusMdValue), - ), - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space3, - ), - ), - child: Text( - isInstant - ? t.available_orders.book_order - : t.available_orders.apply, - style: UiTypography.body2m.white, - ), + text: t.available_orders.book_order, ), ); } From 2a99587d2ff573ee42fc64af901f9ce9922736f0 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 15:11:15 -0400 Subject: [PATCH 09/24] feat: Implement Google Maps integration for shift details and enhance UI components --- .../plugins/GeneratedPluginRegistrant.java | 5 ++ .../ios/Runner/GeneratedPluginRegistrant.m | 7 ++ .../pages/shift_details_page.dart | 2 + .../shift_date_time_section.dart | 2 +- .../shift_description_section.dart | 2 +- .../shift_details/shift_details_header.dart | 4 +- .../shift_details/shift_location_section.dart | 53 +++++++++++++- .../features/staff/shifts/pubspec.yaml | 1 + apps/mobile/pubspec.lock | 72 +++++++++++++++++++ 9 files changed, 141 insertions(+), 7 deletions(-) diff --git a/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index 2b40a2eb..05f7bbac 100644 --- a/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -45,6 +45,11 @@ public final class GeneratedPluginRegistrant { } catch (Exception e) { Log.e(TAG, "Error registering plugin geolocator_android, com.baseflow.geolocator.GeolocatorPlugin", e); } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.googlemaps.GoogleMapsPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin google_maps_flutter_android, io.flutter.plugins.googlemaps.GoogleMapsPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin()); } catch (Exception e) { diff --git a/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m b/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m index 0285454c..89cde2bb 100644 --- a/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m +++ b/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m @@ -36,6 +36,12 @@ @import geolocator_apple; #endif +#if __has_include() +#import +#else +@import google_maps_flutter_ios; +#endif + #if __has_include() #import #else @@ -80,6 +86,7 @@ [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; [FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]]; [GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]]; + [FGMGoogleMapsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FGMGoogleMapsPlugin"]]; [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]]; [RecordIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"RecordIosPlugin"]]; 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 cb238376..f0f9a27a 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 @@ -140,6 +140,8 @@ class _ShiftDetailsPageState extends State { ShiftLocationSection( location: detail.location, address: detail.address ?? '', + latitude: detail.latitude, + longitude: detail.longitude, locationLabel: context.t.staff_shifts.shift_details.location, tbdLabel: context.t.staff_shifts.shift_details.tbd, getDirectionLabel: context.t.staff_shifts.shift_details.get_direction, diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_date_time_section.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_date_time_section.dart index 3e38f151..08ce36e2 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_date_time_section.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_date_time_section.dart @@ -57,7 +57,7 @@ class ShiftDateTimeSection extends StatelessWidget { const Icon( UiIcons.calendar, size: 20, - color: UiColors.primary, + color: UiColors.textPrimary, ), const SizedBox(width: UiConstants.space2), Text( diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_description_section.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_description_section.dart index 770fc3f9..41731764 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_description_section.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_description_section.dart @@ -32,7 +32,7 @@ class ShiftDescriptionSection extends StatelessWidget { const SizedBox(height: UiConstants.space2), Text( description, - style: UiTypography.body2r.textSecondary, + style: UiTypography.body2r, ), ], ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart index c822d5e2..a225243e 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart @@ -45,8 +45,8 @@ class ShiftDetailsHeader extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(detail.title, style: UiTypography.headline1b.textPrimary), - Text(detail.roleName, style: UiTypography.body1m.textSecondary), + Text(detail.roleName, style: UiTypography.headline1b.textPrimary), + Text(detail.clientName, style: UiTypography.body1m.textSecondary), ], ), ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_section.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_section.dart index e85910b6..18e3786d 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_section.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_section.dart @@ -1,6 +1,7 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; /// A section displaying the shift's location, address, and "Get direction" action. @@ -10,6 +11,8 @@ class ShiftLocationSection extends StatelessWidget { super.key, required this.location, required this.address, + this.latitude, + this.longitude, required this.locationLabel, required this.tbdLabel, required this.getDirectionLabel, @@ -21,6 +24,12 @@ class ShiftLocationSection extends StatelessWidget { /// Street address. final String address; + /// Latitude coordinate for map preview. + final double? latitude; + + /// Longitude coordinate for map preview. + final double? longitude; + /// Localization string for location section title. final String locationLabel; @@ -97,15 +106,53 @@ class ShiftLocationSection extends StatelessWidget { ), ], ), + + if (latitude != null && longitude != null) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + child: SizedBox( + height: 180, + width: double.infinity, + child: GoogleMap( + initialCameraPosition: CameraPosition( + target: LatLng(latitude!, longitude!), + zoom: 15, + ), + markers: { + Marker( + markerId: const MarkerId('shift_location'), + position: LatLng(latitude!, longitude!), + ), + }, + liteModeEnabled: true, + myLocationButtonEnabled: false, + myLocationEnabled: false, + zoomControlsEnabled: false, + mapToolbarEnabled: false, + compassEnabled: false, + rotateGesturesEnabled: false, + scrollGesturesEnabled: false, + tiltGesturesEnabled: false, + zoomGesturesEnabled: false, + ), + ), + ), + const SizedBox(height: UiConstants.space3), + ], ], ), ); } Future _openDirections(BuildContext context) async { - final String destination = Uri.encodeComponent( - address.isNotEmpty ? address : location, - ); + String destination; + if (latitude != null && longitude != null) { + destination = '$latitude,$longitude'; + } else { + destination = Uri.encodeComponent( + address.isNotEmpty ? address : location, + ); + } final String url = 'https://www.google.com/maps/dir/?api=1&destination=$destination'; diff --git a/apps/mobile/packages/features/staff/shifts/pubspec.yaml b/apps/mobile/packages/features/staff/shifts/pubspec.yaml index a05c568e..d478f4f9 100644 --- a/apps/mobile/packages/features/staff/shifts/pubspec.yaml +++ b/apps/mobile/packages/features/staff/shifts/pubspec.yaml @@ -29,6 +29,7 @@ dependencies: url_launcher: ^6.3.1 bloc: ^8.1.4 meta: ^1.17.0 + google_maps_flutter: ^2.10.0 dev_dependencies: flutter_test: diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 8a97848c..53a17dd0 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -257,6 +257,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" csv: dependency: transitive description: @@ -637,6 +645,54 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" + google_maps: + dependency: transitive + description: + name: google_maps + sha256: "5d410c32112d7c6eb7858d359275b2aa04778eed3e36c745aeae905fb2fa6468" + url: "https://pub.dev" + source: hosted + version: "8.2.0" + google_maps_flutter: + dependency: transitive + description: + name: google_maps_flutter + sha256: "0114a31e177f650f0972347d93122c42661a75b869561ff6a374cc42ff3af886" + url: "https://pub.dev" + source: hosted + version: "2.16.0" + google_maps_flutter_android: + dependency: transitive + description: + name: google_maps_flutter_android + sha256: "68a3907c90dc37caffbcfc1093541ef2c18d6ebb53296fdb9f04822d16269353" + url: "https://pub.dev" + source: hosted + version: "2.19.3" + google_maps_flutter_ios: + dependency: transitive + description: + name: google_maps_flutter_ios + sha256: c855600dce17e77e8af96edcf85cb68501675bb77a72f85009d08c17a8805ace + url: "https://pub.dev" + source: hosted + version: "2.18.0" + google_maps_flutter_platform_interface: + dependency: transitive + description: + name: google_maps_flutter_platform_interface + sha256: ddbe34435dfb34e83fca295c6a8dcc53c3b51487e9eec3c737ce4ae605574347 + url: "https://pub.dev" + source: hosted + version: "2.15.0" + google_maps_flutter_web: + dependency: transitive + description: + name: google_maps_flutter_web + sha256: "6cefe4ef4cc61dc0dfba4c413dec4bd105cb6b9461bfbe1465ddd09f80af377d" + url: "https://pub.dev" + source: hosted + version: "0.6.2" google_places_flutter: dependency: transitive description: @@ -669,6 +725,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.5" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: transitive description: @@ -1189,6 +1253,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + sanitize_html: + dependency: transitive + description: + name: sanitize_html + sha256: "12669c4a913688a26555323fb9cec373d8f9fbe091f2d01c40c723b33caa8989" + url: "https://pub.dev" + source: hosted + version: "2.1.0" shared_preferences: dependency: transitive description: From faf27b03f27e42d55c8b3404ccde430e64a46587 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 15:14:13 -0400 Subject: [PATCH 10/24] feat: Update OrderDetailsHeader and ShiftDetailsHeader layout for improved UI consistency --- .../order_details/order_details_header.dart | 15 ++-- .../shift_details/shift_details_header.dart | 70 +++++++------------ 2 files changed, 31 insertions(+), 54 deletions(-) diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart index 0b8e8022..705323ff 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart @@ -58,7 +58,7 @@ class OrderDetailsHeader extends StatelessWidget { ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - spacing: UiConstants.space4, + spacing: UiConstants.space6, children: [ Row( crossAxisAlignment: CrossAxisAlignment.center, @@ -68,7 +68,7 @@ class OrderDetailsHeader extends StatelessWidget { width: _kIconContainerSize, height: _kIconContainerSize, decoration: BoxDecoration( - color: UiColors.tagInProgress, + color: UiColors.primary.withAlpha(20), borderRadius: UiConstants.radiusLg, border: Border.all(color: UiColors.primary, width: 0.5), ), @@ -86,13 +86,12 @@ class OrderDetailsHeader extends StatelessWidget { children: [ Text( order.roleName, - style: UiTypography.body1m.textPrimary, + style: UiTypography.headline1b.textPrimary, + ), + Text( + order.clientName, + style: UiTypography.body1m.textSecondary, ), - if (order.clientName.isNotEmpty) - Text( - order.clientName, - style: UiTypography.body3r.textSecondary, - ), ], ), ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart index a225243e..d20226a2 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart @@ -17,56 +17,34 @@ class ShiftDetailsHeader extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(UiConstants.space5), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, spacing: UiConstants.space4, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - spacing: UiConstants.space4, - children: [ - Container( - width: _kIconContainerSize, - height: _kIconContainerSize, - decoration: BoxDecoration( - color: UiColors.primary.withAlpha(20), - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.primary, width: 0.5), - ), - child: const Center( - child: Icon( - UiIcons.briefcase, - color: UiColors.primary, - size: 20, - ), - ), + Container( + width: _kIconContainerSize, + height: _kIconContainerSize, + decoration: BoxDecoration( + color: UiColors.primary.withAlpha(20), + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.primary, width: 0.5), + ), + child: const Center( + child: Icon( + UiIcons.briefcase, + color: UiColors.primary, + size: 20, ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(detail.roleName, style: UiTypography.headline1b.textPrimary), - Text(detail.clientName, style: UiTypography.body1m.textSecondary), - ], - ), - ), - ], + ), ), - Row( - spacing: UiConstants.space1, - children: [ - const Icon( - UiIcons.mapPin, - size: 16, - color: UiColors.textSecondary, - ), - Expanded( - child: Text( - detail.address ?? detail.location, - style: UiTypography.body2r.textSecondary, - ), - ), - ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(detail.roleName, style: UiTypography.headline1b.textPrimary), + Text(detail.clientName, style: UiTypography.body1m.textSecondary), + ], + ), ), ], ), From e1b9ad532bfd148061b437f44c0bd4aa1d3dadd1 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 15:17:26 -0400 Subject: [PATCH 11/24] feat: Update client name text style in OrderDetailsHeader and ShiftDetailsHeader for consistency --- .../widgets/order_details/order_details_header.dart | 2 +- .../widgets/shift_details/shift_details_header.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart index 705323ff..cb8145f9 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_header.dart @@ -90,7 +90,7 @@ class OrderDetailsHeader extends StatelessWidget { ), Text( order.clientName, - style: UiTypography.body1m.textSecondary, + style: UiTypography.body2r.textSecondary, ), ], ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart index d20226a2..a64ef1a1 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart @@ -42,7 +42,7 @@ class ShiftDetailsHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(detail.roleName, style: UiTypography.headline1b.textPrimary), - Text(detail.clientName, style: UiTypography.body1m.textSecondary), + Text(detail.clientName, style: UiTypography.body2r.textSecondary), ], ), ), From fea679b84c325158f8a2c5750630ecbd59547afd Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 15:40:26 -0400 Subject: [PATCH 12/24] feat: Enhance order details page with date range and clock-in/out labels, and improve OrderScheduleSection layout --- .../ui-ux-design/component-patterns.md | 28 +++ .../lib/src/l10n/en.i18n.json | 1 + .../lib/src/l10n/es.i18n.json | 1 + .../pages/order_details_page.dart | 6 + .../order_details_bottom_bar.dart | 2 +- .../order_details/order_schedule_section.dart | 195 +++++++++++++----- 6 files changed, 179 insertions(+), 54 deletions(-) diff --git a/.claude/agent-memory/ui-ux-design/component-patterns.md b/.claude/agent-memory/ui-ux-design/component-patterns.md index 9ce4d7c2..6d50b939 100644 --- a/.claude/agent-memory/ui-ux-design/component-patterns.md +++ b/.claude/agent-memory/ui-ux-design/component-patterns.md @@ -85,3 +85,31 @@ History state is cached in BLoC as `Map> Uses `CustomScrollView` with `SliverList` for header + `SliverPadding` wrapping `SliverList.separated` for content. Bottom padding on content sliver: `EdgeInsets.fromLTRB(16, 16, 16, 120)` to clear bottom nav bar. + +## ShiftDateTimeSection / OrderScheduleSection — Shift Detail Section Pattern + +Both widgets live in `packages/features/staff/shifts/lib/src/presentation/widgets/`: +- `shift_details/shift_date_time_section.dart` — single date, clock-in/clock-out boxes +- `order_details/order_schedule_section.dart` — date range, 7-day circle row, clock-in/clock-out boxes + +**Shared conventions (non-negotiable for section consistency):** +- Outer padding: `EdgeInsets.all(UiConstants.space5)` — 20dp all sides +- Section title: `UiTypography.titleUppercase4b.textSecondary` +- Title → content gap: `UiConstants.space2` (8dp) +- Time boxes: `UiColors.bgThird` background, `UiConstants.radiusBase` (12dp) corners, `UiConstants.space3` (12dp) all padding +- Time box label: `UiTypography.footnote2b.copyWith(color: UiColors.textSecondary, letterSpacing: 0.5)` +- Time box value: `UiTypography.title1m.copyWith(fontWeight: FontWeight.w700).textPrimary` +- Between time boxes: `UiConstants.space4` (16dp) gap +- Date → time boxes gap: `UiConstants.space6` (24dp) +- Time format: `DateFormat('h:mm a')` — uppercase AM/PM with space + +**OrderScheduleSection day-of-week circles:** +- 7 circles always shown (Mon–Sun ISO order) regardless of active days +- Circle size: 32×32dp (fixed, not a token) +- Active: bg=`UiColors.primary`, text=`UiColors.white`, style=`footnote2m` +- Inactive: bg=`UiColors.bgThird`, text=`UiColors.textSecondary`, style=`footnote2m` +- Shape: `UiConstants.radiusFull` +- Single-char labels: M T W T F S S +- Inter-circle gap: `UiConstants.space2` (8dp) +- Accessibility: wrap row with `Semantics(label: "Repeats on ...")`, mark individual circles with `ExcludeSemantics` +- Ordering constant: `[DayOfWeek.mon, .tue, .wed, .thu, .fri, .sat, .sun]` — do NOT derive from API list order diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 423ea826..7088c0e7 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -1885,6 +1885,7 @@ "spots_left": "${count} spot(s) left", "shifts_count": "${count} shift(s)", "schedule_label": "SCHEDULE", + "date_range_label": "Date Range", "booking_success": "Order booked successfully!", "booking_pending": "Your booking is pending approval", "booking_confirmed": "Your booking has been confirmed!", diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 927d701c..718eeb72 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -1885,6 +1885,7 @@ "spots_left": "${count} puesto(s) disponible(s)", "shifts_count": "${count} turno(s)", "schedule_label": "HORARIO", + "date_range_label": "Rango de Fechas", "booking_success": "\u00a1Orden reservada con \u00e9xito!", "booking_pending": "Tu reserva est\u00e1 pendiente de aprobaci\u00f3n", "booking_confirmed": "\u00a1Tu reserva ha sido confirmada!", diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/order_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/order_details_page.dart index ffc0debd..3512f336 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/order_details_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/order_details_page.dart @@ -139,6 +139,12 @@ class _OrderDetailsPageState extends State { schedule: order.schedule, scheduleLabel: context.t.available_orders.schedule_label, + dateRangeLabel: + context.t.available_orders.date_range_label, + clockInLabel: + context.t.staff_shifts.shift_details.start_time, + clockOutLabel: + context.t.staff_shifts.shift_details.end_time, shiftsCountLabel: t.available_orders.shifts_count( count: order.schedule.totalShifts, ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart index e97de4c2..80a5d44b 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart @@ -79,7 +79,7 @@ class OrderDetailsBottomBar extends StatelessWidget { width: double.infinity, child: UiButton.primary( onPressed: onBook, - text: t.available_orders.book_order, + text: 'Book Shift', ), ); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_schedule_section.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_schedule_section.dart index a7cbdfda..a961d795 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_schedule_section.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_schedule_section.dart @@ -5,13 +5,18 @@ import 'package:krow_domain/krow_domain.dart'; /// A section displaying the schedule for an available order. /// -/// Shows the days-of-week chips, date range, time range, and total shift count. +/// Shows a date range, Google Calendar-style day-of-week circles, +/// clock-in/clock-out time boxes, and total shift count. +/// Follows the same visual structure as [ShiftDateTimeSection]. class OrderScheduleSection extends StatelessWidget { /// Creates an [OrderScheduleSection]. const OrderScheduleSection({ super.key, required this.schedule, required this.scheduleLabel, + required this.dateRangeLabel, + required this.clockInLabel, + required this.clockOutLabel, required this.shiftsCountLabel, }); @@ -21,9 +26,40 @@ class OrderScheduleSection extends StatelessWidget { /// Localised section title (e.g. "SCHEDULE"). final String scheduleLabel; + /// Localised label for the date range row (e.g. "Date Range"). + final String dateRangeLabel; + + /// Localised label for the clock-in time box (e.g. "START TIME"). + final String clockInLabel; + + /// Localised label for the clock-out time box (e.g. "END TIME"). + final String clockOutLabel; + /// Localised shifts count text (e.g. "3 shift(s)"). final String shiftsCountLabel; + /// All seven days in ISO order for the day-of-week row. + static const List _allDays = [ + DayOfWeek.mon, + DayOfWeek.tue, + DayOfWeek.wed, + DayOfWeek.thu, + DayOfWeek.fri, + DayOfWeek.sat, + DayOfWeek.sun, + ]; + + /// Single-letter labels for each day (ISO order). + static const List _dayLabels = [ + 'M', + 'T', + 'W', + 'T', + 'F', + 'S', + 'S', + ]; + /// Formats a date-only string (e.g. "2026-03-24") to "Mar 24". String _formatDateShort(String dateStr) { if (dateStr.isEmpty) return ''; @@ -35,96 +71,149 @@ class OrderScheduleSection extends StatelessWidget { } } - /// Formats a DateTime to a time string (e.g. "9:00am"). - String _formatTime(DateTime dt) { - return DateFormat('h:mma').format(dt).toLowerCase(); + /// Formats [DateTime] to a time string (e.g. "9:00 AM"). + String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt); + + /// Builds the date range display string including the year. + String _buildDateRangeText() { + final String start = _formatDateShort(schedule.startDate); + final String end = _formatDateShort(schedule.endDate); + // Extract year from endDate for display. + String year = ''; + if (schedule.endDate.isNotEmpty) { + try { + final DateTime endDt = DateTime.parse(schedule.endDate); + year = ', ${endDt.year}'; + } catch (_) { + // Ignore parse errors. + } + } + return '$start - $end$year'; } @override Widget build(BuildContext context) { - final String dateRange = - '${_formatDateShort(schedule.startDate)} - ${_formatDateShort(schedule.endDate)}'; - final String timeRange = - '${_formatTime(schedule.firstShiftStartsAt)} - ${_formatTime(schedule.lastShiftEndsAt)}'; - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space6, - vertical: UiConstants.space4, - ), + padding: const EdgeInsets.all(UiConstants.space5), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Section title Text( scheduleLabel, style: UiTypography.titleUppercase4b.textSecondary, ), - const SizedBox(height: UiConstants.space3), - - // Days of week chips - if (schedule.daysOfWeek.isNotEmpty) ...[ - Wrap( - spacing: UiConstants.space1, - runSpacing: UiConstants.space1, - children: schedule.daysOfWeek - .map((DayOfWeek day) => _buildDayChip(day)) - .toList(), - ), - const SizedBox(height: UiConstants.space3), - ], + const SizedBox(height: UiConstants.space4), // Date range row Row( children: [ const Icon( UiIcons.calendar, - size: 20, - color: UiColors.primary, + size: UiConstants.space5, + color: UiColors.textPrimary, ), const SizedBox(width: UiConstants.space2), - Text(dateRange, style: UiTypography.headline5m.textPrimary), + Text( + _buildDateRangeText(), + style: UiTypography.title1m.textPrimary, + ), ], ), - const SizedBox(height: UiConstants.space2), + const SizedBox(height: UiConstants.space6), - // Time range row + // Days-of-week circles (Google Calendar style) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + for (int i = 0; i < _allDays.length; i++) + _buildDayCircle( + _allDays[i], + _dayLabels[i], + schedule.daysOfWeek.contains(_allDays[i]), + ), + ], + ), + const SizedBox(height: UiConstants.space6), + + // Clock in / Clock out time boxes Row( children: [ - const Icon( - UiIcons.clock, - size: 20, - color: UiColors.primary, + Expanded( + child: _buildTimeBox(clockInLabel, schedule.firstShiftStartsAt), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: _buildTimeBox(clockOutLabel, schedule.lastShiftEndsAt), ), - const SizedBox(width: UiConstants.space2), - Text(timeRange, style: UiTypography.headline5m.textPrimary), ], ), - const SizedBox(height: UiConstants.space2), + const SizedBox(height: UiConstants.space8), + Text( + 'TOTAL SHIFTS', + style: UiTypography.titleUppercase4b.textSecondary, + ), + const SizedBox(height: UiConstants.space2), // Shifts count - Text(shiftsCountLabel, style: UiTypography.footnote2r.textSecondary), + Text(shiftsCountLabel, style: UiTypography.body1r), ], ), ); } - /// Builds a small chip showing a day-of-week abbreviation. - Widget _buildDayChip(DayOfWeek day) { - final String label = day.value.isNotEmpty - ? '${day.value[0]}${day.value.substring(1).toLowerCase()}' - : ''; + /// Builds a single day-of-week circle. + /// + /// Active days are filled with the primary color and white text. + /// Inactive days use the background color and secondary text. + Widget _buildDayCircle(DayOfWeek day, String label, bool isActive) { return Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space2, - vertical: 2, - ), + width: 32, + height: 32, decoration: BoxDecoration( - color: UiColors.primary.withValues(alpha: 0.08), - borderRadius: UiConstants.radiusSm, + border: Border.all( + color: isActive ? UiColors.primary : UiColors.background, + width: 1.5, + ), + color: isActive ? UiColors.primary.withAlpha(40) : UiColors.background, + shape: BoxShape.circle, ), - child: Text( - label, - style: UiTypography.footnote2m.copyWith(color: UiColors.primary), + child: Center( + child: Text( + label, + style: isActive + ? UiTypography.footnote1b.primary + : UiTypography.footnote2m.textSecondary, + ), + ), + ); + } + + /// Builds a time-display box matching the [ShiftDateTimeSection] pattern. + Widget _buildTimeBox(String label, DateTime time) { + return Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.bgThird, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + child: Column( + children: [ + Text( + label, + style: UiTypography.footnote2b.copyWith( + color: UiColors.textSecondary, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: UiConstants.space1), + Text( + _formatTime(time), + style: UiTypography.title1m + .copyWith(fontWeight: FontWeight.w700) + .textPrimary, + ), + ], ), ); } From 3d80e6b7acf7767777f1993cba5c645a13b74487 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 15:55:27 -0400 Subject: [PATCH 13/24] feat: Implement accept shift functionality with localization support for success messages --- .../lib/src/l10n/en.i18n.json | 1 + .../lib/src/l10n/es.i18n.json | 1 + .../shift_details/shift_details_bloc.dart | 25 +++++++++++++++++++ .../shift_details/shift_details_event.dart | 15 +++++++++++ .../pages/shift_details_page.dart | 6 +++-- .../shifts/lib/src/shift_details_module.dart | 3 +++ .../shifts/lib/src/staff_shifts_module.dart | 1 + 7 files changed, 50 insertions(+), 2 deletions(-) diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 7088c0e7..58dc4742 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -1362,6 +1362,7 @@ "go_to_certificates": "Go to Certificates", "shift_booked": "Shift successfully booked!", "shift_not_found": "Shift not found", + "shift_accepted": "Shift accepted successfully!", "shift_declined_success": "Shift declined", "complete_account_title": "Complete Your Account", "complete_account_description": "Complete your account to book this shift and start earning" diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 718eeb72..96fa838f 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -1357,6 +1357,7 @@ "go_to_certificates": "Ir a Certificados", "shift_booked": "¡Turno reservado con éxito!", "shift_not_found": "Turno no encontrado", + "shift_accepted": "¡Turno aceptado con éxito!", "shift_declined_success": "Turno rechazado", "complete_account_title": "Completa Tu Cuenta", "complete_account_description": "Completa tu cuenta para reservar este turno y comenzar a ganar" diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart index 8dc53c57..469dc693 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart @@ -2,6 +2,7 @@ import 'package:bloc/bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_shifts/src/domain/usecases/accept_shift_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/apply_for_shift_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/decline_shift_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart'; @@ -18,10 +19,12 @@ class ShiftDetailsBloc extends Bloc required this.getShiftDetail, required this.applyForShift, required this.declineShift, + required this.acceptShift, required this.getProfileCompletion, }) : super(ShiftDetailsInitial()) { on(_onLoadDetails); on(_onBookShift); + on(_onAcceptShift); on(_onDeclineShift); } @@ -34,6 +37,9 @@ class ShiftDetailsBloc extends Bloc /// Use case for declining a shift. final DeclineShiftUseCase declineShift; + /// Use case for accepting an assigned shift. + final AcceptShiftUseCase acceptShift; + /// Use case for checking profile completion. final GetProfileCompletionUseCase getProfileCompletion; @@ -83,6 +89,25 @@ class ShiftDetailsBloc extends Bloc ); } + Future _onAcceptShift( + AcceptShiftDetailsEvent event, + Emitter emit, + ) async { + await handleError( + emit: emit.call, + action: () async { + await acceptShift(event.shiftId); + emit( + ShiftActionSuccess( + 'shift_accepted', + shiftDate: event.date, + ), + ); + }, + onError: (String errorKey) => ShiftDetailsError(errorKey), + ); + } + Future _onDeclineShift( DeclineShiftDetailsEvent event, Emitter emit, diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_event.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_event.dart index 48599313..e99ec66d 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_event.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_event.dart @@ -26,6 +26,21 @@ class BookShiftDetailsEvent extends ShiftDetailsEvent { List get props => [shiftId, roleId, date]; } +/// Event dispatched when the worker accepts an already-assigned shift. +class AcceptShiftDetailsEvent extends ShiftDetailsEvent { + /// The shift to accept. + final String shiftId; + + /// Optional date used for post-action navigation. + final DateTime? date; + + /// Creates an [AcceptShiftDetailsEvent]. + const AcceptShiftDetailsEvent(this.shiftId, {this.date}); + + @override + List get props => [shiftId, date]; +} + class DeclineShiftDetailsEvent extends ShiftDetailsEvent { final String shiftId; const DeclineShiftDetailsEvent(this.shiftId); 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 f0f9a27a..26f72047 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 @@ -166,9 +166,9 @@ class _ShiftDetailsPageState extends State { ).add(DeclineShiftDetailsEvent(detail.shiftId)), onAccept: () => BlocProvider.of(context).add( - BookShiftDetailsEvent( + AcceptShiftDetailsEvent( detail.shiftId, - roleId: detail.roleId, + date: detail.date, ), ), ), @@ -262,6 +262,8 @@ class _ShiftDetailsPageState extends State { switch (key) { case 'shift_booked': return context.t.staff_shifts.shift_details.shift_booked; + case 'shift_accepted': + return context.t.staff_shifts.shift_details.shift_accepted; case 'shift_declined_success': return context.t.staff_shifts.shift_details.shift_declined_success; default: diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/shift_details_module.dart b/apps/mobile/packages/features/staff/shifts/lib/src/shift_details_module.dart index 12b958b3..cbce90e4 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/shift_details_module.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/shift_details_module.dart @@ -4,6 +4,7 @@ import 'package:krow_domain/krow_domain.dart'; import 'package:staff_shifts/src/data/repositories_impl/shifts_repository_impl.dart'; import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; +import 'package:staff_shifts/src/domain/usecases/accept_shift_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/apply_for_shift_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/decline_shift_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart'; @@ -30,6 +31,7 @@ class ShiftDetailsModule extends Module { i.add(GetShiftDetailUseCase.new); i.add(ApplyForShiftUseCase.new); i.add(DeclineShiftUseCase.new); + i.add(AcceptShiftUseCase.new); i.add(GetProfileCompletionUseCase.new); // BLoC @@ -38,6 +40,7 @@ class ShiftDetailsModule extends Module { getShiftDetail: i.get(), applyForShift: i.get(), declineShift: i.get(), + acceptShift: i.get(), getProfileCompletion: i.get(), ), ); 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 8bb5f36d..dc0911ba 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 @@ -74,6 +74,7 @@ class StaffShiftsModule extends Module { getShiftDetail: i.get(), applyForShift: i.get(), declineShift: i.get(), + acceptShift: i.get(), getProfileCompletion: i.get(), ), ); From 18a459a453f7cb8166482faddde2144a1637e7e5 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 16:11:16 -0400 Subject: [PATCH 14/24] feat: Implement GetMyShiftsData use case and integrate it into ShiftsBloc for improved shift data handling --- .../lib/src/domain/models/my_shifts_data.dart | 23 ++++++++++ .../usecases/get_my_shifts_data_usecase.dart | 40 ++++++++++++++++++ .../blocs/shifts/shifts_bloc.dart | 42 +++++++++---------- .../shifts/lib/src/staff_shifts_module.dart | 3 ++ 4 files changed, 86 insertions(+), 22 deletions(-) create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/domain/models/my_shifts_data.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_my_shifts_data_usecase.dart diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/models/my_shifts_data.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/models/my_shifts_data.dart new file mode 100644 index 00000000..42669e27 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/models/my_shifts_data.dart @@ -0,0 +1,23 @@ +import 'package:krow_domain/krow_domain.dart'; + +/// Combined result from loading all My Shifts tab data sources. +/// +/// Holds assigned shifts, pending assignments, and cancelled shifts +/// fetched in parallel from the V2 API. +class MyShiftsData { + /// Creates a [MyShiftsData] instance. + const MyShiftsData({ + required this.assignedShifts, + required this.pendingAssignments, + required this.cancelledShifts, + }); + + /// Assigned shifts for the requested date range. + final List assignedShifts; + + /// Pending assignments awaiting worker acceptance. + final List pendingAssignments; + + /// Cancelled shift assignments. + final List cancelledShifts; +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_my_shifts_data_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_my_shifts_data_usecase.dart new file mode 100644 index 00000000..f6f6952c --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_my_shifts_data_usecase.dart @@ -0,0 +1,40 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_shifts/src/domain/arguments/get_my_shifts_arguments.dart'; +import 'package:staff_shifts/src/domain/models/my_shifts_data.dart'; +import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; + +/// Fetches all data needed for the My Shifts tab in a single call. +/// +/// Calls [ShiftsRepositoryInterface.getAssignedShifts], +/// [ShiftsRepositoryInterface.getPendingAssignments], and +/// [ShiftsRepositoryInterface.getCancelledShifts] in parallel and returns +/// a unified [MyShiftsData]. +class GetMyShiftsDataUseCase + extends UseCase { + /// Creates a [GetMyShiftsDataUseCase]. + GetMyShiftsDataUseCase(this._repository); + + /// The shifts repository. + final ShiftsRepositoryInterface _repository; + + /// Loads assigned, pending, and cancelled shifts for the given date range. + @override + Future call(GetAssignedShiftsArguments arguments) async { + final List results = await Future.wait(>[ + _repository.getAssignedShifts( + start: arguments.start, + end: arguments.end, + ), + _repository.getPendingAssignments(), + _repository.getCancelledShifts(), + ]); + + return MyShiftsData( + assignedShifts: results[0] as List, + pendingAssignments: results[1] as List, + cancelledShifts: results[2] as List, + ); + } +} 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 a63992b3..7c4a6905 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 @@ -8,9 +8,11 @@ import 'package:staff_shifts/src/domain/arguments/get_available_shifts_arguments import 'package:staff_shifts/src/domain/arguments/get_my_shifts_arguments.dart'; import 'package:staff_shifts/src/domain/usecases/accept_shift_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/decline_shift_usecase.dart'; +import 'package:staff_shifts/src/domain/models/my_shifts_data.dart'; import 'package:staff_shifts/src/domain/usecases/get_available_shifts_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_cancelled_shifts_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_history_shifts_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_my_shifts_data_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_my_shifts_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_pending_assignments_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart'; @@ -34,6 +36,7 @@ class ShiftsBloc extends Bloc required this.acceptShift, required this.declineShift, required this.submitForApproval, + required this.getMyShiftsData, }) : super(const ShiftsState()) { on(_onLoadShifts); on(_onLoadHistoryShifts); @@ -74,6 +77,9 @@ class ShiftsBloc extends Bloc /// Use case for submitting a shift for timesheet approval. final SubmitForApprovalUseCase submitForApproval; + /// Use case that loads assigned, pending, and cancelled shifts in parallel. + final GetMyShiftsDataUseCase getMyShiftsData; + Future _onLoadShifts( LoadShiftsEvent event, Emitter emit, @@ -86,29 +92,16 @@ class ShiftsBloc extends Bloc emit: emit.call, action: () async { final List days = getCalendarDaysForOffset(0); - - // Load assigned, pending, and cancelled shifts in parallel. - final List results = await Future.wait(>[ - getAssignedShifts( - GetAssignedShiftsArguments(start: days.first, end: days.last), - ), - getPendingAssignments(), - getCancelledShifts(), - ]); - - final List myShiftsResult = - results[0] as List; - final List pendingResult = - results[1] as List; - final List cancelledResult = - results[2] as List; + final MyShiftsData data = await getMyShiftsData( + GetAssignedShiftsArguments(start: days.first, end: days.last), + ); emit( state.copyWith( status: ShiftsStatus.loaded, - myShifts: myShiftsResult, - pendingShifts: pendingResult, - cancelledShifts: cancelledResult, + myShifts: data.assignedShifts, + pendingShifts: data.pendingAssignments, + cancelledShifts: data.cancelledShifts, availableShifts: const [], historyShifts: const [], availableLoading: false, @@ -250,18 +243,23 @@ class ShiftsBloc extends Bloc LoadShiftsForRangeEvent event, Emitter emit, ) async { - emit(state.copyWith(myShifts: const [], myShiftsLoaded: false)); + emit(state.copyWith( + myShifts: const [], + myShiftsLoaded: false, + )); await handleError( emit: emit.call, action: () async { - final List myShiftsResult = await getAssignedShifts( + final MyShiftsData data = await getMyShiftsData( GetAssignedShiftsArguments(start: event.start, end: event.end), ); emit( state.copyWith( status: ShiftsStatus.loaded, - myShifts: myShiftsResult, + myShifts: data.assignedShifts, + pendingShifts: data.pendingAssignments, + cancelledShifts: data.cancelledShifts, myShiftsLoaded: true, clearErrorMessage: true, ), 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 dc0911ba..f7dee609 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 @@ -8,6 +8,7 @@ import 'package:staff_shifts/src/domain/usecases/apply_for_shift_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_available_shifts_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_cancelled_shifts_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_history_shifts_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_my_shifts_data_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_my_shifts_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_pending_assignments_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart'; @@ -52,6 +53,7 @@ class StaffShiftsModule extends Module { i.addLazySingleton( () => SubmitForApprovalUseCase(i.get()), ); + i.addLazySingleton(GetMyShiftsDataUseCase.new); i.addLazySingleton(GetAvailableOrdersUseCase.new); i.addLazySingleton(BookOrderUseCase.new); @@ -67,6 +69,7 @@ class StaffShiftsModule extends Module { acceptShift: i.get(), declineShift: i.get(), submitForApproval: i.get(), + getMyShiftsData: i.get(), ), ); i.add( From eff8bcce57fa2606ca3f73df0fa833b66ef1eff6 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 16:25:45 -0400 Subject: [PATCH 15/24] feat: Remove shift confirmation and decline dialogs from MyShiftsTab --- .../widgets/tabs/my_shifts_tab.dart | 74 ------------------- 1 file changed, 74 deletions(-) diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart index 67063ce3..aeed0436 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart @@ -112,76 +112,6 @@ class _MyShiftsTabState extends State { return a.year == b.year && a.month == b.month && a.day == b.day; } - void _confirmShift(String id) { - showDialog( - context: context, - builder: (BuildContext ctx) => AlertDialog( - title: - Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.title), - content: - Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.message), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(), - child: Text(context.t.common.cancel), - ), - TextButton( - onPressed: () { - Navigator.of(ctx).pop(); - ReadContext(context).read().add(AcceptShiftEvent(id)); - UiSnackbar.show( - context, - message: context - .t.staff_shifts.my_shifts_tab.confirm_dialog.success, - type: UiSnackbarType.success, - ); - }, - style: TextButton.styleFrom( - foregroundColor: UiColors.success, - ), - child: - Text(context.t.staff_shifts.shift_details.accept_shift), - ), - ], - ), - ); - } - - void _declineShift(String id) { - showDialog( - context: context, - builder: (BuildContext ctx) => AlertDialog( - title: - Text(context.t.staff_shifts.my_shifts_tab.decline_dialog.title), - content: Text( - context.t.staff_shifts.my_shifts_tab.decline_dialog.message, - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(), - child: Text(context.t.common.cancel), - ), - TextButton( - onPressed: () { - Navigator.of(ctx).pop(); - ReadContext(context).read().add(DeclineShiftEvent(id)); - UiSnackbar.show( - context, - message: context - .t.staff_shifts.my_shifts_tab.decline_dialog.success, - type: UiSnackbarType.error, - ); - }, - style: TextButton.styleFrom( - foregroundColor: UiColors.destructive, - ), - child: Text(context.t.staff_shifts.shift_details.decline), - ), - ], - ), - ); - } - @override Widget build(BuildContext context) { final List calendarDays = _getCalendarDays(); @@ -352,10 +282,6 @@ class _MyShiftsTabState extends State { data: ShiftCardData.fromPending(assignment), onTap: () => Modular.to .toShiftDetailsById(assignment.shiftId), - onAccept: () => - _confirmShift(assignment.shiftId), - onDecline: () => - _declineShift(assignment.shiftId), ), ), ), From ac9a0b9c9d7d52b376bbf782e5b37e6b6dec82d1 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 16:28:29 -0400 Subject: [PATCH 16/24] feat: Implement cross-platform NFC clocking interface with calendar and shift sections --- .../tabs/my_shifts/section_header.dart | 44 +++ .../tabs/my_shifts/shift_section_list.dart | 162 ++++++++++ .../my_shifts/week_calendar_selector.dart | 159 ++++++++++ .../widgets/tabs/my_shifts_tab.dart | 293 ++---------------- 4 files changed, 392 insertions(+), 266 deletions(-) create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/section_header.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/shift_section_list.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/week_calendar_selector.dart diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/section_header.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/section_header.dart new file mode 100644 index 00000000..6bd626bd --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/section_header.dart @@ -0,0 +1,44 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A section header with a colored dot indicator and title text. +class SectionHeader extends StatelessWidget { + /// Creates a [SectionHeader]. + const SectionHeader({ + super.key, + required this.title, + required this.dotColor, + }); + + /// The header title text. + final String title; + + /// The color of the leading dot indicator. + final Color dotColor; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space4), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: dotColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: UiConstants.space2), + Text( + title, + style: dotColor == UiColors.textSecondary + ? UiTypography.body2b.textSecondary + : UiTypography.body2b.copyWith(color: dotColor), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/shift_section_list.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/shift_section_list.dart new file mode 100644 index 00000000..eb41d04a --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/shift_section_list.dart @@ -0,0 +1,162 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart' show ReadContext; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; +import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_card/index.dart'; +import 'section_header.dart'; + +/// Scrollable list displaying pending, cancelled, and confirmed shift sections. +/// +/// Renders each section with a [SectionHeader] and a list of [ShiftCard] +/// widgets. Shows an [EmptyStateView] when all sections are empty. +class ShiftSectionList extends StatelessWidget { + /// Creates a [ShiftSectionList]. + const ShiftSectionList({ + super.key, + required this.assignedShifts, + required this.pendingAssignments, + required this.cancelledShifts, + this.submittedShiftIds = const {}, + this.submittingShiftId, + }); + + /// Confirmed/assigned shifts visible for the selected day. + final List assignedShifts; + + /// Pending assignments awaiting acceptance. + final List pendingAssignments; + + /// Cancelled shifts visible for the selected week. + final List cancelledShifts; + + /// Set of shift IDs that have been successfully submitted for approval. + final Set submittedShiftIds; + + /// The shift ID currently being submitted (null when idle). + final String? submittingShiftId; + + @override + Widget build(BuildContext context) { + return Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), + child: Column( + children: [ + const SizedBox(height: UiConstants.space5), + + // Pending assignments section + if (pendingAssignments.isNotEmpty) ...[ + SectionHeader( + title: + context.t.staff_shifts.my_shifts_tab.sections.awaiting, + dotColor: UiColors.textWarning, + ), + ...pendingAssignments.map( + (PendingAssignment assignment) => Padding( + padding: const EdgeInsets.only( + bottom: UiConstants.space4, + ), + child: ShiftCard( + data: ShiftCardData.fromPending(assignment), + onTap: () => + Modular.to.toShiftDetailsById(assignment.shiftId), + ), + ), + ), + const SizedBox(height: UiConstants.space3), + ], + + // Cancelled shifts section + if (cancelledShifts.isNotEmpty) ...[ + SectionHeader( + title: + context.t.staff_shifts.my_shifts_tab.sections.cancelled, + dotColor: UiColors.textSecondary, + ), + ...cancelledShifts.map( + (CancelledShift cs) => Padding( + padding: const EdgeInsets.only( + bottom: UiConstants.space4, + ), + child: ShiftCard( + data: ShiftCardData.fromCancelled(cs), + onTap: () => + Modular.to.toShiftDetailsById(cs.shiftId), + ), + ), + ), + const SizedBox(height: UiConstants.space3), + ], + + // Confirmed shifts section + if (assignedShifts.isNotEmpty) ...[ + SectionHeader( + title: + context.t.staff_shifts.my_shifts_tab.sections.confirmed, + dotColor: UiColors.textSecondary, + ), + ...assignedShifts.map( + (AssignedShift shift) { + final bool isCompleted = + shift.status == AssignmentStatus.completed; + final bool isSubmitted = + submittedShiftIds.contains(shift.shiftId); + final bool isSubmitting = + submittingShiftId == shift.shiftId; + + return Padding( + padding: const EdgeInsets.only( + bottom: UiConstants.space3, + ), + child: ShiftCard( + data: ShiftCardData.fromAssigned(shift), + onTap: () => + Modular.to.toShiftDetailsById(shift.shiftId), + showApprovalAction: isCompleted, + isSubmitted: isSubmitted, + isSubmitting: isSubmitting, + onSubmitForApproval: () { + ReadContext(context).read().add( + SubmitForApprovalEvent( + shiftId: shift.shiftId, + ), + ); + UiSnackbar.show( + context, + message: context.t.staff_shifts + .my_shift_card.timesheet_submitted, + type: UiSnackbarType.success, + ); + }, + ), + ); + }, + ), + ], + + // Empty state + if (assignedShifts.isEmpty && + pendingAssignments.isEmpty && + cancelledShifts.isEmpty) + EmptyStateView( + icon: UiIcons.calendar, + title: context.t.staff_shifts.my_shifts_tab.empty.title, + subtitle: + context.t.staff_shifts.my_shifts_tab.empty.subtitle, + ), + + const SizedBox(height: UiConstants.space32), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/week_calendar_selector.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/week_calendar_selector.dart new file mode 100644 index 00000000..7bf42b7a --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/week_calendar_selector.dart @@ -0,0 +1,159 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// A week-view calendar selector showing 7 days with navigation arrows. +/// +/// Displays a month/year header with chevron arrows for week navigation and +/// a row of day cells. Days with assigned shifts show a dot indicator. +class WeekCalendarSelector extends StatelessWidget { + /// Creates a [WeekCalendarSelector]. + const WeekCalendarSelector({ + super.key, + required this.calendarDays, + required this.selectedDate, + required this.shifts, + required this.onDateSelected, + required this.onPreviousWeek, + required this.onNextWeek, + }); + + /// The 7 days to display in the calendar row. + final List calendarDays; + + /// The currently selected date. + final DateTime selectedDate; + + /// Assigned shifts used to show dot indicators on days with shifts. + final List shifts; + + /// Called when a day cell is tapped. + final ValueChanged onDateSelected; + + /// Called when the previous-week chevron is tapped. + final VoidCallback onPreviousWeek; + + /// Called when the next-week chevron is tapped. + final VoidCallback onNextWeek; + + bool _isSameDay(DateTime a, DateTime b) { + return a.year == b.year && a.month == b.month && a.day == b.day; + } + + @override + Widget build(BuildContext context) { + final DateTime weekStartDate = calendarDays.first; + + return Container( + color: UiColors.white, + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space4, + horizontal: UiConstants.space4, + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon( + UiIcons.chevronLeft, + size: 20, + color: UiColors.textPrimary, + ), + onPressed: onPreviousWeek, + constraints: const BoxConstraints(), + padding: EdgeInsets.zero, + ), + Text( + DateFormat('MMMM yyyy').format(weekStartDate), + style: UiTypography.title1m.textPrimary, + ), + IconButton( + icon: const Icon( + UiIcons.chevronRight, + size: 20, + color: UiColors.textPrimary, + ), + onPressed: onNextWeek, + constraints: const BoxConstraints(), + padding: EdgeInsets.zero, + ), + ], + ), + ), + // Days Grid + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: calendarDays.map((DateTime date) { + final bool isSelected = _isSameDay(date, selectedDate); + final bool hasShifts = shifts.any( + (AssignedShift s) => _isSameDay(s.date, date), + ); + + return GestureDetector( + onTap: () => onDateSelected(date), + child: Column( + children: [ + Container( + width: 44, + height: 60, + decoration: BoxDecoration( + color: isSelected ? UiColors.primary : UiColors.white, + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), + border: Border.all( + color: + isSelected ? UiColors.primary : UiColors.border, + width: 1, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + date.day.toString().padLeft(2, '0'), + style: isSelected + ? UiTypography.body1b.white + : UiTypography.body1b.textPrimary, + ), + Text( + DateFormat('E').format(date), + style: (isSelected + ? UiTypography.footnote2m.white + : UiTypography.footnote2m.textSecondary) + .copyWith( + color: isSelected + ? UiColors.white.withValues(alpha: 0.8) + : null, + ), + ), + if (hasShifts && !isSelected) + Container( + margin: const EdgeInsets.only( + top: UiConstants.space1, + ), + width: 4, + height: 4, + decoration: const BoxDecoration( + color: UiColors.primary, + shape: BoxShape.circle, + ), + ), + ], + ), + ), + ], + ), + ); + }).toList(), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart index aeed0436..8476744a 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart @@ -1,18 +1,17 @@ -import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart' show ReadContext; -import 'package:flutter_modular/flutter_modular.dart'; -import 'package:intl/intl.dart'; -import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:staff_shifts/src/domain/utils/shift_date_utils.dart'; import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; -import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart'; -import 'package:staff_shifts/src/presentation/widgets/shift_card/index.dart'; +import 'my_shifts/shift_section_list.dart'; +import 'my_shifts/week_calendar_selector.dart'; /// Tab displaying the worker's assigned, pending, and cancelled shifts. +/// +/// Manages the calendar selection state and delegates rendering to +/// [WeekCalendarSelector] and [ShiftSectionList]. class MyShiftsTab extends StatefulWidget { /// Creates a [MyShiftsTab]. const MyShiftsTab({ @@ -133,270 +132,32 @@ class _MyShiftsTabState extends State { return Column( children: [ - // Calendar Selector - Container( - color: UiColors.white, - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space4, - horizontal: UiConstants.space4, - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space3), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - icon: const Icon( - UiIcons.chevronLeft, - size: 20, - color: UiColors.textPrimary, - ), - onPressed: () => setState(() { - _weekOffset--; - _selectedDate = _getCalendarDays().first; - _loadShiftsForCurrentWeek(); - }), - constraints: const BoxConstraints(), - padding: EdgeInsets.zero, - ), - Text( - DateFormat('MMMM yyyy').format(weekStartDate), - style: UiTypography.title1m.textPrimary, - ), - IconButton( - icon: const Icon( - UiIcons.chevronRight, - size: 20, - color: UiColors.textPrimary, - ), - onPressed: () => setState(() { - _weekOffset++; - _selectedDate = _getCalendarDays().first; - _loadShiftsForCurrentWeek(); - }), - constraints: const BoxConstraints(), - padding: EdgeInsets.zero, - ), - ], - ), - ), - // Days Grid - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: calendarDays.map((DateTime date) { - final bool isSelected = _isSameDay(date, _selectedDate); - final bool hasShifts = widget.myShifts.any( - (AssignedShift s) => _isSameDay(s.date, date), - ); - - return GestureDetector( - onTap: () => setState(() => _selectedDate = date), - child: Column( - children: [ - Container( - width: 44, - height: 60, - decoration: BoxDecoration( - color: isSelected - ? UiColors.primary - : UiColors.white, - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, - ), - border: Border.all( - color: isSelected - ? UiColors.primary - : UiColors.border, - width: 1, - ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - date.day.toString().padLeft(2, '0'), - style: isSelected - ? UiTypography.body1b.white - : UiTypography.body1b.textPrimary, - ), - Text( - DateFormat('E').format(date), - style: (isSelected - ? UiTypography.footnote2m.white - : UiTypography - .footnote2m.textSecondary) - .copyWith( - color: isSelected - ? UiColors.white - .withValues(alpha: 0.8) - : null, - ), - ), - if (hasShifts && !isSelected) - Container( - margin: const EdgeInsets.only( - top: UiConstants.space1, - ), - width: 4, - height: 4, - decoration: const BoxDecoration( - color: UiColors.primary, - shape: BoxShape.circle, - ), - ), - ], - ), - ), - ], - ), - ); - }).toList(), - ), - ], - ), + WeekCalendarSelector( + calendarDays: calendarDays, + selectedDate: _selectedDate, + shifts: widget.myShifts, + onDateSelected: (DateTime date) => + setState(() => _selectedDate = date), + onPreviousWeek: () => setState(() { + _weekOffset--; + _selectedDate = _getCalendarDays().first; + _loadShiftsForCurrentWeek(); + }), + onNextWeek: () => setState(() { + _weekOffset++; + _selectedDate = _getCalendarDays().first; + _loadShiftsForCurrentWeek(); + }), ), const Divider(height: 1, color: UiColors.border), - - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space5, - ), - child: Column( - children: [ - const SizedBox(height: UiConstants.space5), - if (widget.pendingAssignments.isNotEmpty) ...[ - _buildSectionHeader( - context - .t.staff_shifts.my_shifts_tab.sections.awaiting, - UiColors.textWarning, - ), - ...widget.pendingAssignments.map( - (PendingAssignment assignment) => Padding( - padding: const EdgeInsets.only( - bottom: UiConstants.space4, - ), - child: ShiftCard( - data: ShiftCardData.fromPending(assignment), - onTap: () => Modular.to - .toShiftDetailsById(assignment.shiftId), - ), - ), - ), - const SizedBox(height: UiConstants.space3), - ], - - if (visibleCancelledShifts.isNotEmpty) ...[ - _buildSectionHeader( - context - .t.staff_shifts.my_shifts_tab.sections.cancelled, - UiColors.textSecondary, - ), - ...visibleCancelledShifts.map( - (CancelledShift cs) => Padding( - padding: const EdgeInsets.only( - bottom: UiConstants.space4, - ), - child: ShiftCard( - data: ShiftCardData.fromCancelled(cs), - onTap: () => - Modular.to.toShiftDetailsById(cs.shiftId), - ), - ), - ), - const SizedBox(height: UiConstants.space3), - ], - - // Confirmed Shifts - if (visibleMyShifts.isNotEmpty) ...[ - _buildSectionHeader( - context - .t.staff_shifts.my_shifts_tab.sections.confirmed, - UiColors.textSecondary, - ), - ...visibleMyShifts.map( - (AssignedShift shift) { - final bool isCompleted = - shift.status == AssignmentStatus.completed; - final bool isSubmitted = - widget.submittedShiftIds.contains(shift.shiftId); - final bool isSubmitting = - widget.submittingShiftId == shift.shiftId; - - return Padding( - padding: const EdgeInsets.only( - bottom: UiConstants.space3, - ), - child: ShiftCard( - data: ShiftCardData.fromAssigned(shift), - onTap: () => Modular.to - .toShiftDetailsById(shift.shiftId), - showApprovalAction: isCompleted, - isSubmitted: isSubmitted, - isSubmitting: isSubmitting, - onSubmitForApproval: () { - ReadContext(context).read().add( - SubmitForApprovalEvent( - shiftId: shift.shiftId, - ), - ); - UiSnackbar.show( - context, - message: context.t.staff_shifts - .my_shift_card.timesheet_submitted, - type: UiSnackbarType.success, - ); - }, - ), - ); - }, - ), - ], - - if (visibleMyShifts.isEmpty && - widget.pendingAssignments.isEmpty && - widget.cancelledShifts.isEmpty) - EmptyStateView( - icon: UiIcons.calendar, - title: - context.t.staff_shifts.my_shifts_tab.empty.title, - subtitle: context - .t.staff_shifts.my_shifts_tab.empty.subtitle, - ), - - const SizedBox(height: UiConstants.space32), - ], - ), - ), + ShiftSectionList( + assignedShifts: visibleMyShifts, + pendingAssignments: widget.pendingAssignments, + cancelledShifts: visibleCancelledShifts, + submittedShiftIds: widget.submittedShiftIds, + submittingShiftId: widget.submittingShiftId, ), ], ); } - - Widget _buildSectionHeader(String title, Color dotColor) { - return Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space4), - child: Row( - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: dotColor, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: UiConstants.space2), - Text( - title, - style: (dotColor == UiColors.textSecondary - ? UiTypography.body2b.textSecondary - : UiTypography.body2b.copyWith(color: dotColor)), - ), - ], - ), - ); - } } From bd2d5610b3ab73939ce646348c5af0e6c35619a2 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 16:36:35 -0400 Subject: [PATCH 17/24] feat: Add cancellation reason handling and display in shift details --- .../lib/src/l10n/en.i18n.json | 3 +- .../lib/src/l10n/es.i18n.json | 3 +- .../lib/src/entities/shifts/shift_detail.dart | 7 ++ .../pages/shift_details_page.dart | 10 +++ .../cancellation_reason_banner.dart | 70 +++++++++++++++++++ 5 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/cancellation_reason_banner.dart diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 58dc4742..54a98264 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -1365,7 +1365,8 @@ "shift_accepted": "Shift accepted successfully!", "shift_declined_success": "Shift declined", "complete_account_title": "Complete Your Account", - "complete_account_description": "Complete your account to book this shift and start earning" + "complete_account_description": "Complete your account to book this shift and start earning", + "shift_cancelled": "Shift Cancelled" }, "my_shift_card": { "submit_for_approval": "Submit for Approval", diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 96fa838f..199f4baf 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -1360,7 +1360,8 @@ "shift_accepted": "¡Turno aceptado con éxito!", "shift_declined_success": "Turno rechazado", "complete_account_title": "Completa Tu Cuenta", - "complete_account_description": "Completa tu cuenta para reservar este turno y comenzar a ganar" + "complete_account_description": "Completa tu cuenta para reservar este turno y comenzar a ganar", + "shift_cancelled": "Turno Cancelado" }, "my_shift_card": { "submit_for_approval": "Enviar para Aprobación", diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart index 38e2dc23..e8aac075 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart @@ -41,6 +41,7 @@ class ShiftDetail extends Equatable { required this.allowClockInOverride, this.geofenceRadiusMeters, this.nfcTagId, + this.cancellationReason, }); /// Deserialises from the V2 API JSON response. @@ -76,6 +77,7 @@ class ShiftDetail extends Equatable { allowClockInOverride: json['allowClockInOverride'] as bool? ?? false, geofenceRadiusMeters: json['geofenceRadiusMeters'] as int?, nfcTagId: json['nfcTagId'] as String?, + cancellationReason: json['cancellationReason'] as String?, ); } @@ -157,6 +159,9 @@ class ShiftDetail extends Equatable { /// NFC tag identifier for NFC-based clock-in. final String? nfcTagId; + /// Reason the shift was cancelled, if applicable. + final String? cancellationReason; + /// Duration of the shift in hours. double get durationHours { return endTime.difference(startTime).inMinutes / 60; @@ -194,6 +199,7 @@ class ShiftDetail extends Equatable { 'allowClockInOverride': allowClockInOverride, 'geofenceRadiusMeters': geofenceRadiusMeters, 'nfcTagId': nfcTagId, + 'cancellationReason': cancellationReason, }; } @@ -225,5 +231,6 @@ class ShiftDetail extends Equatable { allowClockInOverride, geofenceRadiusMeters, nfcTagId, + cancellationReason, ]; } 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 26f72047..4d3d8d85 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 @@ -12,6 +12,7 @@ import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_ import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_state.dart'; import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_date_time_section.dart'; import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_description_section.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_details/cancellation_reason_banner.dart'; import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart'; import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_details_header.dart'; import 'package:staff_shifts/src/presentation/widgets/shift_details_page_skeleton.dart'; @@ -117,6 +118,15 @@ class _ShiftDetailsPageState extends State { icon: UiIcons.sparkles, ), ), + if (detail.assignmentStatus == + AssignmentStatus.cancelled && + detail.cancellationReason != null && + detail.cancellationReason!.isNotEmpty) + CancellationReasonBanner( + reason: detail.cancellationReason!, + titleLabel: context.t.staff_shifts.shift_details + .shift_cancelled, + ), ShiftDetailsHeader(detail: detail), const Divider(height: 1, thickness: 0.5), ShiftStatsRow( diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/cancellation_reason_banner.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/cancellation_reason_banner.dart new file mode 100644 index 00000000..7550b07e --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/cancellation_reason_banner.dart @@ -0,0 +1,70 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A banner displaying the cancellation reason for a cancelled shift. +/// +/// Uses error styling to draw attention to the cancellation without being +/// overly alarming. Shown at the top of the shift details page when the +/// shift has been cancelled with a reason. +class CancellationReasonBanner extends StatelessWidget { + /// Creates a [CancellationReasonBanner]. + const CancellationReasonBanner({ + super.key, + required this.reason, + required this.titleLabel, + }); + + /// The cancellation reason text. + final String reason; + + /// Localized title label (e.g., "Shift Cancelled"). + final String titleLabel; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + vertical: UiConstants.space4, + ), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.tagError, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all( + color: UiColors.error.withValues(alpha: 0.3), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + UiIcons.error, + color: UiColors.error, + size: UiConstants.iconMd, + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + titleLabel, + style: UiTypography.body2b.copyWith(color: UiColors.error), + ), + const SizedBox(height: UiConstants.space1), + Text( + reason, + style: UiTypography.body3r.textPrimary, + ), + ], + ), + ), + ], + ), + ), + ); + } +} From a544b051cc8a6112f4f748b943f077175d5952c3 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 16:40:07 -0400 Subject: [PATCH 18/24] feat: Update shift card styles and remove cancellation reason display --- .../widgets/shift_card/shift_card_body.dart | 10 ---------- .../widgets/shift_card/shift_card_status_badge.dart | 4 ++-- .../widgets/tabs/my_shifts/shift_section_list.dart | 2 +- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart index 0816d430..afad825c 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart @@ -26,16 +26,6 @@ class ShiftCardBody extends StatelessWidget { ShiftCardTitleRow(data: data), const SizedBox(height: UiConstants.space2), ShiftCardMetadataRows(data: data), - if (data.cancellationReason != null && - data.cancellationReason!.isNotEmpty) ...[ - const SizedBox(height: UiConstants.space1), - Text( - data.cancellationReason!, - style: UiTypography.footnote2r.textSecondary, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], ], ), ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_status_badge.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_status_badge.dart index 85465ea3..0aac92ed 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_status_badge.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_status_badge.dart @@ -70,8 +70,8 @@ class ShiftCardStatusBadge extends StatelessWidget { case ShiftCardVariant.cancelled: return ShiftCardStatusStyle( label: context.t.staff_shifts.my_shifts_tab.card.cancelled, - foreground: UiColors.destructive, - dot: UiColors.destructive, + foreground: UiColors.mutedForeground, + dot: UiColors.mutedForeground, ); case ShiftCardVariant.completed: return ShiftCardStatusStyle( diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/shift_section_list.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/shift_section_list.dart index eb41d04a..f53681ab 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/shift_section_list.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/shift_section_list.dart @@ -101,7 +101,7 @@ class ShiftSectionList extends StatelessWidget { SectionHeader( title: context.t.staff_shifts.my_shifts_tab.sections.confirmed, - dotColor: UiColors.textSecondary, + dotColor: UiColors.textSuccess, ), ...assignedShifts.map( (AssignedShift shift) { From 591b5d7b88710b16fde5f212c6592bd16da25c38 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 16:44:54 -0400 Subject: [PATCH 19/24] feat: Enhance CancelledShift and PendingAssignment models with additional fields for client and pay details --- .../src/entities/shifts/cancelled_shift.dart | 56 +++++++++++++++++++ .../entities/shifts/pending_assignment.dart | 35 ++++++++++++ 2 files changed, 91 insertions(+) diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/cancelled_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/cancelled_shift.dart index d2cff728..99488dbc 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/cancelled_shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/cancelled_shift.dart @@ -15,6 +15,14 @@ class CancelledShift extends Equatable { required this.location, required this.date, this.cancellationReason, + this.roleName, + this.clientName, + this.startTime, + this.endTime, + this.hourlyRateCents, + this.hourlyRate, + this.totalRateCents, + this.totalRate, }); /// Deserialises from the V2 API JSON response. @@ -26,6 +34,14 @@ class CancelledShift extends Equatable { location: json['location'] as String? ?? '', date: parseUtcToLocal(json['date'] as String), cancellationReason: json['cancellationReason'] as String?, + roleName: json['roleName'] as String?, + clientName: json['clientName'] as String?, + startTime: tryParseUtcToLocal(json['startTime'] as String?), + endTime: tryParseUtcToLocal(json['endTime'] as String?), + hourlyRateCents: json['hourlyRateCents'] as int?, + hourlyRate: (json['hourlyRate'] as num?)?.toDouble(), + totalRateCents: json['totalRateCents'] as int?, + totalRate: (json['totalRate'] as num?)?.toDouble(), ); } @@ -47,6 +63,30 @@ class CancelledShift extends Equatable { /// Reason for cancellation, from assignment metadata. final String? cancellationReason; + /// Display name of the role. + final String? roleName; + + /// Name of the client/business. + final String? clientName; + + /// Scheduled start time. + final DateTime? startTime; + + /// Scheduled end time. + final DateTime? endTime; + + /// Pay rate in cents per hour. + final int? hourlyRateCents; + + /// Pay rate in dollars per hour. + final double? hourlyRate; + + /// Total pay for this shift in cents. + final int? totalRateCents; + + /// Total pay for this shift in dollars. + final double? totalRate; + /// Serialises to JSON. Map toJson() { return { @@ -56,6 +96,14 @@ class CancelledShift extends Equatable { 'location': location, 'date': date.toIso8601String(), 'cancellationReason': cancellationReason, + 'roleName': roleName, + 'clientName': clientName, + 'startTime': startTime?.toIso8601String(), + 'endTime': endTime?.toIso8601String(), + 'hourlyRateCents': hourlyRateCents, + 'hourlyRate': hourlyRate, + 'totalRateCents': totalRateCents, + 'totalRate': totalRate, }; } @@ -67,5 +115,13 @@ class CancelledShift extends Equatable { location, date, cancellationReason, + roleName, + clientName, + startTime, + endTime, + hourlyRateCents, + hourlyRate, + totalRateCents, + totalRate, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/pending_assignment.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/pending_assignment.dart index c96c5810..bf89944f 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/pending_assignment.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/pending_assignment.dart @@ -17,6 +17,11 @@ class PendingAssignment extends Equatable { required this.endTime, required this.location, required this.responseDeadline, + this.clientName, + this.hourlyRateCents, + this.hourlyRate, + this.totalRateCents, + this.totalRate, }); /// Deserialises from the V2 API JSON response. @@ -30,6 +35,11 @@ class PendingAssignment extends Equatable { endTime: parseUtcToLocal(json['endTime'] as String), location: json['location'] as String? ?? '', responseDeadline: parseUtcToLocal(json['responseDeadline'] as String), + clientName: json['clientName'] as String?, + hourlyRateCents: json['hourlyRateCents'] as int?, + hourlyRate: (json['hourlyRate'] as num?)?.toDouble(), + totalRateCents: json['totalRateCents'] as int?, + totalRate: (json['totalRate'] as num?)?.toDouble(), ); } @@ -57,6 +67,21 @@ class PendingAssignment extends Equatable { /// Deadline by which the worker must respond. final DateTime responseDeadline; + /// Name of the client/business. + final String? clientName; + + /// Pay rate in cents per hour. + final int? hourlyRateCents; + + /// Pay rate in dollars per hour. + final double? hourlyRate; + + /// Total pay for this shift in cents. + final int? totalRateCents; + + /// Total pay for this shift in dollars. + final double? totalRate; + /// Serialises to JSON. Map toJson() { return { @@ -68,6 +93,11 @@ class PendingAssignment extends Equatable { 'endTime': endTime.toIso8601String(), 'location': location, 'responseDeadline': responseDeadline.toIso8601String(), + 'clientName': clientName, + 'hourlyRateCents': hourlyRateCents, + 'hourlyRate': hourlyRate, + 'totalRateCents': totalRateCents, + 'totalRate': totalRate, }; } @@ -81,5 +111,10 @@ class PendingAssignment extends Equatable { endTime, location, responseDeadline, + clientName, + hourlyRateCents, + hourlyRate, + totalRateCents, + totalRate, ]; } From 0ff2949c1e24f26db82a1ac2bb1b7d88b083a0b6 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 21:13:35 -0400 Subject: [PATCH 20/24] feat: Refactor ShiftCard components to include client name and improve layout consistency --- .../widgets/available_order_card.dart | 2 +- .../widgets/shift_card/shift_card_body.dart | 47 +++---- .../widgets/shift_card/shift_card_data.dart | 10 ++ .../shift_card/shift_card_metadata_rows.dart | 66 ++++++---- .../shift_card/shift_card_title_row.dart | 115 +++++++++--------- 5 files changed, 125 insertions(+), 115 deletions(-) diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart index 351f99e1..42fc4b60 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/available_order_card.dart @@ -135,7 +135,7 @@ class AvailableOrderCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Role name + pay headline + chevron + // Role name + pay headline Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, spacing: UiConstants.space1, diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart index afad825c..7573f53c 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart @@ -14,27 +14,23 @@ class ShiftCardBody extends StatelessWidget { @override Widget build(BuildContext context) { - return Row( + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ShiftCardIcon(variant: data.variant), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShiftCardTitleRow(data: data), - const SizedBox(height: UiConstants.space2), - ShiftCardMetadataRows(data: data), - ], - ), + Row( + children: [ + ShiftCardIcon(variant: data.variant), + ShiftCardTitleRow(data: data), + ], ), + const SizedBox(height: UiConstants.space2), + ShiftCardMetadataRows(data: data), ], ); } } -/// The 44x44 icon box with a gradient background. +/// The icon box matching the AvailableOrderCard style. class ShiftCardIcon extends StatelessWidget { /// Creates a [ShiftCardIcon]. const ShiftCardIcon({super.key, required this.variant}); @@ -47,30 +43,19 @@ class ShiftCardIcon extends StatelessWidget { final bool isCancelled = variant == ShiftCardVariant.cancelled; return Container( - width: 44, - height: 44, + width: UiConstants.space10, + height: UiConstants.space10, decoration: BoxDecoration( - gradient: isCancelled - ? null - : LinearGradient( - colors: [ - UiColors.primary.withValues(alpha: 0.09), - UiColors.primary.withValues(alpha: 0.03), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - color: isCancelled ? UiColors.primary.withValues(alpha: 0.05) : null, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: isCancelled - ? null - : Border.all(color: UiColors.primary.withValues(alpha: 0.09)), + color: isCancelled + ? UiColors.primary.withValues(alpha: 0.05) + : UiColors.tagInProgress, + borderRadius: UiConstants.radiusLg, ), child: const Center( child: Icon( UiIcons.briefcase, color: UiColors.primary, - size: UiConstants.iconMd, + size: UiConstants.space5, ), ), ); diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_data.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_data.dart index 626ff583..2e029ffa 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_data.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_data.dart @@ -38,6 +38,7 @@ class ShiftCardData { required this.date, required this.variant, this.subtitle, + this.clientName, this.startTime, this.endTime, this.hourlyRateCents, @@ -57,9 +58,12 @@ class ShiftCardData { subtitle: shift.location, location: shift.location, date: shift.date, + clientName: shift.clientName, startTime: shift.startTime, endTime: shift.endTime, hourlyRateCents: shift.hourlyRateCents, + hourlyRate: shift.hourlyRate, + totalRate: shift.totalRate, orderType: shift.orderType, variant: _variantFromAssignmentStatus(shift.status), ); @@ -73,6 +77,7 @@ class ShiftCardData { subtitle: shift.title.isNotEmpty ? shift.title : null, location: shift.location, date: shift.date, + clientName: shift.clientName, startTime: shift.startTime, endTime: shift.endTime, hourlyRateCents: shift.hourlyRateCents, @@ -91,6 +96,7 @@ class ShiftCardData { title: shift.title, location: shift.location, date: shift.date, + clientName: shift.clientName, cancellationReason: shift.cancellationReason, variant: ShiftCardVariant.cancelled, ); @@ -104,6 +110,7 @@ class ShiftCardData { subtitle: assignment.title.isNotEmpty ? assignment.title : null, location: assignment.location, date: assignment.startTime, + clientName: assignment.clientName, startTime: assignment.startTime, endTime: assignment.endTime, variant: ShiftCardVariant.pending, @@ -119,6 +126,9 @@ class ShiftCardData { /// Optional secondary text (e.g. location under the role name). final String? subtitle; + /// Client/business name. + final String? clientName; + /// Human-readable location label. final String location; diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_metadata_rows.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_metadata_rows.dart index df0ce572..c7416145 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_metadata_rows.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_metadata_rows.dart @@ -4,7 +4,10 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart'; -/// Date, time, location, and worked-hours rows. +/// Date, client name, location, and worked-hours metadata rows. +/// +/// Follows the AvailableOrderCard element ordering: +/// date -> client name -> location. class ShiftCardMetadataRows extends StatelessWidget { /// Creates a [ShiftCardMetadataRows]. const ShiftCardMetadataRows({super.key, required this.data}); @@ -15,62 +18,71 @@ class ShiftCardMetadataRows extends StatelessWidget { @override Widget build(BuildContext context) { return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Date and time row + // Date row (with optional worked duration for completed shifts). Row( children: [ const Icon( UiIcons.calendar, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, + size: UiConstants.space3, + color: UiColors.mutedForeground, ), const SizedBox(width: UiConstants.space1), Text( _formatDate(context, data.date), - style: UiTypography.footnote1r.textSecondary, + style: UiTypography.body3r.textSecondary, ), - if (data.startTime != null && data.endTime != null) ...[ - const SizedBox(width: UiConstants.space3), - const Icon( - UiIcons.clock, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space1), - Text( - '${_formatTime(data.startTime!)} - ${_formatTime(data.endTime!)}', - style: UiTypography.footnote1r.textSecondary, - ), - ], if (data.minutesWorked != null) ...[ const SizedBox(width: UiConstants.space3), const Icon( UiIcons.clock, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, + size: UiConstants.space3, + color: UiColors.mutedForeground, ), const SizedBox(width: UiConstants.space1), Text( _formatWorkedDuration(data.minutesWorked!), - style: UiTypography.footnote1r.textSecondary, + style: UiTypography.body3r.textSecondary, ), ], ], ), + // Client name row. + if (data.clientName != null && data.clientName!.isNotEmpty) ...[ + const SizedBox(height: UiConstants.space1), + Row( + children: [ + const Icon( + UiIcons.building, + size: UiConstants.space3, + color: UiColors.mutedForeground, + ), + const SizedBox(width: UiConstants.space1), + Expanded( + child: Text( + data.clientName!, + style: UiTypography.body3r.textSecondary, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + // Location row. const SizedBox(height: UiConstants.space1), - // Location row Row( children: [ const Icon( UiIcons.mapPin, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, + size: UiConstants.space3, + color: UiColors.mutedForeground, ), const SizedBox(width: UiConstants.space1), Expanded( child: Text( data.location, - style: UiTypography.footnote1r.textSecondary, + style: UiTypography.body3r.textSecondary, overflow: TextOverflow.ellipsis, ), ), @@ -80,6 +92,7 @@ class ShiftCardMetadataRows extends StatelessWidget { ); } + /// Formats [date] relative to today/tomorrow, or as "EEE, MMM d". String _formatDate(BuildContext context, DateTime date) { final DateTime now = DateTime.now(); final DateTime today = DateTime(now.year, now.month, now.day); @@ -92,8 +105,7 @@ class ShiftCardMetadataRows extends StatelessWidget { return DateFormat('EEE, MMM d').format(date); } - String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt); - + /// Formats total minutes worked into a "Xh Ym" string. String _formatWorkedDuration(int totalMinutes) { final int hours = totalMinutes ~/ 60; final int mins = totalMinutes % 60; diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_title_row.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_title_row.dart index f6b18b07..77c2ac4c 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_title_row.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_title_row.dart @@ -1,8 +1,10 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart'; -/// Title row with optional pay summary on the right. +/// Title row showing role name + pay headline, with a time subtitle + pay detail +/// row below. Matches the AvailableOrderCard layout. class ShiftCardTitleRow extends StatelessWidget { /// Creates a [ShiftCardTitleRow]. const ShiftCardTitleRow({super.key, required this.data}); @@ -12,77 +14,78 @@ class ShiftCardTitleRow extends StatelessWidget { @override Widget build(BuildContext context) { + // Determine if we have enough data to show pay information. final bool hasDirectRate = data.hourlyRate != null && data.hourlyRate! > 0; final bool hasComputedRate = data.hourlyRateCents != null && data.startTime != null && data.endTime != null; + final bool hasPay = hasDirectRate || hasComputedRate; - if (!hasDirectRate && !hasComputedRate) { - return Text( - data.title, - style: UiTypography.body2m.textPrimary, - overflow: TextOverflow.ellipsis, - ); + // Compute pay values when available. + double hourlyRate = 0; + double estimatedTotal = 0; + double durationHours = 0; + + if (hasPay) { + if (hasDirectRate && data.totalRate != null && data.totalRate! > 0) { + hourlyRate = data.hourlyRate!; + estimatedTotal = data.totalRate!; + durationHours = hourlyRate > 0 ? (estimatedTotal / hourlyRate) : 0; + } else if (hasComputedRate) { + hourlyRate = data.hourlyRateCents! / 100; + final int durationMinutes = + data.endTime!.difference(data.startTime!).inMinutes; + double hours = durationMinutes / 60; + if (hours < 0) hours += 24; + durationHours = hours.roundToDouble(); + estimatedTotal = hourlyRate * durationHours; + } } - // Prefer pre-computed values from the API when available. - final double hourlyRate; - final double estimatedTotal; - final double durationHours; - - if (hasDirectRate && data.totalRate != null && data.totalRate! > 0) { - hourlyRate = data.hourlyRate!; - estimatedTotal = data.totalRate!; - durationHours = hourlyRate > 0 ? (estimatedTotal / hourlyRate) : 0; - } else { - hourlyRate = data.hourlyRateCents! / 100; - final int durationMinutes = data.endTime! - .difference(data.startTime!) - .inMinutes; - double hours = durationMinutes / 60; - if (hours < 0) hours += 24; - durationHours = hours.roundToDouble(); - estimatedTotal = hourlyRate * durationHours; - } - - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( + // Row 1: Title + Pay headline + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: UiConstants.space1, + children: [ + Flexible( + child: Text( data.title, - style: UiTypography.body2m.textPrimary, + style: UiTypography.body1m.textPrimary, overflow: TextOverflow.ellipsis, ), - if (data.subtitle != null) ...[ - Text( - data.subtitle!, - style: UiTypography.body3r.textSecondary, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - const SizedBox(width: UiConstants.space3), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '\$${estimatedTotal.toStringAsFixed(0)}', - style: UiTypography.title1m.textPrimary, - ), - Text( - '\$${hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h', - style: UiTypography.footnote2r.textSecondary, ), + if (hasPay) + Text( + '\$${estimatedTotal.toStringAsFixed(0)}', + style: UiTypography.title1m.textPrimary, + ), ], ), + // Row 2: Time subtitle + pay detail + if (data.startTime != null && data.endTime != null) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: UiConstants.space1, + children: [ + Text( + '${_formatTime(data.startTime!)} - ${_formatTime(data.endTime!)}', + style: UiTypography.body3r.textSecondary, + ), + if (hasPay) + Text( + '\$${hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h', + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), ], ); } + + /// Formats a [DateTime] to a compact time string like "3:30pm". + String _formatTime(DateTime dt) => DateFormat('h:mma').format(dt).toLowerCase(); } From 207831eb3e76075631b7d13b7d5c1e45301050a6 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 21:15:52 -0400 Subject: [PATCH 21/24] feat: Adjust layout in ShiftCardBody to improve icon and title alignment --- .../presentation/widgets/shift_card/shift_card_body.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart index 7573f53c..21abde9e 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card/shift_card_body.dart @@ -18,9 +18,11 @@ class ShiftCardBody extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - children: [ + crossAxisAlignment: CrossAxisAlignment.start, + children: [ ShiftCardIcon(variant: data.variant), - ShiftCardTitleRow(data: data), + const SizedBox(width: UiConstants.space3), + Expanded(child: ShiftCardTitleRow(data: data)), ], ), const SizedBox(height: UiConstants.space2), From 4cd83a92811cf57bcde0f0468d69fa9097ceeb95 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 23:34:29 -0400 Subject: [PATCH 22/24] feat: Implement UTC conversion for order date and time serialization in order use cases --- .../core/lib/src/utils/time_utils.dart | 42 +++++++++++++++ .../arguments/one_time_order_arguments.dart | 33 ++++++++++++ .../arguments/permanent_order_arguments.dart | 45 ++++++++++++++++ .../arguments/recurring_order_arguments.dart | 47 ++++++++++++++++ .../create_one_time_order_usecase.dart | 40 ++------------ .../create_permanent_order_usecase.dart | 52 ++---------------- .../create_recurring_order_usecase.dart | 54 ++----------------- .../view_orders_repository_impl.dart | 4 +- .../shifts_repository_impl.dart | 4 +- 9 files changed, 186 insertions(+), 135 deletions(-) diff --git a/apps/mobile/packages/core/lib/src/utils/time_utils.dart b/apps/mobile/packages/core/lib/src/utils/time_utils.dart index 0f5b7d8c..f8e25eb2 100644 --- a/apps/mobile/packages/core/lib/src/utils/time_utils.dart +++ b/apps/mobile/packages/core/lib/src/utils/time_utils.dart @@ -68,3 +68,45 @@ String formatTime(String timeStr) { } } } + +/// Converts a local date + local HH:MM time string to a UTC HH:MM string. +/// +/// Combines [localDate] with the hours and minutes from [localTime] (e.g. +/// "09:00") to create a full local [DateTime], converts it to UTC, then +/// extracts the HH:MM portion. +/// +/// Example: March 19, "21:00" in UTC-5 → "02:00" (next day UTC). +String toUtcTimeHHmm(DateTime localDate, String localTime) { + final List parts = localTime.split(':'); + final DateTime localDt = DateTime( + localDate.year, + localDate.month, + localDate.day, + int.parse(parts[0]), + int.parse(parts[1]), + ); + final DateTime utcDt = localDt.toUtc(); + return '${utcDt.hour.toString().padLeft(2, '0')}:' + '${utcDt.minute.toString().padLeft(2, '0')}'; +} + +/// Converts a local date + local HH:MM time string to a UTC YYYY-MM-DD string. +/// +/// This accounts for date-boundary crossings: a shift at 11 PM on March 19 +/// in UTC-5 is actually March 20 in UTC. +/// +/// Example: March 19, "23:00" in UTC-5 → "2026-03-20". +String toUtcDateIso(DateTime localDate, String localTime) { + final List parts = localTime.split(':'); + final DateTime localDt = DateTime( + localDate.year, + localDate.month, + localDate.day, + int.parse(parts[0]), + int.parse(parts[1]), + ); + final DateTime utcDt = localDt.toUtc(); + return '${utcDt.year.toString().padLeft(4, '0')}-' + '${utcDt.month.toString().padLeft(2, '0')}-' + '${utcDt.day.toString().padLeft(2, '0')}'; +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart index 890fbeaf..308e74b1 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart @@ -63,6 +63,39 @@ class OneTimeOrderArguments extends UseCaseArgument { /// The selected vendor ID, if applicable. final String? vendorId; + /// Serialises these arguments into the V2 API payload shape. + /// + /// Times and dates are converted to UTC so the backend's + /// `combineDateAndTime` helper receives the correct values. + Map toJson() { + final String firstStartTime = + positions.isNotEmpty ? positions.first.startTime : '00:00'; + final String utcOrderDate = toUtcDateIso(orderDate, firstStartTime); + + final List> positionsList = + positions.map((OneTimeOrderPositionArgument p) { + return { + if (p.roleName != null) 'roleName': p.roleName, + if (p.roleId.isNotEmpty) 'roleId': p.roleId, + 'workerCount': p.workerCount, + 'startTime': toUtcTimeHHmm(orderDate, p.startTime), + 'endTime': toUtcTimeHHmm(orderDate, p.endTime), + if (p.lunchBreak != null && + p.lunchBreak != 'NO_BREAK' && + p.lunchBreak!.isNotEmpty) + 'lunchBreakMinutes': breakMinutesFromLabel(p.lunchBreak!), + }; + }).toList(); + + return { + 'hubId': hubId, + 'eventName': eventName, + 'orderDate': utcOrderDate, + 'positions': positionsList, + if (vendorId != null) 'vendorId': vendorId, + }; + } + @override List get props => [hubId, eventName, orderDate, positions, vendorId]; diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart index fb19864e..859097fd 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart @@ -63,6 +63,51 @@ class PermanentOrderArguments extends UseCaseArgument { /// The selected vendor ID, if applicable. final String? vendorId; + /// Day-of-week labels in Sunday-first order, matching the V2 API convention. + static const List _dayLabels = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + + /// Serialises these arguments into the V2 API payload shape. + /// + /// Times and dates are converted to UTC so the backend's + /// `combineDateAndTime` helper receives the correct values. + Map toJson() { + final String firstStartTime = + positions.isNotEmpty ? positions.first.startTime : '00:00'; + final String utcStartDate = toUtcDateIso(startDate, firstStartTime); + + final List daysOfWeekList = daysOfWeek + .map((String day) => _dayLabels.indexOf(day) % 7) + .toList(); + + final List> positionsList = + positions.map((PermanentOrderPositionArgument p) { + return { + if (p.roleName != null) 'roleName': p.roleName, + if (p.roleId.isNotEmpty) 'roleId': p.roleId, + 'workerCount': p.workerCount, + 'startTime': toUtcTimeHHmm(startDate, p.startTime), + 'endTime': toUtcTimeHHmm(startDate, p.endTime), + }; + }).toList(); + + return { + 'hubId': hubId, + 'eventName': eventName, + 'startDate': utcStartDate, + 'daysOfWeek': daysOfWeekList, + 'positions': positionsList, + if (vendorId != null) 'vendorId': vendorId, + }; + } + @override List get props => [ hubId, diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart index 01999078..ef219e07 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart @@ -67,6 +67,53 @@ class RecurringOrderArguments extends UseCaseArgument { /// The selected vendor ID, if applicable. final String? vendorId; + /// Day-of-week labels in Sunday-first order, matching the V2 API convention. + static const List _dayLabels = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + + /// Serialises these arguments into the V2 API payload shape. + /// + /// Times and dates are converted to UTC so the backend's + /// `combineDateAndTime` helper receives the correct values. + Map toJson() { + final String firstStartTime = + positions.isNotEmpty ? positions.first.startTime : '00:00'; + final String utcStartDate = toUtcDateIso(startDate, firstStartTime); + final String utcEndDate = toUtcDateIso(endDate, firstStartTime); + + final List recurrenceDaysList = recurringDays + .map((String day) => _dayLabels.indexOf(day) % 7) + .toList(); + + final List> positionsList = + positions.map((RecurringOrderPositionArgument p) { + return { + if (p.roleName != null) 'roleName': p.roleName, + if (p.roleId.isNotEmpty) 'roleId': p.roleId, + 'workerCount': p.workerCount, + 'startTime': toUtcTimeHHmm(startDate, p.startTime), + 'endTime': toUtcTimeHHmm(startDate, p.endTime), + }; + }).toList(); + + return { + 'hubId': hubId, + 'eventName': eventName, + 'startDate': utcStartDate, + 'endDate': utcEndDate, + 'recurrenceDays': recurrenceDaysList, + 'positions': positionsList, + if (vendorId != null) 'vendorId': vendorId, + }; + } + @override List get props => [ hubId, diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart index f74c4b63..eea3fdbc 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart @@ -1,49 +1,19 @@ -import 'package:krow_core/core.dart'; - import '../arguments/one_time_order_arguments.dart'; import '../repositories/client_create_order_repository_interface.dart'; /// Use case for creating a one-time staffing order. /// -/// Builds the V2 API payload from typed [OneTimeOrderArguments] and -/// delegates submission to the repository. Payload construction (date -/// formatting, position mapping, break-minutes conversion) is business -/// logic that belongs here, not in the BLoC. -class CreateOneTimeOrderUseCase - implements UseCase { +/// Delegates payload construction to [OneTimeOrderArguments.toJson] and +/// submission to the repository. +class CreateOneTimeOrderUseCase { /// Creates a [CreateOneTimeOrderUseCase]. const CreateOneTimeOrderUseCase(this._repository); /// The create-order repository. final ClientCreateOrderRepositoryInterface _repository; - @override + /// Creates a one-time order from the given arguments. Future call(OneTimeOrderArguments input) { - final String orderDate = formatDateToIso(input.orderDate); - - final List> positions = - input.positions.map((OneTimeOrderPositionArgument p) { - return { - if (p.roleName != null) 'roleName': p.roleName, - if (p.roleId.isNotEmpty) 'roleId': p.roleId, - 'workerCount': p.workerCount, - 'startTime': p.startTime, - 'endTime': p.endTime, - if (p.lunchBreak != null && - p.lunchBreak != 'NO_BREAK' && - p.lunchBreak!.isNotEmpty) - 'lunchBreakMinutes': breakMinutesFromLabel(p.lunchBreak!), - }; - }).toList(); - - final Map payload = { - 'hubId': input.hubId, - 'eventName': input.eventName, - 'orderDate': orderDate, - 'positions': positions, - if (input.vendorId != null) 'vendorId': input.vendorId, - }; - - return _repository.createOneTimeOrder(payload); + return _repository.createOneTimeOrder(input.toJson()); } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart index e33163d9..970ea149 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart @@ -1,61 +1,19 @@ -import 'package:krow_core/core.dart'; - import '../arguments/permanent_order_arguments.dart'; import '../repositories/client_create_order_repository_interface.dart'; -/// Day-of-week labels in Sunday-first order, matching the V2 API convention. -const List _dayLabels = [ - 'SUN', - 'MON', - 'TUE', - 'WED', - 'THU', - 'FRI', - 'SAT', -]; - /// Use case for creating a permanent staffing order. /// -/// Builds the V2 API payload from typed [PermanentOrderArguments] and -/// delegates submission to the repository. Payload construction (date -/// formatting, day-of-week mapping, position mapping) is business -/// logic that belongs here, not in the BLoC. -class CreatePermanentOrderUseCase - implements UseCase { +/// Delegates payload construction to [PermanentOrderArguments.toJson] and +/// submission to the repository. +class CreatePermanentOrderUseCase { /// Creates a [CreatePermanentOrderUseCase]. const CreatePermanentOrderUseCase(this._repository); /// The create-order repository. final ClientCreateOrderRepositoryInterface _repository; - @override + /// Creates a permanent order from the given arguments. Future call(PermanentOrderArguments input) { - final String startDate = formatDateToIso(input.startDate); - - final List daysOfWeek = input.daysOfWeek - .map((String day) => _dayLabels.indexOf(day) % 7) - .toList(); - - final List> positions = - input.positions.map((PermanentOrderPositionArgument p) { - return { - if (p.roleName != null) 'roleName': p.roleName, - if (p.roleId.isNotEmpty) 'roleId': p.roleId, - 'workerCount': p.workerCount, - 'startTime': p.startTime, - 'endTime': p.endTime, - }; - }).toList(); - - final Map payload = { - 'hubId': input.hubId, - 'eventName': input.eventName, - 'startDate': startDate, - 'daysOfWeek': daysOfWeek, - 'positions': positions, - if (input.vendorId != null) 'vendorId': input.vendorId, - }; - - return _repository.createPermanentOrder(payload); + return _repository.createPermanentOrder(input.toJson()); } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart index 7bd1232f..48d26c78 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart @@ -1,63 +1,19 @@ -import 'package:krow_core/core.dart'; - import '../arguments/recurring_order_arguments.dart'; import '../repositories/client_create_order_repository_interface.dart'; -/// Day-of-week labels in Sunday-first order, matching the V2 API convention. -const List _dayLabels = [ - 'SUN', - 'MON', - 'TUE', - 'WED', - 'THU', - 'FRI', - 'SAT', -]; - /// Use case for creating a recurring staffing order. /// -/// Builds the V2 API payload from typed [RecurringOrderArguments] and -/// delegates submission to the repository. Payload construction (date -/// formatting, recurrence-day mapping, position mapping) is business -/// logic that belongs here, not in the BLoC. -class CreateRecurringOrderUseCase - implements UseCase { +/// Delegates payload construction to [RecurringOrderArguments.toJson] and +/// submission to the repository. +class CreateRecurringOrderUseCase { /// Creates a [CreateRecurringOrderUseCase]. const CreateRecurringOrderUseCase(this._repository); /// The create-order repository. final ClientCreateOrderRepositoryInterface _repository; - @override + /// Creates a recurring order from the given arguments. Future call(RecurringOrderArguments input) { - final String startDate = formatDateToIso(input.startDate); - final String endDate = formatDateToIso(input.endDate); - - final List recurrenceDays = input.recurringDays - .map((String day) => _dayLabels.indexOf(day) % 7) - .toList(); - - final List> positions = - input.positions.map((RecurringOrderPositionArgument p) { - return { - if (p.roleName != null) 'roleName': p.roleName, - if (p.roleId.isNotEmpty) 'roleId': p.roleId, - 'workerCount': p.workerCount, - 'startTime': p.startTime, - 'endTime': p.endTime, - }; - }).toList(); - - final Map payload = { - 'hubId': input.hubId, - 'eventName': input.eventName, - 'startDate': startDate, - 'endDate': endDate, - 'recurrenceDays': recurrenceDays, - 'positions': positions, - if (input.vendorId != null) 'vendorId': input.vendorId, - }; - - return _repository.createRecurringOrder(payload); + return _repository.createRecurringOrder(input.toJson()); } } diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart index 192b4384..91967d92 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart @@ -22,8 +22,8 @@ class ViewOrdersRepositoryImpl implements ViewOrdersRepositoryInterface { final ApiResponse response = await _api.get( ClientEndpoints.ordersView, params: { - 'startDate': start.toIso8601String(), - 'endDate': end.toIso8601String(), + 'startDate': start.toUtc().toIso8601String(), + 'endDate': end.toUtc().toIso8601String(), }, ); final Map data = response.data as Map; 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 2ade65ba..e5a118af 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 @@ -36,8 +36,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { final ApiResponse response = await _apiService.get( StaffEndpoints.shiftsAssigned, params: { - 'startDate': start.toIso8601String(), - 'endDate': end.toIso8601String(), + 'startDate': start.toUtc().toIso8601String(), + 'endDate': end.toUtc().toIso8601String(), }, ); final List items = _extractItems(response.data); From 1e4c8982a504befdd0334c32f18f73033f21f3f7 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 23:55:57 -0400 Subject: [PATCH 23/24] feat: Add additional fields to OrderItem and update cost calculation in ViewOrderCard --- .../lib/src/entities/orders/order_item.dart | 70 +++++++++++++++++++ .../presentation/widgets/view_order_card.dart | 4 +- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart b/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart index dfcd6072..b064f083 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart @@ -26,6 +26,16 @@ class OrderItem extends Equatable { this.locationName, required this.status, this.workers = const [], + this.eventName = '', + this.clientName = '', + this.hourlyRate = 0.0, + this.hours = 0.0, + this.totalValue = 0.0, + this.locationAddress, + this.startTime, + this.endTime, + this.hubManagerId, + this.hubManagerName, }); /// Deserialises an [OrderItem] from a V2 API JSON map. @@ -53,6 +63,16 @@ class OrderItem extends Equatable { locationName: json['locationName'] as String?, status: ShiftStatus.fromJson(json['status'] as String?), workers: workersList, + eventName: json['eventName'] as String? ?? '', + clientName: json['clientName'] as String? ?? '', + hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0, + hours: (json['hours'] as num?)?.toDouble() ?? 0.0, + totalValue: (json['totalValue'] as num?)?.toDouble() ?? 0.0, + locationAddress: json['locationAddress'] as String?, + startTime: json['startTime'] as String?, + endTime: json['endTime'] as String?, + hubManagerId: json['hubManagerId'] as String?, + hubManagerName: json['hubManagerName'] as String?, ); } @@ -98,6 +118,36 @@ class OrderItem extends Equatable { /// Assigned workers for this line item. final List workers; + /// Event/order name. + final String eventName; + + /// Client/business name. + final String clientName; + + /// Billing rate in dollars per hour. + final double hourlyRate; + + /// Duration of the shift in fractional hours. + final double hours; + + /// Total cost in dollars (rate x workers x hours). + final double totalValue; + + /// Full street address of the location. + final String? locationAddress; + + /// Display start time string (HH:MM UTC). + final String? startTime; + + /// Display end time string (HH:MM UTC). + final String? endTime; + + /// Hub manager's business membership ID. + final String? hubManagerId; + + /// Hub manager's display name. + final String? hubManagerName; + /// Serialises this [OrderItem] to a JSON map. Map toJson() { return { @@ -115,6 +165,16 @@ class OrderItem extends Equatable { 'locationName': locationName, 'status': status.toJson(), 'workers': workers.map((AssignedWorkerSummary w) => w.toJson()).toList(), + 'eventName': eventName, + 'clientName': clientName, + 'hourlyRate': hourlyRate, + 'hours': hours, + 'totalValue': totalValue, + 'locationAddress': locationAddress, + 'startTime': startTime, + 'endTime': endTime, + 'hubManagerId': hubManagerId, + 'hubManagerName': hubManagerName, }; } @@ -134,5 +194,15 @@ class OrderItem extends Equatable { locationName, status, workers, + eventName, + clientName, + hourlyRate, + hours, + totalValue, + locationAddress, + startTime, + endTime, + hubManagerId, + hubManagerName, ]; } diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart index 969aed43..d14a2f94 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart @@ -123,7 +123,9 @@ class _ViewOrderCardState extends State { : 0; final double hours = _computeHours(order); - final double cost = order.totalCostCents / 100.0; + final double cost = order.totalValue > 0 + ? order.totalValue + : order.totalCostCents / 100.0; return Container( decoration: BoxDecoration( From b10ef57d378bb50ba61c71e37d6c7336385ff6dd Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 23:59:00 -0400 Subject: [PATCH 24/24] feat: Add hourly rate field to order position arguments and update related blocs --- .../arguments/one_time_order_arguments.dart | 16 ++++++++++++++-- .../arguments/permanent_order_arguments.dart | 15 +++++++++++++-- .../arguments/recurring_order_arguments.dart | 15 +++++++++++++-- .../one_time_order/one_time_order_bloc.dart | 2 ++ .../permanent_order/permanent_order_bloc.dart | 2 ++ .../recurring_order/recurring_order_bloc.dart | 2 ++ 6 files changed, 46 insertions(+), 6 deletions(-) diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart index 308e74b1..a0e2d189 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart @@ -10,6 +10,7 @@ class OneTimeOrderPositionArgument extends UseCaseArgument { required this.endTime, this.roleName, this.lunchBreak, + this.hourlyRateCents, }); /// The role ID for this position. @@ -30,9 +31,19 @@ class OneTimeOrderPositionArgument extends UseCaseArgument { /// Break duration label (e.g. `'MIN_30'`, `'NO_BREAK'`), if set. final String? lunchBreak; + /// Hourly rate in cents for this position, if set. + final int? hourlyRateCents; + @override - List get props => - [roleId, roleName, workerCount, startTime, endTime, lunchBreak]; + List get props => [ + roleId, + roleName, + workerCount, + startTime, + endTime, + lunchBreak, + hourlyRateCents, + ]; } /// Typed arguments for [CreateOneTimeOrderUseCase]. @@ -84,6 +95,7 @@ class OneTimeOrderArguments extends UseCaseArgument { p.lunchBreak != 'NO_BREAK' && p.lunchBreak!.isNotEmpty) 'lunchBreakMinutes': breakMinutesFromLabel(p.lunchBreak!), + if (p.hourlyRateCents != null) 'hourlyRateCents': p.hourlyRateCents, }; }).toList(); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart index 859097fd..47bcb943 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart @@ -9,6 +9,7 @@ class PermanentOrderPositionArgument extends UseCaseArgument { required this.startTime, required this.endTime, this.roleName, + this.hourlyRateCents, }); /// The role ID for this position. @@ -26,9 +27,18 @@ class PermanentOrderPositionArgument extends UseCaseArgument { /// Shift end time in HH:mm format. final String endTime; + /// Hourly rate in cents for this position, if set. + final int? hourlyRateCents; + @override - List get props => - [roleId, roleName, workerCount, startTime, endTime]; + List get props => [ + roleId, + roleName, + workerCount, + startTime, + endTime, + hourlyRateCents, + ]; } /// Typed arguments for [CreatePermanentOrderUseCase]. @@ -95,6 +105,7 @@ class PermanentOrderArguments extends UseCaseArgument { 'workerCount': p.workerCount, 'startTime': toUtcTimeHHmm(startDate, p.startTime), 'endTime': toUtcTimeHHmm(startDate, p.endTime), + if (p.hourlyRateCents != null) 'hourlyRateCents': p.hourlyRateCents, }; }).toList(); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart index ef219e07..7a340df7 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart @@ -9,6 +9,7 @@ class RecurringOrderPositionArgument extends UseCaseArgument { required this.startTime, required this.endTime, this.roleName, + this.hourlyRateCents, }); /// The role ID for this position. @@ -26,9 +27,18 @@ class RecurringOrderPositionArgument extends UseCaseArgument { /// Shift end time in HH:mm format. final String endTime; + /// Hourly rate in cents for this position, if set. + final int? hourlyRateCents; + @override - List get props => - [roleId, roleName, workerCount, startTime, endTime]; + List get props => [ + roleId, + roleName, + workerCount, + startTime, + endTime, + hourlyRateCents, + ]; } /// Typed arguments for [CreateRecurringOrderUseCase]. @@ -100,6 +110,7 @@ class RecurringOrderArguments extends UseCaseArgument { 'workerCount': p.workerCount, 'startTime': toUtcTimeHHmm(startDate, p.startTime), 'endTime': toUtcTimeHHmm(startDate, p.endTime), + if (p.hourlyRateCents != null) 'hourlyRateCents': p.hourlyRateCents, }; }).toList(); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart index e40aa20f..e7f50954 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart @@ -265,6 +265,8 @@ class OneTimeOrderBloc extends Bloc startTime: p.startTime, endTime: p.endTime, lunchBreak: p.lunchBreak, + hourlyRateCents: + role != null ? (role.costPerHour * 100).round() : null, ); }).toList(); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart index fae8ee4d..ed6f2ac3 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart @@ -360,6 +360,8 @@ class PermanentOrderBloc extends Bloc workerCount: p.count, startTime: p.startTime, endTime: p.endTime, + hourlyRateCents: + role != null ? (role.costPerHour * 100).round() : null, ); }).toList(); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart index ce226789..65a48ff4 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart @@ -380,6 +380,8 @@ class RecurringOrderBloc extends Bloc workerCount: p.count, startTime: p.startTime, endTime: p.endTime, + hourlyRateCents: + role != null ? (role.costPerHour * 100).round() : null, ); }).toList();