feat: Implement available orders feature in staff marketplace

- Added `AvailableOrder` and `AvailableOrderSchedule` entities to represent available orders and their schedules.
- Introduced `GetAvailableOrdersUseCase` and `BookOrderUseCase` for fetching and booking orders.
- Created `AvailableOrdersBloc` to manage the state of available orders and handle booking actions.
- Developed UI components including `AvailableOrderCard` to display order details and booking options.
- Added necessary events and states for the BLoC architecture to support loading and booking orders.
- Integrated new enums and utility functions for handling order types and scheduling.
This commit is contained in:
Achintha Isuru
2026-03-19 13:23:28 -04:00
parent 5792aa6e98
commit 96056d0170
21 changed files with 1498 additions and 359 deletions

View File

@@ -165,4 +165,38 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
final ProfileCompletion completion = ProfileCompletion.fromJson(data);
return completion.completed;
}
@override
Future<List<AvailableOrder>> getAvailableOrders({
String? search,
int limit = 20,
}) async {
final Map<String, dynamic> params = <String, dynamic>{
'limit': limit,
};
if (search != null && search.isNotEmpty) {
params['search'] = search;
}
final ApiResponse response = await _apiService.get(
StaffEndpoints.ordersAvailable,
params: params,
);
final List<dynamic> items = _extractItems(response.data);
return items
.map((dynamic json) =>
AvailableOrder.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<OrderBooking> bookOrder({
required String orderId,
required String roleId,
}) async {
final ApiResponse response = await _apiService.post(
StaffEndpoints.orderBook(orderId),
data: <String, dynamic>{'roleId': roleId},
);
return OrderBooking.fromJson(response.data as Map<String, dynamic>);
}
}

View File

@@ -52,4 +52,16 @@ abstract interface class ShiftsRepositoryInterface {
///
/// Only allowed for shifts in CHECKED_OUT or COMPLETED status.
Future<void> submitForApproval(String shiftId, {String? note});
/// Retrieves available orders from the staff marketplace.
Future<List<AvailableOrder>> getAvailableOrders({
String? search,
int limit,
});
/// Books an order by placing the staff member into a role.
Future<OrderBooking> bookOrder({
required String orderId,
required String roleId,
});
}

View File

@@ -0,0 +1,23 @@
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart';
/// Books an available order for the current staff member.
///
/// Delegates to [ShiftsRepositoryInterface.bookOrder] with the order and
/// role identifiers.
class BookOrderUseCase {
/// Creates a [BookOrderUseCase].
BookOrderUseCase(this._repository);
/// The shifts repository.
final ShiftsRepositoryInterface _repository;
/// Executes the use case, returning the [OrderBooking] result.
Future<OrderBooking> call({
required String orderId,
required String roleId,
}) {
return _repository.bookOrder(orderId: orderId, roleId: roleId);
}
}

View File

@@ -0,0 +1,20 @@
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart';
/// Retrieves available orders from the staff marketplace.
///
/// Delegates to [ShiftsRepositoryInterface.getAvailableOrders] with an
/// optional search filter.
class GetAvailableOrdersUseCase {
/// Creates a [GetAvailableOrdersUseCase].
GetAvailableOrdersUseCase(this._repository);
/// The shifts repository.
final ShiftsRepositoryInterface _repository;
/// Executes the use case, returning a list of [AvailableOrder].
Future<List<AvailableOrder>> call({String? search, int limit = 20}) {
return _repository.getAvailableOrders(search: search, limit: limit);
}
}

View File

@@ -0,0 +1,97 @@
import 'package:bloc/bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_shifts/src/domain/usecases/book_order_usecase.dart';
import 'package:staff_shifts/src/domain/usecases/get_available_orders_usecase.dart';
import 'available_orders_event.dart';
import 'available_orders_state.dart';
/// Manages the state for the available-orders marketplace tab.
///
/// Loads order-level cards from `GET /staff/orders/available` and handles
/// booking via `POST /staff/orders/:orderId/book`.
class AvailableOrdersBloc
extends Bloc<AvailableOrdersEvent, AvailableOrdersState>
with BlocErrorHandler<AvailableOrdersState> {
/// Creates an [AvailableOrdersBloc].
AvailableOrdersBloc({
required GetAvailableOrdersUseCase getAvailableOrders,
required BookOrderUseCase bookOrder,
}) : _getAvailableOrders = getAvailableOrders,
_bookOrder = bookOrder,
super(const AvailableOrdersState()) {
on<LoadAvailableOrdersEvent>(_onLoadAvailableOrders);
on<BookOrderEvent>(_onBookOrder);
on<ClearBookingResultEvent>(_onClearBookingResult);
}
/// Use case for fetching available orders.
final GetAvailableOrdersUseCase _getAvailableOrders;
/// Use case for booking an order.
final BookOrderUseCase _bookOrder;
Future<void> _onLoadAvailableOrders(
LoadAvailableOrdersEvent event,
Emitter<AvailableOrdersState> emit,
) async {
emit(state.copyWith(
status: AvailableOrdersStatus.loading,
clearErrorMessage: true,
));
await handleError(
emit: emit.call,
action: () async {
final List<AvailableOrder> orders =
await _getAvailableOrders(search: event.search);
emit(state.copyWith(
status: AvailableOrdersStatus.loaded,
orders: orders,
clearErrorMessage: true,
));
},
onError: (String errorKey) => state.copyWith(
status: AvailableOrdersStatus.error,
errorMessage: errorKey,
),
);
}
Future<void> _onBookOrder(
BookOrderEvent event,
Emitter<AvailableOrdersState> emit,
) async {
emit(state.copyWith(bookingInProgress: true, clearErrorMessage: true));
await handleError(
emit: emit.call,
action: () async {
final OrderBooking booking = await _bookOrder(
orderId: event.orderId,
roleId: event.roleId,
);
emit(state.copyWith(
bookingInProgress: false,
lastBooking: booking,
clearErrorMessage: true,
));
// Reload orders after successful booking.
add(const LoadAvailableOrdersEvent());
},
onError: (String errorKey) => state.copyWith(
bookingInProgress: false,
errorMessage: errorKey,
),
);
}
void _onClearBookingResult(
ClearBookingResultEvent event,
Emitter<AvailableOrdersState> emit,
) {
emit(state.copyWith(clearLastBooking: true, clearErrorMessage: true));
}
}

View File

@@ -0,0 +1,45 @@
import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart';
/// Base class for all available-orders events.
@immutable
sealed class AvailableOrdersEvent extends Equatable {
/// Creates an [AvailableOrdersEvent].
const AvailableOrdersEvent();
@override
List<Object?> get props => <Object?>[];
}
/// Loads available orders from the staff marketplace.
class LoadAvailableOrdersEvent extends AvailableOrdersEvent {
/// Creates a [LoadAvailableOrdersEvent].
const LoadAvailableOrdersEvent({this.search});
/// Optional search query to filter orders.
final String? search;
@override
List<Object?> get props => <Object?>[search];
}
/// Books the staff member into an order for a specific role.
class BookOrderEvent extends AvailableOrdersEvent {
/// Creates a [BookOrderEvent].
const BookOrderEvent({required this.orderId, required this.roleId});
/// The order to book.
final String orderId;
/// The role within the order to fill.
final String roleId;
@override
List<Object?> get props => <Object?>[orderId, roleId];
}
/// Clears the last booking result so the UI can dismiss confirmation.
class ClearBookingResultEvent extends AvailableOrdersEvent {
/// Creates a [ClearBookingResultEvent].
const ClearBookingResultEvent();
}

View File

@@ -0,0 +1,74 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
/// Lifecycle status for the available-orders list.
enum AvailableOrdersStatus {
/// No data has been requested yet.
initial,
/// A load is in progress.
loading,
/// Data has been loaded successfully.
loaded,
/// An error occurred during loading.
error,
}
/// State for the available-orders marketplace tab.
class AvailableOrdersState extends Equatable {
/// Creates an [AvailableOrdersState].
const AvailableOrdersState({
this.status = AvailableOrdersStatus.initial,
this.orders = const <AvailableOrder>[],
this.bookingInProgress = false,
this.lastBooking,
this.errorMessage,
});
/// Current lifecycle status.
final AvailableOrdersStatus status;
/// The list of available orders.
final List<AvailableOrder> orders;
/// Whether a booking request is currently in flight.
final bool bookingInProgress;
/// The result of the most recent booking, if any.
final OrderBooking? lastBooking;
/// Error message key for display.
final String? errorMessage;
/// Creates a copy with the given fields replaced.
AvailableOrdersState copyWith({
AvailableOrdersStatus? status,
List<AvailableOrder>? orders,
bool? bookingInProgress,
OrderBooking? lastBooking,
bool clearLastBooking = false,
String? errorMessage,
bool clearErrorMessage = false,
}) {
return AvailableOrdersState(
status: status ?? this.status,
orders: orders ?? this.orders,
bookingInProgress: bookingInProgress ?? this.bookingInProgress,
lastBooking:
clearLastBooking ? null : (lastBooking ?? this.lastBooking),
errorMessage:
clearErrorMessage ? null : (errorMessage ?? this.errorMessage),
);
}
@override
List<Object?> get props => <Object?>[
status,
orders,
bookingInProgress,
lastBooking,
errorMessage,
];
}

View File

@@ -5,6 +5,9 @@ import 'package:design_system/design_system.dart';
import 'package:core_localization/core_localization.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_bloc.dart';
import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_event.dart';
import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_state.dart';
import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart';
import 'package:staff_shifts/src/presentation/utils/shift_tab_type.dart';
import 'package:staff_shifts/src/presentation/widgets/shifts_page_skeleton.dart';
@@ -14,7 +17,8 @@ import 'package:staff_shifts/src/presentation/widgets/tabs/history_shifts_tab.da
/// Tabbed page for browsing staff shifts (My Shifts, Find Work, History).
///
/// Manages tab state locally and delegates data loading to [ShiftsBloc].
/// Manages tab state locally and delegates data loading to [ShiftsBloc]
/// and [AvailableOrdersBloc].
class ShiftsPage extends StatefulWidget {
/// Creates a [ShiftsPage].
///
@@ -45,9 +49,9 @@ class _ShiftsPageState extends State<ShiftsPage> {
late ShiftTabType _activeTab;
DateTime? _selectedDate;
bool _prioritizeFind = false;
bool _refreshAvailable = false;
bool _pendingAvailableRefresh = false;
final ShiftsBloc _bloc = Modular.get<ShiftsBloc>();
final AvailableOrdersBloc _ordersBloc = Modular.get<AvailableOrdersBloc>();
@override
void initState() {
@@ -55,7 +59,6 @@ class _ShiftsPageState extends State<ShiftsPage> {
_activeTab = widget.initialTab ?? ShiftTabType.find;
_selectedDate = widget.selectedDate;
_prioritizeFind = _activeTab == ShiftTabType.find;
_refreshAvailable = widget.refreshAvailable;
_pendingAvailableRefresh = widget.refreshAvailable;
if (_prioritizeFind) {
_bloc.add(LoadFindFirstEvent());
@@ -66,9 +69,8 @@ class _ShiftsPageState extends State<ShiftsPage> {
_bloc.add(LoadHistoryShiftsEvent());
}
if (_activeTab == ShiftTabType.find) {
if (!_prioritizeFind) {
_bloc.add(LoadAvailableShiftsEvent(force: _refreshAvailable));
}
// Load available orders via the new BLoC.
_ordersBloc.add(const LoadAvailableOrdersEvent());
}
// Check profile completion
@@ -90,160 +92,193 @@ class _ShiftsPageState extends State<ShiftsPage> {
});
}
if (widget.refreshAvailable) {
_refreshAvailable = true;
_pendingAvailableRefresh = true;
}
}
@override
Widget build(BuildContext context) {
final t = Translations.of(context);
return BlocProvider.value(
value: _bloc,
child: BlocConsumer<ShiftsBloc, ShiftsState>(
listener: (context, state) {
if (state.status == ShiftsStatus.error &&
state.errorMessage != null) {
final Translations t = Translations.of(context);
return MultiBlocProvider(
providers: <BlocProvider<dynamic>>[
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: translateErrorKey(state.errorMessage!),
message: message,
type: UiSnackbarType.success,
);
_ordersBloc.add(const ClearBookingResultEvent());
}
if (ordersState.errorMessage != null) {
UiSnackbar.show(
context,
message: translateErrorKey(ordersState.errorMessage!),
type: UiSnackbarType.error,
);
}
},
builder: (context, state) {
if (_pendingAvailableRefresh && state.status == ShiftsStatus.loaded) {
_pendingAvailableRefresh = false;
_bloc.add(const LoadAvailableShiftsEvent(force: true));
}
final bool baseLoaded = state.status == ShiftsStatus.loaded;
final List<AssignedShift> myShifts = state.myShifts;
final List<OpenShift> availableJobs = state.availableShifts;
final bool availableLoading = state.availableLoading;
final bool availableLoaded = state.availableLoaded;
final List<PendingAssignment> pendingAssignments = state.pendingShifts;
final List<CancelledShift> cancelledShifts = state.cancelledShifts;
final List<CompletedShift> historyShifts = state.historyShifts;
final bool historyLoading = state.historyLoading;
final bool historyLoaded = state.historyLoaded;
final bool myShiftsLoaded = state.myShiftsLoaded;
final bool blockTabsForFind = _prioritizeFind && !availableLoaded;
child: BlocConsumer<ShiftsBloc, ShiftsState>(
listener: (BuildContext context, ShiftsState state) {
if (state.status == ShiftsStatus.error &&
state.errorMessage != null) {
UiSnackbar.show(
context,
message: translateErrorKey(state.errorMessage!),
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, ShiftsState state) {
if (_pendingAvailableRefresh &&
state.status == ShiftsStatus.loaded) {
_pendingAvailableRefresh = false;
_ordersBloc.add(const LoadAvailableOrdersEvent());
}
final bool baseLoaded = state.status == ShiftsStatus.loaded;
final List<AssignedShift> myShifts = state.myShifts;
final List<PendingAssignment> pendingAssignments =
state.pendingShifts;
final List<CancelledShift> cancelledShifts = state.cancelledShifts;
final List<CompletedShift> historyShifts = state.historyShifts;
final bool historyLoading = state.historyLoading;
final bool historyLoaded = state.historyLoaded;
final bool myShiftsLoaded = state.myShiftsLoaded;
// Note: "filteredJobs" logic moved to FindShiftsTab
// Note: Calendar logic moved to MyShiftsTab
return Scaffold(
body: Column(
children: [
// Header (Blue)
Container(
color: UiColors.primary,
padding: EdgeInsets.fromLTRB(
UiConstants.space5,
MediaQuery.of(context).padding.top + UiConstants.space2,
UiConstants.space5,
UiConstants.space5,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: UiConstants.space4,
children: [
Text(
t.staff_shifts.title,
style: UiTypography.display1b.white,
),
// Tabs
Row(
children: [
if (state.profileComplete != false)
Expanded(
child: _buildTab(
ShiftTabType.myShifts,
t.staff_shifts.tabs.my_shifts,
UiIcons.calendar,
myShifts.length,
showCount: myShiftsLoaded,
enabled:
!blockTabsForFind &&
(state.profileComplete ?? false),
),
)
else
const SizedBox.shrink(),
if (state.profileComplete != false)
const SizedBox(width: UiConstants.space2)
else
const SizedBox.shrink(),
_buildTab(
ShiftTabType.find,
t.staff_shifts.tabs.find_work,
UiIcons.search,
availableJobs.length,
showCount: availableLoaded,
enabled: baseLoaded,
),
if (state.profileComplete != false)
const SizedBox(width: UiConstants.space2)
else
const SizedBox.shrink(),
if (state.profileComplete != false)
Expanded(
child: _buildTab(
ShiftTabType.history,
t.staff_shifts.tabs.history,
UiIcons.clock,
historyShifts.length,
showCount: historyLoaded,
enabled:
!blockTabsForFind &&
baseLoaded &&
(state.profileComplete ?? false),
),
)
else
const SizedBox.shrink(),
],
),
],
),
),
// Body Content
Expanded(
child: state.status == ShiftsStatus.loading
? const ShiftsPageSkeleton()
: state.status == ShiftsStatus.error
? Center(
child: Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
translateErrorKey(state.errorMessage ?? ''),
style: UiTypography.body2r.textSecondary,
textAlign: TextAlign.center,
),
],
),
),
)
: _buildTabContent(
state,
myShifts,
pendingAssignments,
cancelledShifts,
availableJobs,
historyShifts,
availableLoading,
historyLoading,
return Scaffold(
body: Column(
children: <Widget>[
// Header (Blue)
Container(
color: UiColors.primary,
padding: EdgeInsets.fromLTRB(
UiConstants.space5,
MediaQuery.of(context).padding.top + UiConstants.space2,
UiConstants.space5,
UiConstants.space5,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: UiConstants.space4,
children: <Widget>[
Text(
t.staff_shifts.title,
style: UiTypography.display1b.white,
),
),
],
),
);
},
// Tabs -- use BlocBuilder on orders bloc for count
BlocBuilder<AvailableOrdersBloc,
AvailableOrdersState>(
builder: (BuildContext context,
AvailableOrdersState ordersState) {
final bool ordersLoaded = ordersState.status ==
AvailableOrdersStatus.loaded;
final int ordersCount = ordersState.orders.length;
final bool blockTabsForFind =
_prioritizeFind && !ordersLoaded;
return Row(
children: <Widget>[
if (state.profileComplete != false)
Expanded(
child: _buildTab(
ShiftTabType.myShifts,
t.staff_shifts.tabs.my_shifts,
UiIcons.calendar,
myShifts.length,
showCount: myShiftsLoaded,
enabled: !blockTabsForFind &&
(state.profileComplete ?? false),
),
)
else
const SizedBox.shrink(),
if (state.profileComplete != false)
const SizedBox(width: UiConstants.space2)
else
const SizedBox.shrink(),
_buildTab(
ShiftTabType.find,
t.staff_shifts.tabs.find_work,
UiIcons.search,
ordersCount,
showCount: ordersLoaded,
enabled: baseLoaded,
),
if (state.profileComplete != false)
const SizedBox(width: UiConstants.space2)
else
const SizedBox.shrink(),
if (state.profileComplete != false)
Expanded(
child: _buildTab(
ShiftTabType.history,
t.staff_shifts.tabs.history,
UiIcons.clock,
historyShifts.length,
showCount: historyLoaded,
enabled: !blockTabsForFind &&
baseLoaded &&
(state.profileComplete ?? false),
),
)
else
const SizedBox.shrink(),
],
);
},
),
],
),
),
// Body Content
Expanded(
child: state.status == ShiftsStatus.loading
? const ShiftsPageSkeleton()
: state.status == ShiftsStatus.error
? Center(
child: Padding(
padding:
const EdgeInsets.all(UiConstants.space5),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
translateErrorKey(
state.errorMessage ?? ''),
style:
UiTypography.body2r.textSecondary,
textAlign: TextAlign.center,
),
],
),
),
)
: _buildTabContent(
state,
myShifts,
pendingAssignments,
cancelledShifts,
historyShifts,
historyLoading,
),
),
],
),
);
},
),
),
);
}
@@ -253,9 +288,7 @@ class _ShiftsPageState extends State<ShiftsPage> {
List<AssignedShift> myShifts,
List<PendingAssignment> pendingAssignments,
List<CancelledShift> cancelledShifts,
List<OpenShift> availableJobs,
List<CompletedShift> historyShifts,
bool availableLoading,
bool historyLoading,
) {
switch (_activeTab) {
@@ -269,12 +302,23 @@ class _ShiftsPageState extends State<ShiftsPage> {
submittingShiftId: state.submittingShiftId,
);
case ShiftTabType.find:
if (availableLoading) {
return const ShiftsPageSkeleton();
}
return FindShiftsTab(
availableJobs: availableJobs,
profileComplete: state.profileComplete ?? true,
return BlocBuilder<AvailableOrdersBloc, AvailableOrdersState>(
builder:
(BuildContext context, AvailableOrdersState ordersState) {
if (ordersState.status == AvailableOrdersStatus.loading) {
return const ShiftsPageSkeleton();
}
return FindShiftsTab(
availableOrders: ordersState.orders,
profileComplete: state.profileComplete ?? true,
onBook: (String orderId, String roleId) {
_ordersBloc.add(
BookOrderEvent(orderId: orderId, roleId: roleId),
);
},
bookingInProgress: ordersState.bookingInProgress,
);
},
);
case ShiftTabType.history:
if (historyLoading) {
@@ -296,7 +340,7 @@ class _ShiftsPageState extends State<ShiftsPage> {
bool showCount = true,
bool enabled = true,
}) {
final isActive = _activeTab == type;
final bool isActive = _activeTab == type;
return Expanded(
child: GestureDetector(
onTap: !enabled
@@ -307,7 +351,7 @@ class _ShiftsPageState extends State<ShiftsPage> {
_bloc.add(LoadHistoryShiftsEvent());
}
if (type == ShiftTabType.find) {
_bloc.add(LoadAvailableShiftsEvent());
_ordersBloc.add(const LoadAvailableOrdersEvent());
}
},
child: Container(
@@ -324,35 +368,33 @@ class _ShiftsPageState extends State<ShiftsPage> {
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
children: <Widget>[
Icon(
icon,
size: 14,
color: !enabled
? UiColors.white.withValues(alpha: 0.5)
: isActive
? UiColors.primary
: UiColors.white,
? UiColors.primary
: UiColors.white,
),
const SizedBox(width: UiConstants.space1),
Flexible(
child: Text(
label,
style:
(isActive
? UiTypography.body3m.copyWith(
color: UiColors.primary,
)
: UiTypography.body3m.white)
.copyWith(
color: !enabled
? UiColors.white.withValues(alpha: 0.5)
: null,
),
style: (isActive
? UiTypography.body3m
.copyWith(color: UiColors.primary)
: UiTypography.body3m.white)
.copyWith(
color: !enabled
? UiColors.white.withValues(alpha: 0.5)
: null,
),
overflow: TextOverflow.ellipsis,
),
),
if (showCount) ...[
if (showCount) ...<Widget>[
const SizedBox(width: UiConstants.space1),
Container(
padding: const EdgeInsets.symmetric(
@@ -368,7 +410,7 @@ class _ShiftsPageState extends State<ShiftsPage> {
),
child: Center(
child: Text(
"$count",
'$count',
style: UiTypography.footnote1b.copyWith(
color: isActive ? UiColors.primary : UiColors.white,
),

View File

@@ -0,0 +1,415 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
/// Card displaying an [AvailableOrder] from the staff marketplace.
///
/// Shows role, location, schedule, pay rate, and a booking/apply action.
class AvailableOrderCard extends StatelessWidget {
/// Creates an [AvailableOrderCard].
const AvailableOrderCard({
super.key,
required this.order,
required this.onBook,
this.bookingInProgress = false,
});
/// The available order to display.
final AvailableOrder order;
/// Callback when the user taps book/apply, providing orderId and roleId.
final void Function(String orderId, String roleId) onBook;
/// Whether a booking request is currently in progress.
final bool bookingInProgress;
/// Formats a date-only string (e.g. "2026-03-24") to "Mar 24".
String _formatDateShort(String dateStr) {
if (dateStr.isEmpty) return '';
try {
final DateTime date = DateTime.parse(dateStr);
return DateFormat('MMM d').format(date);
} catch (_) {
return dateStr;
}
}
/// Returns a human-readable label for the order type.
String _orderTypeLabel(OrderType type) {
switch (type) {
case OrderType.oneTime:
return t.staff_shifts.filter.one_day;
case OrderType.recurring:
return t.staff_shifts.filter.multi_day;
case OrderType.permanent:
return t.staff_shifts.filter.long_term;
case OrderType.rapid:
return 'Rapid';
case OrderType.unknown:
return '';
}
}
/// Returns a capitalised short label for a dispatch team value.
String _dispatchTeamLabel(String team) {
switch (team.toUpperCase()) {
case 'CORE':
return 'Core';
case 'CERTIFIED_LOCATION':
return 'Certified';
case 'MARKETPLACE':
return 'Marketplace';
default:
return team;
}
}
@override
Widget build(BuildContext context) {
final AvailableOrderSchedule schedule = order.schedule;
final int spotsLeft = order.requiredWorkerCount - order.filledCount;
final String hourlyDisplay =
'\$${order.hourlyRate.toStringAsFixed(order.hourlyRate.truncateToDouble() == order.hourlyRate ? 0 : 2)}';
final String dateRange =
'${_formatDateShort(schedule.startDate)} - ${_formatDateShort(schedule.endDate)}';
final String timeRange = '${schedule.startTime} - ${schedule.endTime}';
return Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
),
child: Padding(
padding: const EdgeInsets.all(UiConstants.space4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// -- Badge row: order type, instant book, dispatch team --
_buildBadgeRow(),
const SizedBox(height: UiConstants.space3),
// -- Main content row: icon + details + pay --
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// Role icon
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: <Color>[
UiColors.primary.withValues(alpha: 0.09),
UiColors.primary.withValues(alpha: 0.03),
],
),
borderRadius:
BorderRadius.circular(UiConstants.radiusBase),
),
child: const Center(
child: Icon(
UiIcons.briefcase,
color: UiColors.primary,
size: UiConstants.iconMd,
),
),
),
const SizedBox(width: UiConstants.space3),
// Details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// Role name + hourly rate
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
order.roleName,
style: UiTypography.body2m.textPrimary,
overflow: TextOverflow.ellipsis,
),
if (order.clientName.isNotEmpty)
Text(
order.clientName,
style: UiTypography.body3r.textSecondary,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: UiConstants.space2),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
'$hourlyDisplay${t.available_orders.per_hour}',
style: UiTypography.title1m.textPrimary,
),
Text(
'${order.filledCount}/${order.requiredWorkerCount} ${t.available_orders.spots_left(count: spotsLeft)}',
style: UiTypography.footnote2r.textSecondary,
),
],
),
],
),
const SizedBox(height: UiConstants.space2),
// Location
if (order.location.isNotEmpty)
Padding(
padding:
const EdgeInsets.only(bottom: UiConstants.space1),
child: Row(
children: <Widget>[
const Icon(
UiIcons.mapPin,
size: UiConstants.iconXs,
color: UiColors.iconSecondary,
),
const SizedBox(width: UiConstants.space1),
Expanded(
child: Text(
order.location,
style: UiTypography.footnote1r.textSecondary,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
// Address
if (order.locationAddress.isNotEmpty)
Padding(
padding:
const EdgeInsets.only(bottom: UiConstants.space2),
child: Padding(
padding: const EdgeInsets.only(
left: UiConstants.iconXs + UiConstants.space1,
),
child: Text(
order.locationAddress,
style: UiTypography.footnote2r.textSecondary,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
),
// Schedule: days of week chips
if (schedule.daysOfWeek.isNotEmpty)
Padding(
padding:
const EdgeInsets.only(bottom: UiConstants.space2),
child: Wrap(
spacing: UiConstants.space1,
runSpacing: UiConstants.space1,
children: schedule.daysOfWeek
.map(
(DayOfWeek day) => _buildDayChip(day),
)
.toList(),
),
),
// Date range + time + shifts count
Row(
children: <Widget>[
const Icon(
UiIcons.calendar,
size: UiConstants.iconXs,
color: UiColors.iconSecondary,
),
const SizedBox(width: UiConstants.space1),
Text(
dateRange,
style: UiTypography.footnote1r.textSecondary,
),
const SizedBox(width: UiConstants.space3),
const Icon(
UiIcons.clock,
size: UiConstants.iconXs,
color: UiColors.iconSecondary,
),
const SizedBox(width: UiConstants.space1),
Text(
timeRange,
style: UiTypography.footnote1r.textSecondary,
),
],
),
const SizedBox(height: UiConstants.space1),
// Total shifts + timezone
Row(
children: <Widget>[
Text(
t.available_orders.shifts_count(
count: schedule.totalShifts,
),
style: UiTypography.footnote2r.textSecondary,
),
if (schedule.timezone.isNotEmpty) ...<Widget>[
const SizedBox(width: UiConstants.space2),
Text(
schedule.timezone,
style: UiTypography.footnote2r.textSecondary,
),
],
],
),
],
),
),
],
),
const SizedBox(height: UiConstants.space3),
// -- Action button --
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: bookingInProgress
? null
: () => onBook(order.orderId, order.roleId),
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
foregroundColor: UiColors.white,
disabledBackgroundColor:
UiColors.primary.withValues(alpha: 0.5),
disabledForegroundColor: UiColors.white,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(UiConstants.radiusMdValue),
),
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space3,
),
),
child: bookingInProgress
? const SizedBox(
width: UiConstants.iconMd,
height: UiConstants.iconMd,
child: CircularProgressIndicator(
strokeWidth: 2,
color: UiColors.white,
),
)
: Text(
order.instantBook
? t.available_orders.book_order
: t.available_orders.apply,
style: UiTypography.body2m.white,
),
),
),
],
),
),
);
}
/// Builds the horizontal row of badge chips at the top of the card.
Widget _buildBadgeRow() {
return Wrap(
spacing: UiConstants.space2,
runSpacing: UiConstants.space1,
children: <Widget>[
// Order type badge
_buildBadge(
label: _orderTypeLabel(order.orderType),
backgroundColor: UiColors.background,
textColor: UiColors.textSecondary,
borderColor: UiColors.border,
),
// Instant book badge
if (order.instantBook)
_buildBadge(
label: t.available_orders.instant_book,
backgroundColor: UiColors.success.withValues(alpha: 0.1),
textColor: UiColors.success,
borderColor: UiColors.success.withValues(alpha: 0.3),
icon: UiIcons.zap,
),
// Dispatch team badge
if (order.dispatchTeam.isNotEmpty)
_buildBadge(
label: _dispatchTeamLabel(order.dispatchTeam),
backgroundColor: UiColors.primary.withValues(alpha: 0.08),
textColor: UiColors.primary,
borderColor: UiColors.primary.withValues(alpha: 0.2),
),
],
);
}
/// Builds a single badge chip with optional leading icon.
Widget _buildBadge({
required String label,
required Color backgroundColor,
required Color textColor,
required Color borderColor,
IconData? icon,
}) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: 2,
),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: UiConstants.radiusSm,
border: Border.all(color: borderColor),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (icon != null) ...<Widget>[
Icon(icon, size: 10, color: textColor),
const SizedBox(width: 2),
],
Text(
label,
style: UiTypography.footnote2m.copyWith(color: textColor),
),
],
),
);
}
/// Builds a small chip showing a day-of-week abbreviation.
Widget _buildDayChip(DayOfWeek day) {
// Display as 3-letter capitalised abbreviation (e.g. "MON" -> "Mon").
final String label = day.value.isNotEmpty
? '${day.value[0]}${day.value.substring(1).toLowerCase()}'
: '';
return Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: 2,
),
decoration: BoxDecoration(
color: UiColors.primary.withValues(alpha: 0.08),
borderRadius: UiConstants.radiusSm,
),
child: Text(
label,
style: UiTypography.footnote2m.copyWith(color: UiColors.primary),
),
);
}
}

