feat: Implement order details page and navigation for available orders
This commit is contained in:
@@ -112,6 +112,13 @@ extension StaffNavigator on IModularNavigator {
|
|||||||
safeNavigate(StaffPaths.shiftDetails(shift.id), arguments: shift);
|
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).
|
/// 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).
|
/// Used when only the shift ID is available (e.g. from dashboard list items).
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ class StaffPaths {
|
|||||||
/// View detailed information for a specific shift.
|
/// View detailed information for a specific shift.
|
||||||
static const String shiftDetailsRoute = '/worker-main/shift-details';
|
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).
|
/// Shift details page (dynamic).
|
||||||
///
|
///
|
||||||
/// View detailed information for a specific shift.
|
/// View detailed information for a specific shift.
|
||||||
|
|||||||
@@ -1881,14 +1881,26 @@
|
|||||||
"available_orders": {
|
"available_orders": {
|
||||||
"book_order": "Book Order",
|
"book_order": "Book Order",
|
||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
|
"fully_staffed": "Fully Staffed",
|
||||||
"spots_left": "${count} spot(s) left",
|
"spots_left": "${count} spot(s) left",
|
||||||
"shifts_count": "${count} shift(s)",
|
"shifts_count": "${count} shift(s)",
|
||||||
|
"schedule_label": "SCHEDULE",
|
||||||
"booking_success": "Order booked successfully!",
|
"booking_success": "Order booked successfully!",
|
||||||
"booking_pending": "Your booking is pending approval",
|
"booking_pending": "Your booking is pending approval",
|
||||||
"booking_confirmed": "Your booking has been confirmed!",
|
"booking_confirmed": "Your booking has been confirmed!",
|
||||||
"no_orders": "No orders available",
|
"no_orders": "No orders available",
|
||||||
"no_orders_subtitle": "Check back later for new opportunities",
|
"no_orders_subtitle": "Check back later for new opportunities",
|
||||||
"instant_book": "Instant Book",
|
"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!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1881,14 +1881,26 @@
|
|||||||
"available_orders": {
|
"available_orders": {
|
||||||
"book_order": "Reservar Orden",
|
"book_order": "Reservar Orden",
|
||||||
"apply": "Aplicar",
|
"apply": "Aplicar",
|
||||||
|
"fully_staffed": "Completamente dotado",
|
||||||
"spots_left": "${count} puesto(s) disponible(s)",
|
"spots_left": "${count} puesto(s) disponible(s)",
|
||||||
"shifts_count": "${count} turno(s)",
|
"shifts_count": "${count} turno(s)",
|
||||||
|
"schedule_label": "HORARIO",
|
||||||
"booking_success": "\u00a1Orden reservada con \u00e9xito!",
|
"booking_success": "\u00a1Orden reservada con \u00e9xito!",
|
||||||
"booking_pending": "Tu reserva est\u00e1 pendiente de aprobaci\u00f3n",
|
"booking_pending": "Tu reserva est\u00e1 pendiente de aprobaci\u00f3n",
|
||||||
"booking_confirmed": "\u00a1Tu reserva ha sido confirmada!",
|
"booking_confirmed": "\u00a1Tu reserva ha sido confirmada!",
|
||||||
"no_orders": "No hay \u00f3rdenes disponibles",
|
"no_orders": "No hay \u00f3rdenes disponibles",
|
||||||
"no_orders_subtitle": "Vuelve m\u00e1s tarde para nuevas oportunidades",
|
"no_orders_subtitle": "Vuelve m\u00e1s tarde para nuevas oportunidades",
|
||||||
"instant_book": "Reserva Instant\u00e1nea",
|
"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!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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<Module> get imports => <Module>[CoreModule()];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void binds(Injector i) {
|
||||||
|
// Repository
|
||||||
|
i.add<ShiftsRepositoryInterface>(
|
||||||
|
() => ShiftsRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<OrderDetailsPage> createState() => _OrderDetailsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OrderDetailsPageState extends State<OrderDetailsPage> {
|
||||||
|
/// 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<AvailableOrdersBloc>(
|
||||||
|
create: (_) => Modular.get<AvailableOrdersBloc>(),
|
||||||
|
child: BlocConsumer<AvailableOrdersBloc, AvailableOrdersState>(
|
||||||
|
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: <Widget>[
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
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<void>(
|
||||||
|
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: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Modular.to.popSafe(),
|
||||||
|
child: Text(Translations.of(context).common.cancel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Modular.to.popSafe();
|
||||||
|
_showBookingDialog(context);
|
||||||
|
BlocProvider.of<AvailableOrdersBloc>(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<void>(
|
||||||
|
context: context,
|
||||||
|
useRootNavigator: true,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (BuildContext ctx) => AlertDialog(
|
||||||
|
title: Text(t.available_orders.booking_dialog.title),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: <Widget>[
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,30 +104,6 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
|||||||
BlocProvider<ShiftsBloc>.value(value: _bloc),
|
BlocProvider<ShiftsBloc>.value(value: _bloc),
|
||||||
BlocProvider<AvailableOrdersBloc>.value(value: _ordersBloc),
|
BlocProvider<AvailableOrdersBloc>.value(value: _ordersBloc),
|
||||||
],
|
],
|
||||||
child: BlocListener<AvailableOrdersBloc, AvailableOrdersState>(
|
|
||||||
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<ShiftsBloc, ShiftsState>(
|
child: BlocConsumer<ShiftsBloc, ShiftsState>(
|
||||||
listener: (BuildContext context, ShiftsState state) {
|
listener: (BuildContext context, ShiftsState state) {
|
||||||
if (state.status == ShiftsStatus.error &&
|
if (state.status == ShiftsStatus.error &&
|
||||||
@@ -279,7 +255,6 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,12 +286,6 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
|||||||
return FindShiftsTab(
|
return FindShiftsTab(
|
||||||
availableOrders: ordersState.orders,
|
availableOrders: ordersState.orders,
|
||||||
profileComplete: state.profileComplete ?? true,
|
profileComplete: state.profileComplete ?? true,
|
||||||
onBook: (String orderId, String roleId) {
|
|
||||||
_ordersBloc.add(
|
|
||||||
BookOrderEvent(orderId: orderId, roleId: roleId),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
bookingInProgress: ordersState.bookingInProgress,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,25 +7,22 @@ import 'package:krow_domain/krow_domain.dart';
|
|||||||
/// Card displaying an [AvailableOrder] from the staff marketplace.
|
/// Card displaying an [AvailableOrder] from the staff marketplace.
|
||||||
///
|
///
|
||||||
/// Shows role, pay (total + hourly), time, date, client, location,
|
/// 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 {
|
class AvailableOrderCard extends StatelessWidget {
|
||||||
/// Creates an [AvailableOrderCard].
|
/// Creates an [AvailableOrderCard].
|
||||||
const AvailableOrderCard({
|
const AvailableOrderCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.order,
|
required this.order,
|
||||||
required this.onBook,
|
required this.onTap,
|
||||||
this.bookingInProgress = false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The available order to display.
|
/// The available order to display.
|
||||||
final AvailableOrder order;
|
final AvailableOrder order;
|
||||||
|
|
||||||
/// Callback when the user taps book/apply, providing orderId and roleId.
|
/// Callback when the user taps the card.
|
||||||
final void Function(String orderId, String roleId) onBook;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
/// Whether a booking request is currently in progress.
|
|
||||||
final bool bookingInProgress;
|
|
||||||
|
|
||||||
|
/// Formats a DateTime to a time string like "3:30pm".
|
||||||
String _formatTime(DateTime time) {
|
String _formatTime(DateTime time) {
|
||||||
return DateFormat('h:mma').format(time).toLowerCase();
|
return DateFormat('h:mma').format(time).toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -93,7 +90,9 @@ class AvailableOrderCard extends StatelessWidget {
|
|||||||
final String timeRange =
|
final String timeRange =
|
||||||
'${_formatTime(schedule.firstShiftStartsAt)} - ${_formatTime(schedule.lastShiftEndsAt)}';
|
'${_formatTime(schedule.firstShiftStartsAt)} - ${_formatTime(schedule.lastShiftEndsAt)}';
|
||||||
|
|
||||||
return Container(
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: UiColors.white,
|
color: UiColors.white,
|
||||||
@@ -136,7 +135,7 @@ class AvailableOrderCard extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
// Role name + pay headline
|
// Role name + pay headline + chevron
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
spacing: UiConstants.space1,
|
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: <Widget>[
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
spacing: UiConstants.space4,
|
||||||
|
children: <Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
// 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: <Widget>[
|
||||||
|
if (icon != null) ...<Widget>[
|
||||||
|
Icon(icon, size: 10, color: textColor),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: UiTypography.footnote2m.copyWith(color: textColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: <Widget>[
|
||||||
|
Text(
|
||||||
|
scheduleLabel,
|
||||||
|
style: UiTypography.titleUppercase4b.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space3),
|
||||||
|
|
||||||
|
// Days of week chips
|
||||||
|
if (schedule.daysOfWeek.isNotEmpty) ...<Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
/// Tab showing available orders for the worker to browse and book.
|
||||||
///
|
///
|
||||||
/// Replaces the former open-shift listing with order-level marketplace cards.
|
/// Replaces the former open-shift listing with order-level marketplace cards.
|
||||||
|
/// Tapping a card navigates to the order details page.
|
||||||
class FindShiftsTab extends StatefulWidget {
|
class FindShiftsTab extends StatefulWidget {
|
||||||
/// Creates a [FindShiftsTab].
|
/// Creates a [FindShiftsTab].
|
||||||
const FindShiftsTab({
|
const FindShiftsTab({
|
||||||
super.key,
|
super.key,
|
||||||
required this.availableOrders,
|
required this.availableOrders,
|
||||||
this.profileComplete = true,
|
this.profileComplete = true,
|
||||||
required this.onBook,
|
|
||||||
this.bookingInProgress = false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Available orders loaded from the V2 API.
|
/// Available orders loaded from the V2 API.
|
||||||
@@ -26,12 +25,6 @@ class FindShiftsTab extends StatefulWidget {
|
|||||||
/// Whether the worker's profile is complete.
|
/// Whether the worker's profile is complete.
|
||||||
final bool profileComplete;
|
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
|
@override
|
||||||
State<FindShiftsTab> createState() => _FindShiftsTabState();
|
State<FindShiftsTab> createState() => _FindShiftsTabState();
|
||||||
}
|
}
|
||||||
@@ -186,8 +179,7 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
|
|||||||
...filteredOrders.map(
|
...filteredOrders.map(
|
||||||
(AvailableOrder order) => AvailableOrderCard(
|
(AvailableOrder order) => AvailableOrderCard(
|
||||||
order: order,
|
order: order,
|
||||||
onBook: widget.onBook,
|
onTap: () => Modular.to.toOrderDetails(order),
|
||||||
bookingInProgress: widget.bookingInProgress,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space32),
|
const SizedBox(height: UiConstants.space32),
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ library;
|
|||||||
|
|
||||||
export 'src/staff_shifts_module.dart';
|
export 'src/staff_shifts_module.dart';
|
||||||
export 'src/shift_details_module.dart';
|
export 'src/shift_details_module.dart';
|
||||||
|
export 'src/order_details_module.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,10 @@ class StaffMainModule extends Module {
|
|||||||
StaffPaths.childRoute(StaffPaths.main, StaffPaths.shiftDetailsRoute),
|
StaffPaths.childRoute(StaffPaths.main, StaffPaths.shiftDetailsRoute),
|
||||||
module: ShiftDetailsModule(),
|
module: ShiftDetailsModule(),
|
||||||
);
|
);
|
||||||
|
r.module(
|
||||||
|
StaffPaths.childRoute(StaffPaths.main, StaffPaths.orderDetailsRoute),
|
||||||
|
module: OrderDetailsModule(),
|
||||||
|
);
|
||||||
r.module(
|
r.module(
|
||||||
StaffPaths.childRoute(StaffPaths.main, StaffPaths.faqs),
|
StaffPaths.childRoute(StaffPaths.main, StaffPaths.faqs),
|
||||||
module: FaqsModule(),
|
module: FaqsModule(),
|
||||||
|
|||||||
Reference in New Issue
Block a user