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:
@@ -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>);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user