From 9c71acb96a2a0a7dab2f5600becb1b0fbbb19f29 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Mar 2026 14:08:34 -0400 Subject: [PATCH] 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(),