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

@@ -92,7 +92,7 @@ abstract final class ClientEndpoints {
/// View orders. /// View orders.
static const ApiEndpoint ordersView = static const ApiEndpoint ordersView =
ApiEndpoint('/client/orders/view'); ApiEndpoint('/client/shifts/scheduled');
/// Order reorder preview. /// Order reorder preview.
static ApiEndpoint orderReorderPreview(String orderId) => static ApiEndpoint orderReorderPreview(String orderId) =>

View File

@@ -130,6 +130,10 @@ abstract final class StaffEndpoints {
/// FAQs search. /// FAQs search.
static const ApiEndpoint faqsSearch = ApiEndpoint('/staff/faqs/search'); static const ApiEndpoint faqsSearch = ApiEndpoint('/staff/faqs/search');
/// Available orders for the marketplace.
static const ApiEndpoint ordersAvailable =
ApiEndpoint('/staff/orders/available');
// ── Write ───────────────────────────────────────────────────────────── // ── Write ─────────────────────────────────────────────────────────────
/// Staff profile setup. /// Staff profile setup.
@@ -198,6 +202,10 @@ abstract final class StaffEndpoints {
static const ApiEndpoint locationStreams = static const ApiEndpoint locationStreams =
ApiEndpoint('/staff/location-streams'); ApiEndpoint('/staff/location-streams');
/// Book an available order.
static ApiEndpoint orderBook(String orderId) =>
ApiEndpoint('/staff/orders/$orderId/book');
/// Register or delete device push token (POST to register, DELETE to remove). /// Register or delete device push token (POST to register, DELETE to remove).
static const ApiEndpoint devicesPushTokens = static const ApiEndpoint devicesPushTokens =
ApiEndpoint('/staff/devices/push-tokens'); ApiEndpoint('/staff/devices/push-tokens');

View File

@@ -1877,5 +1877,18 @@
"success_message": "Cash out request submitted!", "success_message": "Cash out request submitted!",
"fee_notice": "A small fee of \\$1.99 may apply for instant transfers." "fee_notice": "A small fee of \\$1.99 may apply for instant transfers."
} }
},
"available_orders": {
"book_order": "Book Order",
"apply": "Apply",
"spots_left": "${count} spot(s) left",
"shifts_count": "${count} shift(s)",
"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"
} }
} }

View File

@@ -1877,5 +1877,18 @@
"success_message": "\u00a1Solicitud de retiro enviada!", "success_message": "\u00a1Solicitud de retiro enviada!",
"fee_notice": "Puede aplicarse una peque\u00f1a tarifa de \\$1.99 para transferencias instant\u00e1neas." "fee_notice": "Puede aplicarse una peque\u00f1a tarifa de \\$1.99 para transferencias instant\u00e1neas."
} }
},
"available_orders": {
"book_order": "Reservar Orden",
"apply": "Aplicar",
"spots_left": "${count} puesto(s) disponible(s)",
"shifts_count": "${count} turno(s)",
"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"
} }
} }

View File

@@ -16,6 +16,7 @@ export 'src/entities/enums/benefit_status.dart';
export 'src/entities/enums/business_status.dart'; export 'src/entities/enums/business_status.dart';
export 'src/entities/enums/invoice_status.dart'; export 'src/entities/enums/invoice_status.dart';
export 'src/entities/enums/onboarding_status.dart'; export 'src/entities/enums/onboarding_status.dart';
export 'src/entities/enums/day_of_week.dart';
export 'src/entities/enums/order_type.dart'; export 'src/entities/enums/order_type.dart';
export 'src/entities/enums/payment_status.dart'; export 'src/entities/enums/payment_status.dart';
export 'src/entities/enums/review_issue_flag.dart'; export 'src/entities/enums/review_issue_flag.dart';
@@ -69,8 +70,12 @@ export 'src/entities/shifts/completed_shift.dart';
export 'src/entities/shifts/shift_detail.dart'; export 'src/entities/shifts/shift_detail.dart';
// Orders // Orders
export 'src/entities/orders/order_item.dart'; export 'src/entities/orders/available_order.dart';
export 'src/entities/orders/available_order_schedule.dart';
export 'src/entities/orders/assigned_worker_summary.dart'; export 'src/entities/orders/assigned_worker_summary.dart';
export 'src/entities/orders/booking_assigned_shift.dart';
export 'src/entities/orders/order_booking.dart';
export 'src/entities/orders/order_item.dart';
export 'src/entities/orders/order_preview.dart'; export 'src/entities/orders/order_preview.dart';
export 'src/entities/orders/recent_order.dart'; export 'src/entities/orders/recent_order.dart';

