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);
|
||||
}
|
||||
|
||||
/// 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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
@@ -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<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>(
|
||||
listener: (BuildContext context, ShiftsState state) {
|
||||
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(
|
||||
availableOrders: ordersState.orders,
|
||||
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.
|
||||
///
|
||||
/// 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,7 +90,9 @@ class AvailableOrderCard extends StatelessWidget {
|
||||
final String timeRange =
|
||||
'${_formatTime(schedule.firstShiftStartsAt)} - ${_formatTime(schedule.lastShiftEndsAt)}';
|
||||
|
||||
return Container(
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
@@ -136,7 +135,7 @@ class AvailableOrderCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
/// 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<FindShiftsTab> createState() => _FindShiftsTabState();
|
||||
}
|
||||
@@ -186,8 +179,7 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
|
||||
...filteredOrders.map(
|
||||
(AvailableOrder order) => AvailableOrderCard(
|
||||
order: order,
|
||||
onBook: widget.onBook,
|
||||
bookingInProgress: widget.bookingInProgress,
|
||||
onTap: () => Modular.to.toOrderDetails(order),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space32),
|
||||
|
||||
@@ -2,4 +2,5 @@ library;
|
||||
|
||||
export 'src/staff_shifts_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),
|
||||
module: ShiftDetailsModule(),
|
||||
);
|
||||
r.module(
|
||||
StaffPaths.childRoute(StaffPaths.main, StaffPaths.orderDetailsRoute),
|
||||
module: OrderDetailsModule(),
|
||||
);
|
||||
r.module(
|
||||
StaffPaths.childRoute(StaffPaths.main, StaffPaths.faqs),
|
||||
module: FaqsModule(),
|
||||
|
||||
Reference in New Issue
Block a user