From 96056d0170d936cde3c4dbcd54596df8dd8f3ab7 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 13:23:28 -0400 Subject: [PATCH] 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