View File

@@ -0,0 +1,46 @@
/// Day of the week for order scheduling.
///
/// Maps to the `day_of_week` values used in V2 order schedule responses.
enum DayOfWeek {
/// Monday.
mon('MON'),
/// Tuesday.
tue('TUE'),
/// Wednesday.
wed('WED'),
/// Thursday.
thu('THU'),
/// Friday.
fri('FRI'),
/// Saturday.
sat('SAT'),
/// Sunday.
sun('SUN'),
/// Fallback for unrecognised API values.
unknown('UNKNOWN');
const DayOfWeek(this.value);
/// The V2 API string representation.
final String value;
/// Deserialises from a V2 API string with safe fallback.
static DayOfWeek fromJson(String? value) {
if (value == null) return DayOfWeek.unknown;
final String upper = value.toUpperCase();
for (final DayOfWeek day in DayOfWeek.values) {
if (day.value == upper) return day;
}
return DayOfWeek.unknown;
}
/// Serialises to the V2 API string.
String toJson() => value;
}

View File

@@ -0,0 +1,145 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/src/entities/enums/order_type.dart';
import 'package:krow_domain/src/entities/orders/available_order_schedule.dart';
/// An available order in the staff marketplace.
///
/// Returned by `GET /staff/orders/available`. Represents an order-level card
/// that a staff member can book into, containing role, location, pay rate,
/// and schedule details.
class AvailableOrder extends Equatable {
/// Creates an [AvailableOrder].
const AvailableOrder({
required this.orderId,
required this.orderType,
required this.roleId,
required this.roleCode,
required this.roleName,
this.clientName = '',
this.location = '',
this.locationAddress = '',
required this.hourlyRateCents,
required this.hourlyRate,
required this.requiredWorkerCount,
required this.filledCount,
required this.instantBook,
this.dispatchTeam = '',
this.dispatchPriority = 0,
required this.schedule,
});
/// Deserialises from the V2 API JSON response.
factory AvailableOrder.fromJson(Map<String, dynamic> json) {
return AvailableOrder(
orderId: json['orderId'] as String,
orderType: OrderType.fromJson(json['orderType'] as String?),
roleId: json['roleId'] as String,
roleCode: json['roleCode'] as String? ?? '',
roleName: json['roleName'] as String? ?? '',
clientName: json['clientName'] as String? ?? '',
location: json['location'] as String? ?? '',
locationAddress: json['locationAddress'] as String? ?? '',
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
requiredWorkerCount: json['requiredWorkerCount'] as int? ?? 1,
filledCount: json['filledCount'] as int? ?? 0,
instantBook: json['instantBook'] as bool? ?? false,
dispatchTeam: json['dispatchTeam'] as String? ?? '',
dispatchPriority: json['dispatchPriority'] as int? ?? 0,
schedule: AvailableOrderSchedule.fromJson(
json['schedule'] as Map<String, dynamic>,
),
);
}
/// The order row id.
final String orderId;
/// Type of order (one-time, recurring, permanent, etc.).
final OrderType orderType;
/// The shift-role row id.
final String roleId;
/// Machine-readable role code.
final String roleCode;
/// Display name of the role.
final String roleName;
/// Name of the client/business offering this order.
final String clientName;
/// Human-readable location label.
final String location;
/// Full street address of the location.
final String locationAddress;
/// Pay rate in cents per hour.
final int hourlyRateCents;
/// Pay rate in dollars per hour.
final double hourlyRate;
/// Total number of workers required for this role.
final int requiredWorkerCount;
/// Number of positions already filled.
final int filledCount;
/// Whether the order supports instant booking (no approval needed).
final bool instantBook;
/// Dispatch team identifier.
final String dispatchTeam;
/// Priority level for dispatch ordering.
final int dispatchPriority;
/// Schedule details including recurrence, times, and bounding timestamps.
final AvailableOrderSchedule schedule;
/// Serialises to JSON.
Map<String, dynamic> toJson() {
return <String, dynamic>{
'orderId': orderId,
'orderType': orderType.toJson(),
'roleId': roleId,
'roleCode': roleCode,
'roleName': roleName,
'clientName': clientName,
'location': location,
'locationAddress': locationAddress,
'hourlyRateCents': hourlyRateCents,
'hourlyRate': hourlyRate,
'requiredWorkerCount': requiredWorkerCount,
'filledCount': filledCount,
'instantBook': instantBook,
'dispatchTeam': dispatchTeam,
'dispatchPriority': dispatchPriority,
'schedule': schedule.toJson(),
};
}
@override
List<Object?> get props => <Object?>[
orderId,
orderType,
roleId,
roleCode,
roleName,
clientName,
location,
locationAddress,
hourlyRateCents,
hourlyRate,
requiredWorkerCount,
filledCount,
instantBook,
dispatchTeam,
dispatchPriority,
schedule,
];
}

