feat: Implement order details page and navigation for available orders

This commit is contained in:
Achintha Isuru
2026-03-19 14:08:34 -04:00
parent 742c8c75c5
commit 9c71acb96a
14 changed files with 813 additions and 102 deletions

View File

@@ -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).

View File

@@ -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.

View File

@@ -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!"
}
}

View File

@@ -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!"
}
}

View File

@@ -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);
},
);
}
}

View File

@@ -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;
}
}

View File

@@ -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,
);
},
);

View File

@@ -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,
),
),
),
],
),
),
),
);
}

View File

@@ -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,
),
),
);
}
}

View File

@@ -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),
),
],
),
);
}
}

View File

@@ -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),
),
);
}
}

View File

@@ -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),

View File

@@ -2,4 +2,5 @@ library;
export 'src/staff_shifts_module.dart';
export 'src/shift_details_module.dart';
export 'src/order_details_module.dart';

View File

@@ -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(),