View File

@@ -2,26 +2,36 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_shifts/src/presentation/widgets/available_order_card.dart';
import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart';
/// Tab showing open shifts available for the worker to browse and apply.
/// Tab showing available orders for the worker to browse and book.
///
/// Replaces the former open-shift listing with order-level marketplace cards.
class FindShiftsTab extends StatefulWidget {
/// Creates a [FindShiftsTab].
const FindShiftsTab({
super.key,
required this.availableJobs,
required this.availableOrders,
this.profileComplete = true,
required this.onBook,
this.bookingInProgress = false,
});
/// Open shifts loaded from the V2 API.
final List<OpenShift> availableJobs;
/// Available orders loaded from the V2 API.
final List<AvailableOrder> availableOrders;
/// Whether the worker's profile is complete.
final bool profileComplete;
/// Callback when the worker taps book/apply on an order card.
final void Function(String orderId, String roleId) onBook;
/// Whether a booking request is currently in flight.
final bool bookingInProgress;
@override
State<FindShiftsTab> createState() => _FindShiftsTabState();
}
@@ -30,18 +40,7 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
String _searchQuery = '';
String _jobType = 'all';
String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt);
String _formatDate(DateTime date) {
final DateTime now = DateTime.now();
final DateTime today = DateTime(now.year, now.month, now.day);
final DateTime tomorrow = today.add(const Duration(days: 1));
final DateTime d = DateTime(date.year, date.month, date.day);
if (d == today) return 'Today';
if (d == tomorrow) return 'Tomorrow';
return DateFormat('EEE, MMM d').format(date);
}
/// Builds a filter tab chip.
Widget _buildFilterTab(String id, String label) {
final bool isSelected = _jobType == id;
return GestureDetector(
@@ -69,178 +68,27 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
);
}
List<OpenShift> _filterByType(List<OpenShift> shifts) {
if (_jobType == 'all') return shifts;
return shifts.where((OpenShift s) {
if (_jobType == 'one-day') return s.orderType == OrderType.oneTime;
if (_jobType == 'multi-day') return s.orderType == OrderType.recurring;
if (_jobType == 'long-term') return s.orderType == OrderType.permanent;
/// Filters orders by the selected order type tab.
List<AvailableOrder> _filterByType(List<AvailableOrder> orders) {
if (_jobType == 'all') return orders;
return orders.where((AvailableOrder o) {
if (_jobType == 'one-day') return o.orderType == OrderType.oneTime;
if (_jobType == 'multi-day') return o.orderType == OrderType.recurring;
if (_jobType == 'long-term') return o.orderType == OrderType.permanent;
return true;
}).toList();
}
/// Builds an open shift card.
Widget _buildOpenShiftCard(BuildContext context, OpenShift shift) {
final double hourlyRate = shift.hourlyRateCents / 100;
final int minutes = shift.endTime.difference(shift.startTime).inMinutes;
final double duration = minutes / 60;
final double estimatedTotal = hourlyRate * duration;
String typeLabel;
switch (shift.orderType) {
case OrderType.permanent:
typeLabel = t.staff_shifts.filter.long_term;
case OrderType.recurring:
typeLabel = t.staff_shifts.filter.multi_day;
case OrderType.oneTime:
default:
typeLabel = t.staff_shifts.filter.one_day;
}
return GestureDetector(
onTap: () => Modular.to.toShiftDetailsById(shift.shiftId),
child: Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
),
child: Padding(
padding: const EdgeInsets.all(UiConstants.space4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// Type badge
Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space2),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: 2,
),
decoration: BoxDecoration(
color: UiColors.background,
borderRadius: UiConstants.radiusSm,
border: Border.all(color: UiColors.border),
),
child: Text(
typeLabel,
style: UiTypography.footnote2m
.copyWith(color: UiColors.textSecondary),
),
),
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: <Color>[
UiColors.primary.withValues(alpha: 0.09),
UiColors.primary.withValues(alpha: 0.03),
],
),
borderRadius:
BorderRadius.circular(UiConstants.radiusBase),
),
child: const Center(
child: Icon(UiIcons.briefcase,
color: UiColors.primary, size: UiConstants.iconMd),
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(shift.roleName,
style: UiTypography.body2m.textPrimary,
overflow: TextOverflow.ellipsis),
Text(shift.location,
style: UiTypography.body3r.textSecondary,
overflow: TextOverflow.ellipsis),
],
),
),
const SizedBox(width: UiConstants.space2),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text('\$${estimatedTotal.toStringAsFixed(0)}',
style: UiTypography.title1m.textPrimary),
Text(
'\$${hourlyRate.toInt()}/hr \u00b7 ${duration.toInt()}h',
style:
UiTypography.footnote2r.textSecondary),
],
),
],
),
const SizedBox(height: UiConstants.space2),
Row(
children: <Widget>[
const Icon(UiIcons.calendar,
size: UiConstants.iconXs,
color: UiColors.iconSecondary),
const SizedBox(width: UiConstants.space1),
Text(_formatDate(shift.date),
style: UiTypography.footnote1r.textSecondary),
const SizedBox(width: UiConstants.space3),
const Icon(UiIcons.clock,
size: UiConstants.iconXs,
color: UiColors.iconSecondary),
const SizedBox(width: UiConstants.space1),
Text(
'${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}',
style: UiTypography.footnote1r.textSecondary),
],
),
const SizedBox(height: UiConstants.space1),
Row(
children: <Widget>[
const Icon(UiIcons.mapPin,
size: UiConstants.iconXs,
color: UiColors.iconSecondary),
const SizedBox(width: UiConstants.space1),
Expanded(
child: Text(shift.location,
style: UiTypography.footnote1r.textSecondary,
overflow: TextOverflow.ellipsis),
),
],
),
],
),
),
],
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
// Client-side filter by order type
final List<OpenShift> filteredJobs =
_filterByType(widget.availableJobs).where((OpenShift s) {
// Client-side filter by order type and search query
final List<AvailableOrder> filteredOrders =
_filterByType(widget.availableOrders).where((AvailableOrder o) {
if (_searchQuery.isEmpty) return true;
final String q = _searchQuery.toLowerCase();
return s.roleName.toLowerCase().contains(q) ||
s.location.toLowerCase().contains(q);
return o.roleName.toLowerCase().contains(q) ||
o.clientName.toLowerCase().contains(q) ||
o.location.toLowerCase().contains(q);
}).toList();
return Column(
@@ -322,7 +170,7 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
),
),
Expanded(
child: filteredJobs.isEmpty
child: filteredOrders.isEmpty
? EmptyStateView(
icon: UiIcons.search,
title: context.t.staff_shifts.find_shifts.no_jobs_title,
@@ -335,9 +183,12 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
child: Column(
children: <Widget>[
const SizedBox(height: UiConstants.space5),
...filteredJobs.map(
(OpenShift shift) =>
_buildOpenShiftCard(context, shift),
...filteredOrders.map(
(AvailableOrder order) => AvailableOrderCard(
order: order,
onBook: widget.onBook,
bookingInProgress: widget.bookingInProgress,
),
),
const SizedBox(height: UiConstants.space32),
],

View File

@@ -14,7 +14,10 @@ import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.
import 'package:staff_shifts/src/domain/usecases/get_shift_details_usecase.dart';
import 'package:staff_shifts/src/domain/usecases/accept_shift_usecase.dart';
import 'package:staff_shifts/src/domain/usecases/decline_shift_usecase.dart';
import 'package:staff_shifts/src/domain/usecases/book_order_usecase.dart';
import 'package:staff_shifts/src/domain/usecases/get_available_orders_usecase.dart';
import 'package:staff_shifts/src/domain/usecases/submit_for_approval_usecase.dart';
import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_bloc.dart';
import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart';
import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_bloc.dart';
import 'package:staff_shifts/src/presentation/utils/shift_tab_type.dart';
@@ -49,6 +52,8 @@ class StaffShiftsModule extends Module {
i.addLazySingleton(
() => SubmitForApprovalUseCase(i.get<ShiftsRepositoryInterface>()),
);
i.addLazySingleton(GetAvailableOrdersUseCase.new);
i.addLazySingleton(BookOrderUseCase.new);
// BLoC
i.add(
@@ -72,6 +77,12 @@ class StaffShiftsModule extends Module {
getProfileCompletion: i.get(),
),
);
i.add(
() => AvailableOrdersBloc(
getAvailableOrders: i.get(),
bookOrder: i.get(),
),
);
}
@override