View File

@@ -0,0 +1,99 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/src/core/utils/utc_parser.dart';
import 'package:krow_domain/src/entities/enums/day_of_week.dart';
/// Schedule details for an available order in the marketplace.
///
/// Contains the recurrence pattern, time window, and bounding timestamps
/// for the order's shifts.
class AvailableOrderSchedule extends Equatable {
/// Creates an [AvailableOrderSchedule].
const AvailableOrderSchedule({
required this.totalShifts,
required this.startDate,
required this.endDate,
required this.daysOfWeek,
required this.startTime,
required this.endTime,
required this.timezone,
required this.firstShiftStartsAt,
required this.lastShiftEndsAt,
});
/// Deserialises from the V2 API JSON response.
factory AvailableOrderSchedule.fromJson(Map<String, dynamic> json) {
return AvailableOrderSchedule(
totalShifts: json['totalShifts'] as int? ?? 0,
startDate: json['startDate'] as String? ?? '',
endDate: json['endDate'] as String? ?? '',
daysOfWeek: (json['daysOfWeek'] as List<dynamic>?)
?.map(
(dynamic e) => DayOfWeek.fromJson(e as String),
)
.toList() ??
<DayOfWeek>[],
startTime: json['startTime'] as String? ?? '',
endTime: json['endTime'] as String? ?? '',
timezone: json['timezone'] as String? ?? 'UTC',
firstShiftStartsAt:
parseUtcToLocal(json['firstShiftStartsAt'] as String),
lastShiftEndsAt: parseUtcToLocal(json['lastShiftEndsAt'] as String),
);
}
/// Total number of shifts in this schedule.
final int totalShifts;
/// Date-only start string (e.g. "2026-03-24").
final String startDate;
/// Date-only end string.
final String endDate;
/// Days of the week the order repeats on.
final List<DayOfWeek> daysOfWeek;
/// Daily start time display string (e.g. "09:00").
final String startTime;
/// Daily end time display string (e.g. "15:00").
final String endTime;
/// IANA timezone identifier (e.g. "America/Los_Angeles").
final String timezone;
/// UTC timestamp of the first shift's start, converted to local time.
final DateTime firstShiftStartsAt;
/// UTC timestamp of the last shift's end, converted to local time.
final DateTime lastShiftEndsAt;
/// Serialises to JSON.
Map<String, dynamic> toJson() => <String, dynamic>{
'totalShifts': totalShifts,
'startDate': startDate,
'endDate': endDate,
'daysOfWeek':
daysOfWeek.map((DayOfWeek e) => e.toJson()).toList(),
'startTime': startTime,
'endTime': endTime,
'timezone': timezone,
'firstShiftStartsAt':
firstShiftStartsAt.toUtc().toIso8601String(),
'lastShiftEndsAt': lastShiftEndsAt.toUtc().toIso8601String(),
};
@override
List<Object?> get props => <Object?>[
totalShifts,
startDate,
endDate,
daysOfWeek,
startTime,
endTime,
timezone,
firstShiftStartsAt,
lastShiftEndsAt,
];
}

View File

@@ -0,0 +1,92 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/src/core/utils/utc_parser.dart';
/// A shift assigned to a staff member as part of an order booking.
///
/// Returned within the `assignedShifts` array of the
/// `POST /staff/orders/:orderId/book` response.
class BookingAssignedShift extends Equatable {
/// Creates a [BookingAssignedShift].
const BookingAssignedShift({
required this.shiftId,
required this.date,
required this.startsAt,
required this.endsAt,
required this.startTime,
required this.endTime,
required this.timezone,
required this.assignmentId,
this.assignmentStatus = '',
});
/// Deserialises from the V2 API JSON response.
factory BookingAssignedShift.fromJson(Map<String, dynamic> json) {
return BookingAssignedShift(
shiftId: json['shiftId'] as String,
date: json['date'] as String? ?? '',
startsAt: parseUtcToLocal(json['startsAt'] as String),
endsAt: parseUtcToLocal(json['endsAt'] as String),
startTime: json['startTime'] as String? ?? '',
endTime: json['endTime'] as String? ?? '',
timezone: json['timezone'] as String? ?? 'UTC',
assignmentId: json['assignmentId'] as String,
assignmentStatus: json['assignmentStatus'] as String? ?? '',
);
}
/// The shift row id.
final String shiftId;
/// Date-only display string (e.g. "2026-03-24").
final String date;
/// UTC start timestamp converted to local time.
final DateTime startsAt;
/// UTC end timestamp converted to local time.
final DateTime endsAt;
/// Display start time string (e.g. "09:00").
final String startTime;
/// Display end time string (e.g. "15:00").
final String endTime;
/// IANA timezone identifier.
final String timezone;
/// The assignment row id linking staff to this shift.
final String assignmentId;
/// Current status of the assignment (e.g. "ASSIGNED").
final String assignmentStatus;
/// Serialises to JSON.
Map<String, dynamic> toJson() {
return <String, dynamic>{
'shiftId': shiftId,
'date': date,
'startsAt': startsAt.toUtc().toIso8601String(),
'endsAt': endsAt.toUtc().toIso8601String(),
'startTime': startTime,
'endTime': endTime,
'timezone': timezone,
'assignmentId': assignmentId,
'assignmentStatus': assignmentStatus,
};
}
@override
List<Object?> get props => <Object?>[
shiftId,
date,
startsAt,
endsAt,
startTime,
endTime,
timezone,
assignmentId,
assignmentStatus,
];
}

View File

@@ -0,0 +1,94 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/src/entities/orders/booking_assigned_shift.dart';
/// Result of booking an order via `POST /staff/orders/:orderId/book`.
///
/// Contains the booking metadata and the list of shifts assigned to the
/// staff member as part of this booking.
class OrderBooking extends Equatable {
/// Creates an [OrderBooking].
const OrderBooking({
required this.bookingId,
required this.orderId,
required this.roleId,
this.roleCode = '',
this.roleName = '',
required this.assignedShiftCount,
this.status = 'PENDING',
this.assignedShifts = const <BookingAssignedShift>[],
});
/// Deserialises from the V2 API JSON response.
factory OrderBooking.fromJson(Map<String, dynamic> json) {
return OrderBooking(
bookingId: json['bookingId'] as String,
orderId: json['orderId'] as String,
roleId: json['roleId'] as String,
roleCode: json['roleCode'] as String? ?? '',
roleName: json['roleName'] as String? ?? '',
assignedShiftCount: json['assignedShiftCount'] as int? ?? 0,
status: json['status'] as String? ?? 'PENDING',
assignedShifts: (json['assignedShifts'] as List<dynamic>?)
?.map(
(dynamic e) => BookingAssignedShift.fromJson(
e as Map<String, dynamic>,
),
)
.toList() ??
<BookingAssignedShift>[],
);
}
/// Unique booking identifier.
final String bookingId;
/// The order this booking belongs to.
final String orderId;
/// The role row id within the order.
final String roleId;
/// Machine-readable role code.
final String roleCode;
/// Display name of the role.
final String roleName;
/// Number of shifts assigned in this booking.
final int assignedShiftCount;
/// Booking status (e.g. "PENDING", "CONFIRMED").
final String status;
/// The individual shifts assigned as part of this booking.
final List<BookingAssignedShift> assignedShifts;
/// Serialises to JSON.
Map<String, dynamic> toJson() {
return <String, dynamic>{
'bookingId': bookingId,
'orderId': orderId,
'roleId': roleId,
'roleCode': roleCode,
'roleName': roleName,
'assignedShiftCount': assignedShiftCount,
'status': status,
'assignedShifts': assignedShifts
.map((BookingAssignedShift e) => e.toJson())
.toList(),
};
}
@override
List<Object?> get props => <Object?>[
bookingId,
orderId,
roleId,
roleCode,
roleName,
assignedShiftCount,
status,
assignedShifts,
];
}

View File

@@ -165,4 +165,38 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
final ProfileCompletion completion = ProfileCompletion.fromJson(data); final ProfileCompletion completion = ProfileCompletion.fromJson(data);
return completion.completed; 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. /// Only allowed for shifts in CHECKED_OUT or COMPLETED status.
Future<void> submitForApproval(String shiftId, {String? note}); 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:core_localization/core_localization.dart';
import 'package:krow_domain/krow_domain.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/blocs/shifts/shifts_bloc.dart';
import 'package:staff_shifts/src/presentation/utils/shift_tab_type.dart'; import 'package:staff_shifts/src/presentation/utils/shift_tab_type.dart';
import 'package:staff_shifts/src/presentation/widgets/shifts_page_skeleton.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). /// 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 { class ShiftsPage extends StatefulWidget {
/// Creates a [ShiftsPage]. /// Creates a [ShiftsPage].
/// ///
@@ -45,9 +49,9 @@ class _ShiftsPageState extends State<ShiftsPage> {
late ShiftTabType _activeTab; late ShiftTabType _activeTab;
DateTime? _selectedDate; DateTime? _selectedDate;
bool _prioritizeFind = false; bool _prioritizeFind = false;
bool _refreshAvailable = false;
bool _pendingAvailableRefresh = false; bool _pendingAvailableRefresh = false;
final ShiftsBloc _bloc = Modular.get<ShiftsBloc>(); final ShiftsBloc _bloc = Modular.get<ShiftsBloc>();
final AvailableOrdersBloc _ordersBloc = Modular.get<AvailableOrdersBloc>();
@override @override
void initState() { void initState() {
@@ -55,7 +59,6 @@ class _ShiftsPageState extends State<ShiftsPage> {
_activeTab = widget.initialTab ?? ShiftTabType.find; _activeTab = widget.initialTab ?? ShiftTabType.find;
_selectedDate = widget.selectedDate; _selectedDate = widget.selectedDate;
_prioritizeFind = _activeTab == ShiftTabType.find; _prioritizeFind = _activeTab == ShiftTabType.find;
_refreshAvailable = widget.refreshAvailable;
_pendingAvailableRefresh = widget.refreshAvailable; _pendingAvailableRefresh = widget.refreshAvailable;
if (_prioritizeFind) { if (_prioritizeFind) {
_bloc.add(LoadFindFirstEvent()); _bloc.add(LoadFindFirstEvent());
@@ -66,9 +69,8 @@ class _ShiftsPageState extends State<ShiftsPage> {
_bloc.add(LoadHistoryShiftsEvent()); _bloc.add(LoadHistoryShiftsEvent());
} }
if (_activeTab == ShiftTabType.find) { if (_activeTab == ShiftTabType.find) {
if (!_prioritizeFind) { // Load available orders via the new BLoC.
_bloc.add(LoadAvailableShiftsEvent(force: _refreshAvailable)); _ordersBloc.add(const LoadAvailableOrdersEvent());
}
} }
// Check profile completion // Check profile completion
@@ -90,160 +92,193 @@ class _ShiftsPageState extends State<ShiftsPage> {
}); });
} }
if (widget.refreshAvailable) { if (widget.refreshAvailable) {
_refreshAvailable = true;
_pendingAvailableRefresh = true; _pendingAvailableRefresh = true;
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = Translations.of(context); final Translations t = Translations.of(context);
return BlocProvider.value( return MultiBlocProvider(
value: _bloc, providers: <BlocProvider<dynamic>>[
child: BlocConsumer<ShiftsBloc, ShiftsState>( BlocProvider<ShiftsBloc>.value(value: _bloc),
listener: (context, state) { BlocProvider<AvailableOrdersBloc>.value(value: _ordersBloc),
if (state.status == ShiftsStatus.error && ],
state.errorMessage != null) { 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( UiSnackbar.show(
context, 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, type: UiSnackbarType.error,
); );
} }
}, },
builder: (context, state) { child: BlocConsumer<ShiftsBloc, ShiftsState>(
if (_pendingAvailableRefresh && state.status == ShiftsStatus.loaded) { listener: (BuildContext context, ShiftsState state) {
_pendingAvailableRefresh = false; if (state.status == ShiftsStatus.error &&
_bloc.add(const LoadAvailableShiftsEvent(force: true)); state.errorMessage != null) {
} UiSnackbar.show(
final bool baseLoaded = state.status == ShiftsStatus.loaded; context,
final List<AssignedShift> myShifts = state.myShifts; message: translateErrorKey(state.errorMessage!),
final List<OpenShift> availableJobs = state.availableShifts; type: UiSnackbarType.error,
final bool availableLoading = state.availableLoading; );
final bool availableLoaded = state.availableLoaded; }
final List<PendingAssignment> pendingAssignments = state.pendingShifts; },
final List<CancelledShift> cancelledShifts = state.cancelledShifts; builder: (BuildContext context, ShiftsState state) {
final List<CompletedShift> historyShifts = state.historyShifts; if (_pendingAvailableRefresh &&
final bool historyLoading = state.historyLoading; state.status == ShiftsStatus.loaded) {
final bool historyLoaded = state.historyLoaded; _pendingAvailableRefresh = false;
final bool myShiftsLoaded = state.myShiftsLoaded; _ordersBloc.add(const LoadAvailableOrdersEvent());
final bool blockTabsForFind = _prioritizeFind && !availableLoaded; }
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 return Scaffold(
// Note: Calendar logic moved to MyShiftsTab body: Column(
children: <Widget>[
return Scaffold( // Header (Blue)
body: Column( Container(
children: [ color: UiColors.primary,
// Header (Blue) padding: EdgeInsets.fromLTRB(
Container( UiConstants.space5,
color: UiColors.primary, MediaQuery.of(context).padding.top + UiConstants.space2,
padding: EdgeInsets.fromLTRB( UiConstants.space5,
UiConstants.space5, UiConstants.space5,
MediaQuery.of(context).padding.top + UiConstants.space2, ),
UiConstants.space5, child: Column(
UiConstants.space5, crossAxisAlignment: CrossAxisAlignment.start,
), spacing: UiConstants.space4,
child: Column( children: <Widget>[
crossAxisAlignment: CrossAxisAlignment.start, Text(
spacing: UiConstants.space4, t.staff_shifts.title,
children: [ style: UiTypography.display1b.white,
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,
), ),
),
], // 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<AssignedShift> myShifts,
List<PendingAssignment> pendingAssignments, List<PendingAssignment> pendingAssignments,
List<CancelledShift> cancelledShifts, List<CancelledShift> cancelledShifts,
List<OpenShift> availableJobs,
List<CompletedShift> historyShifts, List<CompletedShift> historyShifts,
bool availableLoading,
bool historyLoading, bool historyLoading,
) { ) {
switch (_activeTab) { switch (_activeTab) {
@@ -269,12 +302,23 @@ class _ShiftsPageState extends State<ShiftsPage> {
submittingShiftId: state.submittingShiftId, submittingShiftId: state.submittingShiftId,
); );
case ShiftTabType.find: case ShiftTabType.find:
if (availableLoading) { return BlocBuilder<AvailableOrdersBloc, AvailableOrdersState>(
return const ShiftsPageSkeleton(); builder:
} (BuildContext context, AvailableOrdersState ordersState) {
return FindShiftsTab( if (ordersState.status == AvailableOrdersStatus.loading) {
availableJobs: availableJobs, return const ShiftsPageSkeleton();
profileComplete: state.profileComplete ?? true, }
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: case ShiftTabType.history:
if (historyLoading) { if (historyLoading) {
@@ -296,7 +340,7 @@ class _ShiftsPageState extends State<ShiftsPage> {
bool showCount = true, bool showCount = true,
bool enabled = true, bool enabled = true,
}) { }) {
final isActive = _activeTab == type; final bool isActive = _activeTab == type;
return Expanded( return Expanded(
child: GestureDetector( child: GestureDetector(
onTap: !enabled onTap: !enabled
@@ -307,7 +351,7 @@ class _ShiftsPageState extends State<ShiftsPage> {
_bloc.add(LoadHistoryShiftsEvent()); _bloc.add(LoadHistoryShiftsEvent());
} }
if (type == ShiftTabType.find) { if (type == ShiftTabType.find) {
_bloc.add(LoadAvailableShiftsEvent()); _ordersBloc.add(const LoadAvailableOrdersEvent());
} }
}, },
child: Container( child: Container(
@@ -324,35 +368,33 @@ class _ShiftsPageState extends State<ShiftsPage> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: <Widget>[
Icon( Icon(
icon, icon,
size: 14, size: 14,
color: !enabled color: !enabled
? UiColors.white.withValues(alpha: 0.5) ? UiColors.white.withValues(alpha: 0.5)
: isActive : isActive
? UiColors.primary ? UiColors.primary
: UiColors.white, : UiColors.white,
), ),
const SizedBox(width: UiConstants.space1), const SizedBox(width: UiConstants.space1),
Flexible( Flexible(
child: Text( child: Text(
label, label,
style: style: (isActive
(isActive ? UiTypography.body3m
? UiTypography.body3m.copyWith( .copyWith(color: UiColors.primary)
color: UiColors.primary, : UiTypography.body3m.white)
) .copyWith(
: UiTypography.body3m.white) color: !enabled
.copyWith( ? UiColors.white.withValues(alpha: 0.5)
color: !enabled : null,
? UiColors.white.withValues(alpha: 0.5) ),
: null,
),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
if (showCount) ...[ if (showCount) ...<Widget>[
const SizedBox(width: UiConstants.space1), const SizedBox(width: UiConstants.space1),
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@@ -368,7 +410,7 @@ class _ShiftsPageState extends State<ShiftsPage> {
), ),
child: Center( child: Center(
child: Text( child: Text(
"$count", '$count',
style: UiTypography.footnote1b.copyWith( style: UiTypography.footnote1b.copyWith(
color: isActive ? UiColors.primary : UiColors.white, 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:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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'; 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 { class FindShiftsTab extends StatefulWidget {
/// Creates a [FindShiftsTab]. /// Creates a [FindShiftsTab].
const FindShiftsTab({ const FindShiftsTab({
super.key, super.key,
required this.availableJobs, required this.availableOrders,
this.profileComplete = true, this.profileComplete = true,
required this.onBook,
this.bookingInProgress = false,
}); });
/// Open shifts loaded from the V2 API. /// Available orders loaded from the V2 API.
final List<OpenShift> availableJobs; final List<AvailableOrder> availableOrders;
/// Whether the worker's profile is complete. /// Whether the worker's profile is complete.
final bool profileComplete; final bool profileComplete;
/// Callback when the worker taps book/apply on an order card.
final void Function(String orderId, String roleId) onBook;
/// Whether a booking request is currently in flight.
final bool bookingInProgress;
@override @override
State<FindShiftsTab> createState() => _FindShiftsTabState(); State<FindShiftsTab> createState() => _FindShiftsTabState();
} }
@@ -30,18 +40,7 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
String _searchQuery = ''; String _searchQuery = '';
String _jobType = 'all'; String _jobType = 'all';
String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt); /// Builds a filter tab chip.
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);
}
Widget _buildFilterTab(String id, String label) { Widget _buildFilterTab(String id, String label) {
final bool isSelected = _jobType == id; final bool isSelected = _jobType == id;
return GestureDetector( return GestureDetector(
@@ -69,178 +68,27 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
); );
} }
List<OpenShift> _filterByType(List<OpenShift> shifts) { /// Filters orders by the selected order type tab.
if (_jobType == 'all') return shifts; List<AvailableOrder> _filterByType(List<AvailableOrder> orders) {
return shifts.where((OpenShift s) { if (_jobType == 'all') return orders;
if (_jobType == 'one-day') return s.orderType == OrderType.oneTime; return orders.where((AvailableOrder o) {
if (_jobType == 'multi-day') return s.orderType == OrderType.recurring; if (_jobType == 'one-day') return o.orderType == OrderType.oneTime;
if (_jobType == 'long-term') return s.orderType == OrderType.permanent; if (_jobType == 'multi-day') return o.orderType == OrderType.recurring;
if (_jobType == 'long-term') return o.orderType == OrderType.permanent;
return true; return true;
}).toList(); }).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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Client-side filter by order type // Client-side filter by order type and search query
final List<OpenShift> filteredJobs = final List<AvailableOrder> filteredOrders =
_filterByType(widget.availableJobs).where((OpenShift s) { _filterByType(widget.availableOrders).where((AvailableOrder o) {
if (_searchQuery.isEmpty) return true; if (_searchQuery.isEmpty) return true;
final String q = _searchQuery.toLowerCase(); final String q = _searchQuery.toLowerCase();
return s.roleName.toLowerCase().contains(q) || return o.roleName.toLowerCase().contains(q) ||
s.location.toLowerCase().contains(q); o.clientName.toLowerCase().contains(q) ||
o.location.toLowerCase().contains(q);
}).toList(); }).toList();
return Column( return Column(
@@ -322,7 +170,7 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
), ),
), ),
Expanded( Expanded(
child: filteredJobs.isEmpty child: filteredOrders.isEmpty
? EmptyStateView( ? EmptyStateView(
icon: UiIcons.search, icon: UiIcons.search,
title: context.t.staff_shifts.find_shifts.no_jobs_title, title: context.t.staff_shifts.find_shifts.no_jobs_title,
@@ -335,9 +183,12 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
child: Column( child: Column(
children: <Widget>[ children: <Widget>[
const SizedBox(height: UiConstants.space5), const SizedBox(height: UiConstants.space5),
...filteredJobs.map( ...filteredOrders.map(
(OpenShift shift) => (AvailableOrder order) => AvailableOrderCard(
_buildOpenShiftCard(context, shift), order: order,
onBook: widget.onBook,
bookingInProgress: widget.bookingInProgress,
),
), ),
const SizedBox(height: UiConstants.space32), 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/get_shift_details_usecase.dart';
import 'package:staff_shifts/src/domain/usecases/accept_shift_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/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/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/shifts/shifts_bloc.dart';
import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_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'; import 'package:staff_shifts/src/presentation/utils/shift_tab_type.dart';
@@ -49,6 +52,8 @@ class StaffShiftsModule extends Module {
i.addLazySingleton( i.addLazySingleton(
() => SubmitForApprovalUseCase(i.get<ShiftsRepositoryInterface>()), () => SubmitForApprovalUseCase(i.get<ShiftsRepositoryInterface>()),
); );
i.addLazySingleton(GetAvailableOrdersUseCase.new);
i.addLazySingleton(BookOrderUseCase.new);
// BLoC // BLoC
i.add( i.add(
@@ -72,6 +77,12 @@ class StaffShiftsModule extends Module {
getProfileCompletion: i.get(), getProfileCompletion: i.get(),
), ),
); );
i.add(
() => AvailableOrdersBloc(
getAvailableOrders: i.get(),
bookOrder: i.get(),
),
);
} }
@override @override