Merge branch 'origin/dev' into feature/session-persistence-new
This commit is contained in:
@@ -85,3 +85,31 @@ History state is cached in BLoC as `Map<String, AsyncValue<List<BenefitHistory>>
|
|||||||
|
|
||||||
Uses `CustomScrollView` with `SliverList` for header + `SliverPadding` wrapping `SliverList.separated` for content.
|
Uses `CustomScrollView` with `SliverList` for header + `SliverPadding` wrapping `SliverList.separated` for content.
|
||||||
Bottom padding on content sliver: `EdgeInsets.fromLTRB(16, 16, 16, 120)` to clear bottom nav bar.
|
Bottom padding on content sliver: `EdgeInsets.fromLTRB(16, 16, 16, 120)` to clear bottom nav bar.
|
||||||
|
|
||||||
|
## ShiftDateTimeSection / OrderScheduleSection — Shift Detail Section Pattern
|
||||||
|
|
||||||
|
Both widgets live in `packages/features/staff/shifts/lib/src/presentation/widgets/`:
|
||||||
|
- `shift_details/shift_date_time_section.dart` — single date, clock-in/clock-out boxes
|
||||||
|
- `order_details/order_schedule_section.dart` — date range, 7-day circle row, clock-in/clock-out boxes
|
||||||
|
|
||||||
|
**Shared conventions (non-negotiable for section consistency):**
|
||||||
|
- Outer padding: `EdgeInsets.all(UiConstants.space5)` — 20dp all sides
|
||||||
|
- Section title: `UiTypography.titleUppercase4b.textSecondary`
|
||||||
|
- Title → content gap: `UiConstants.space2` (8dp)
|
||||||
|
- Time boxes: `UiColors.bgThird` background, `UiConstants.radiusBase` (12dp) corners, `UiConstants.space3` (12dp) all padding
|
||||||
|
- Time box label: `UiTypography.footnote2b.copyWith(color: UiColors.textSecondary, letterSpacing: 0.5)`
|
||||||
|
- Time box value: `UiTypography.title1m.copyWith(fontWeight: FontWeight.w700).textPrimary`
|
||||||
|
- Between time boxes: `UiConstants.space4` (16dp) gap
|
||||||
|
- Date → time boxes gap: `UiConstants.space6` (24dp)
|
||||||
|
- Time format: `DateFormat('h:mm a')` — uppercase AM/PM with space
|
||||||
|
|
||||||
|
**OrderScheduleSection day-of-week circles:**
|
||||||
|
- 7 circles always shown (Mon–Sun ISO order) regardless of active days
|
||||||
|
- Circle size: 32×32dp (fixed, not a token)
|
||||||
|
- Active: bg=`UiColors.primary`, text=`UiColors.white`, style=`footnote2m`
|
||||||
|
- Inactive: bg=`UiColors.bgThird`, text=`UiColors.textSecondary`, style=`footnote2m`
|
||||||
|
- Shape: `UiConstants.radiusFull`
|
||||||
|
- Single-char labels: M T W T F S S
|
||||||
|
- Inter-circle gap: `UiConstants.space2` (8dp)
|
||||||
|
- Accessibility: wrap row with `Semantics(label: "Repeats on ...")`, mark individual circles with `ExcludeSemantics`
|
||||||
|
- Ordering constant: `[DayOfWeek.mon, .tue, .wed, .thu, .fri, .sat, .sun]` — do NOT derive from API list order
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ class AppConfig {
|
|||||||
/// The base URL for the V2 Unified API gateway.
|
/// The base URL for the V2 Unified API gateway.
|
||||||
static const String v2ApiBaseUrl = String.fromEnvironment(
|
static const String v2ApiBaseUrl = String.fromEnvironment(
|
||||||
'V2_API_BASE_URL',
|
'V2_API_BASE_URL',
|
||||||
defaultValue: 'https://krow-api-v2-933560802882.us-central1.run.app',
|
defaultValue: 'https://krow-api-v2-e3g6witsvq-uc.a.run.app',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,13 @@ extension StaffNavigator on IModularNavigator {
|
|||||||
safeNavigate(StaffPaths.shiftDetails(shift.id), arguments: shift);
|
safeNavigate(StaffPaths.shiftDetails(shift.id), arguments: shift);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Navigates to the order details page for a given [AvailableOrder].
|
||||||
|
///
|
||||||
|
/// The order is passed as a data argument to the route.
|
||||||
|
void toOrderDetails(AvailableOrder order) {
|
||||||
|
safePush(StaffPaths.orderDetailsRoute, arguments: order);
|
||||||
|
}
|
||||||
|
|
||||||
/// Navigates to shift details by ID only (no pre-fetched [Shift] object).
|
/// Navigates to shift details by ID only (no pre-fetched [Shift] object).
|
||||||
///
|
///
|
||||||
/// Used when only the shift ID is available (e.g. from dashboard list items).
|
/// Used when only the shift ID is available (e.g. from dashboard list items).
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ class StaffPaths {
|
|||||||
/// View detailed information for a specific shift.
|
/// View detailed information for a specific shift.
|
||||||
static const String shiftDetailsRoute = '/worker-main/shift-details';
|
static const String shiftDetailsRoute = '/worker-main/shift-details';
|
||||||
|
|
||||||
|
/// Order details route.
|
||||||
|
///
|
||||||
|
/// View detailed information for an available order and book/apply.
|
||||||
|
static const String orderDetailsRoute = '/worker-main/order-details';
|
||||||
|
|
||||||
/// Shift details page (dynamic).
|
/// Shift details page (dynamic).
|
||||||
///
|
///
|
||||||
/// View detailed information for a specific shift.
|
/// View detailed information for a specific shift.
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ abstract final class StaffEndpoints {
|
|||||||
static const ApiEndpoint profileCompletion =
|
static const ApiEndpoint profileCompletion =
|
||||||
ApiEndpoint('/staff/profile-completion');
|
ApiEndpoint('/staff/profile-completion');
|
||||||
|
|
||||||
|
/// Staff reliability and performance statistics.
|
||||||
|
static const ApiEndpoint profileStats =
|
||||||
|
ApiEndpoint('/staff/profile/stats');
|
||||||
|
|
||||||
/// Staff availability schedule.
|
/// Staff availability schedule.
|
||||||
static const ApiEndpoint availability = ApiEndpoint('/staff/availability');
|
static const ApiEndpoint availability = ApiEndpoint('/staff/availability');
|
||||||
|
|
||||||
@@ -126,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.
|
||||||
@@ -194,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');
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class AuthInterceptor extends Interceptor {
|
|||||||
if (!skipAuth) {
|
if (!skipAuth) {
|
||||||
final User? user = FirebaseAuth.instance.currentUser;
|
final User? user = FirebaseAuth.instance.currentUser;
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
final String? token = await user.getIdToken();
|
final String? token = await user.getIdToken();
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
options.headers['Authorization'] = 'Bearer $token';
|
options.headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,4 +4,9 @@ class DateTimeUtils {
|
|||||||
static DateTime toDeviceTime(DateTime date) {
|
static DateTime toDeviceTime(DateTime date) {
|
||||||
return date.toLocal();
|
return date.toLocal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Converts a local [DateTime] back to UTC for API payloads.
|
||||||
|
static String toUtcIso(DateTime local) {
|
||||||
|
return local.toUtc().toIso8601String();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,3 +68,45 @@ String formatTime(String timeStr) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Converts a local date + local HH:MM time string to a UTC HH:MM string.
|
||||||
|
///
|
||||||
|
/// Combines [localDate] with the hours and minutes from [localTime] (e.g.
|
||||||
|
/// "09:00") to create a full local [DateTime], converts it to UTC, then
|
||||||
|
/// extracts the HH:MM portion.
|
||||||
|
///
|
||||||
|
/// Example: March 19, "21:00" in UTC-5 → "02:00" (next day UTC).
|
||||||
|
String toUtcTimeHHmm(DateTime localDate, String localTime) {
|
||||||
|
final List<String> parts = localTime.split(':');
|
||||||
|
final DateTime localDt = DateTime(
|
||||||
|
localDate.year,
|
||||||
|
localDate.month,
|
||||||
|
localDate.day,
|
||||||
|
int.parse(parts[0]),
|
||||||
|
int.parse(parts[1]),
|
||||||
|
);
|
||||||
|
final DateTime utcDt = localDt.toUtc();
|
||||||
|
return '${utcDt.hour.toString().padLeft(2, '0')}:'
|
||||||
|
'${utcDt.minute.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a local date + local HH:MM time string to a UTC YYYY-MM-DD string.
|
||||||
|
///
|
||||||
|
/// This accounts for date-boundary crossings: a shift at 11 PM on March 19
|
||||||
|
/// in UTC-5 is actually March 20 in UTC.
|
||||||
|
///
|
||||||
|
/// Example: March 19, "23:00" in UTC-5 → "2026-03-20".
|
||||||
|
String toUtcDateIso(DateTime localDate, String localTime) {
|
||||||
|
final List<String> parts = localTime.split(':');
|
||||||
|
final DateTime localDt = DateTime(
|
||||||
|
localDate.year,
|
||||||
|
localDate.month,
|
||||||
|
localDate.day,
|
||||||
|
int.parse(parts[0]),
|
||||||
|
int.parse(parts[1]),
|
||||||
|
);
|
||||||
|
final DateTime utcDt = localDt.toUtc();
|
||||||
|
return '${utcDt.year.toString().padLeft(4, '0')}-'
|
||||||
|
'${utcDt.month.toString().padLeft(2, '0')}-'
|
||||||
|
'${utcDt.day.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|||||||
@@ -1362,9 +1362,11 @@
|
|||||||
"go_to_certificates": "Go to Certificates",
|
"go_to_certificates": "Go to Certificates",
|
||||||
"shift_booked": "Shift successfully booked!",
|
"shift_booked": "Shift successfully booked!",
|
||||||
"shift_not_found": "Shift not found",
|
"shift_not_found": "Shift not found",
|
||||||
|
"shift_accepted": "Shift accepted successfully!",
|
||||||
"shift_declined_success": "Shift declined",
|
"shift_declined_success": "Shift declined",
|
||||||
"complete_account_title": "Complete Your Account",
|
"complete_account_title": "Complete Your Account",
|
||||||
"complete_account_description": "Complete your account to book this shift and start earning"
|
"complete_account_description": "Complete your account to book this shift and start earning",
|
||||||
|
"shift_cancelled": "Shift Cancelled"
|
||||||
},
|
},
|
||||||
"my_shift_card": {
|
"my_shift_card": {
|
||||||
"submit_for_approval": "Submit for Approval",
|
"submit_for_approval": "Submit for Approval",
|
||||||
@@ -1877,5 +1879,31 @@
|
|||||||
"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",
|
||||||
|
"fully_staffed": "Fully Staffed",
|
||||||
|
"spots_left": "${count} spot(s) left",
|
||||||
|
"shifts_count": "${count} shift(s)",
|
||||||
|
"schedule_label": "SCHEDULE",
|
||||||
|
"date_range_label": "Date Range",
|
||||||
|
"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",
|
||||||
|
"book_dialog": {
|
||||||
|
"title": "Book this order?",
|
||||||
|
"message": "This will book you for all ${count} shift(s) in this order.",
|
||||||
|
"confirm": "Confirm Booking"
|
||||||
|
},
|
||||||
|
"booking_dialog": {
|
||||||
|
"title": "Booking order..."
|
||||||
|
},
|
||||||
|
"order_booked_pending": "Order booking submitted! Awaiting approval.",
|
||||||
|
"order_booked_confirmed": "Order booked and confirmed!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1357,9 +1357,11 @@
|
|||||||
"go_to_certificates": "Ir a Certificados",
|
"go_to_certificates": "Ir a Certificados",
|
||||||
"shift_booked": "¡Turno reservado con éxito!",
|
"shift_booked": "¡Turno reservado con éxito!",
|
||||||
"shift_not_found": "Turno no encontrado",
|
"shift_not_found": "Turno no encontrado",
|
||||||
|
"shift_accepted": "¡Turno aceptado con éxito!",
|
||||||
"shift_declined_success": "Turno rechazado",
|
"shift_declined_success": "Turno rechazado",
|
||||||
"complete_account_title": "Completa Tu Cuenta",
|
"complete_account_title": "Completa Tu Cuenta",
|
||||||
"complete_account_description": "Completa tu cuenta para reservar este turno y comenzar a ganar"
|
"complete_account_description": "Completa tu cuenta para reservar este turno y comenzar a ganar",
|
||||||
|
"shift_cancelled": "Turno Cancelado"
|
||||||
},
|
},
|
||||||
"my_shift_card": {
|
"my_shift_card": {
|
||||||
"submit_for_approval": "Enviar para Aprobación",
|
"submit_for_approval": "Enviar para Aprobación",
|
||||||
@@ -1877,5 +1879,31 @@
|
|||||||
"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",
|
||||||
|
"fully_staffed": "Completamente dotado",
|
||||||
|
"spots_left": "${count} puesto(s) disponible(s)",
|
||||||
|
"shifts_count": "${count} turno(s)",
|
||||||
|
"schedule_label": "HORARIO",
|
||||||
|
"date_range_label": "Rango de Fechas",
|
||||||
|
"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",
|
||||||
|
"book_dialog": {
|
||||||
|
"title": "\u00bfReservar esta orden?",
|
||||||
|
"message": "Esto te reservar\u00e1 para los ${count} turno(s) de esta orden.",
|
||||||
|
"confirm": "Confirmar Reserva"
|
||||||
|
},
|
||||||
|
"booking_dialog": {
|
||||||
|
"title": "Reservando orden..."
|
||||||
|
},
|
||||||
|
"order_booked_pending": "\u00a1Reserva de orden enviada! Esperando aprobaci\u00f3n.",
|
||||||
|
"order_booked_confirmed": "\u00a1Orden reservada y confirmada!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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';
|
||||||
@@ -25,6 +26,9 @@ export 'src/entities/enums/staff_skill.dart';
|
|||||||
export 'src/entities/enums/staff_status.dart';
|
export 'src/entities/enums/staff_status.dart';
|
||||||
export 'src/entities/enums/user_role.dart';
|
export 'src/entities/enums/user_role.dart';
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
export 'src/core/utils/utc_parser.dart';
|
||||||
|
|
||||||
// Core
|
// Core
|
||||||
export 'src/core/services/api_services/api_endpoint.dart';
|
export 'src/core/services/api_services/api_endpoint.dart';
|
||||||
export 'src/core/services/api_services/api_response.dart';
|
export 'src/core/services/api_services/api_response.dart';
|
||||||
@@ -66,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';
|
||||||
|
|
||||||
@@ -99,6 +107,7 @@ export 'src/entities/profile/accessibility.dart';
|
|||||||
|
|
||||||
// Ratings
|
// Ratings
|
||||||
export 'src/entities/ratings/staff_rating.dart';
|
export 'src/entities/ratings/staff_rating.dart';
|
||||||
|
export 'src/entities/ratings/staff_reliability_stats.dart';
|
||||||
|
|
||||||
// Home
|
// Home
|
||||||
export 'src/entities/home/client_dashboard.dart';
|
export 'src/entities/home/client_dashboard.dart';
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/// Parses a UTC ISO 8601 timestamp and converts to local device time.
|
||||||
|
DateTime parseUtcToLocal(String value) => DateTime.parse(value).toLocal();
|
||||||
|
|
||||||
|
/// Parses a nullable UTC ISO 8601 timestamp. Returns null if input is null.
|
||||||
|
DateTime? tryParseUtcToLocal(String? value) =>
|
||||||
|
value != null ? DateTime.parse(value).toLocal() : null;
|
||||||
@@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart';
|
|||||||
|
|
||||||
import 'package:krow_domain/src/entities/enums/benefit_status.dart';
|
import 'package:krow_domain/src/entities/enums/benefit_status.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/utc_parser.dart';
|
||||||
|
|
||||||
/// A historical record of a staff benefit accrual period.
|
/// A historical record of a staff benefit accrual period.
|
||||||
///
|
///
|
||||||
/// Returned by `GET /staff/profile/benefits/history`.
|
/// Returned by `GET /staff/profile/benefits/history`.
|
||||||
@@ -28,10 +30,8 @@ class BenefitHistory extends Equatable {
|
|||||||
benefitType: json['benefitType'] as String,
|
benefitType: json['benefitType'] as String,
|
||||||
title: json['title'] as String,
|
title: json['title'] as String,
|
||||||
status: BenefitStatus.fromJson(json['status'] as String?),
|
status: BenefitStatus.fromJson(json['status'] as String?),
|
||||||
effectiveAt: DateTime.parse(json['effectiveAt'] as String),
|
effectiveAt: parseUtcToLocal(json['effectiveAt'] as String),
|
||||||
endedAt: json['endedAt'] != null
|
endedAt: tryParseUtcToLocal(json['endedAt'] as String?),
|
||||||
? DateTime.parse(json['endedAt'] as String)
|
|
||||||
: null,
|
|
||||||
trackedHours: (json['trackedHours'] as num).toInt(),
|
trackedHours: (json['trackedHours'] as num).toInt(),
|
||||||
targetHours: (json['targetHours'] as num).toInt(),
|
targetHours: (json['targetHours'] as num).toInt(),
|
||||||
notes: json['notes'] as String?,
|
notes: json['notes'] as String?,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart';
|
|||||||
|
|
||||||
import 'package:krow_domain/src/entities/enums/business_status.dart';
|
import 'package:krow_domain/src/entities/enums/business_status.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/utc_parser.dart';
|
||||||
|
|
||||||
/// A client company registered on the platform.
|
/// A client company registered on the platform.
|
||||||
///
|
///
|
||||||
/// Maps to the V2 `businesses` table.
|
/// Maps to the V2 `businesses` table.
|
||||||
@@ -35,12 +37,8 @@ class Business extends Equatable {
|
|||||||
metadata: json['metadata'] is Map
|
metadata: json['metadata'] is Map
|
||||||
? Map<String, dynamic>.from(json['metadata'] as Map<dynamic, dynamic>)
|
? Map<String, dynamic>.from(json['metadata'] as Map<dynamic, dynamic>)
|
||||||
: const <String, dynamic>{},
|
: const <String, dynamic>{},
|
||||||
createdAt: json['createdAt'] != null
|
createdAt: tryParseUtcToLocal(json['createdAt'] as String?),
|
||||||
? DateTime.parse(json['createdAt'] as String)
|
updatedAt: tryParseUtcToLocal(json['updatedAt'] as String?),
|
||||||
: null,
|
|
||||||
updatedAt: json['updatedAt'] != null
|
|
||||||
? DateTime.parse(json['updatedAt'] as String)
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart';
|
|||||||
|
|
||||||
import 'package:krow_domain/src/entities/enums/attendance_status_type.dart';
|
import 'package:krow_domain/src/entities/enums/attendance_status_type.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/utc_parser.dart';
|
||||||
|
|
||||||
/// Current clock-in / attendance status of the staff member.
|
/// Current clock-in / attendance status of the staff member.
|
||||||
///
|
///
|
||||||
/// Returned by `GET /staff/clock-in/status`. When no open session exists
|
/// Returned by `GET /staff/clock-in/status`. When no open session exists
|
||||||
@@ -20,9 +22,7 @@ class AttendanceStatus extends Equatable {
|
|||||||
activeShiftId: json['activeShiftId'] as String?,
|
activeShiftId: json['activeShiftId'] as String?,
|
||||||
attendanceStatus:
|
attendanceStatus:
|
||||||
AttendanceStatusType.fromJson(json['attendanceStatus'] as String?),
|
AttendanceStatusType.fromJson(json['attendanceStatus'] as String?),
|
||||||
clockInAt: json['clockInAt'] != null
|
clockInAt: tryParseUtcToLocal(json['clockInAt'] as String?),
|
||||||
? DateTime.parse(json['clockInAt'] as String)
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart';
|
|||||||
|
|
||||||
import 'package:krow_domain/src/entities/enums/assignment_status.dart';
|
import 'package:krow_domain/src/entities/enums/assignment_status.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/utc_parser.dart';
|
||||||
|
|
||||||
/// A worker assigned to a coverage shift.
|
/// A worker assigned to a coverage shift.
|
||||||
///
|
///
|
||||||
/// Nested within [ShiftWithWorkers].
|
/// Nested within [ShiftWithWorkers].
|
||||||
@@ -13,6 +15,7 @@ class AssignedWorker extends Equatable {
|
|||||||
required this.fullName,
|
required this.fullName,
|
||||||
required this.status,
|
required this.status,
|
||||||
this.checkInAt,
|
this.checkInAt,
|
||||||
|
this.hasReview = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Deserialises an [AssignedWorker] from a V2 API JSON map.
|
/// Deserialises an [AssignedWorker] from a V2 API JSON map.
|
||||||
@@ -22,9 +25,8 @@ class AssignedWorker extends Equatable {
|
|||||||
staffId: json['staffId'] as String,
|
staffId: json['staffId'] as String,
|
||||||
fullName: json['fullName'] as String,
|
fullName: json['fullName'] as String,
|
||||||
status: AssignmentStatus.fromJson(json['status'] as String?),
|
status: AssignmentStatus.fromJson(json['status'] as String?),
|
||||||
checkInAt: json['checkInAt'] != null
|
checkInAt: tryParseUtcToLocal(json['checkInAt'] as String?),
|
||||||
? DateTime.parse(json['checkInAt'] as String)
|
hasReview: json['hasReview'] as bool? ?? false,
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +45,9 @@ class AssignedWorker extends Equatable {
|
|||||||
/// When the worker clocked in (null if not yet).
|
/// When the worker clocked in (null if not yet).
|
||||||
final DateTime? checkInAt;
|
final DateTime? checkInAt;
|
||||||
|
|
||||||
|
/// Whether this worker has already been reviewed for this assignment.
|
||||||
|
final bool hasReview;
|
||||||
|
|
||||||
/// Serialises this [AssignedWorker] to a JSON map.
|
/// Serialises this [AssignedWorker] to a JSON map.
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
@@ -51,6 +56,7 @@ class AssignedWorker extends Equatable {
|
|||||||
'fullName': fullName,
|
'fullName': fullName,
|
||||||
'status': status.toJson(),
|
'status': status.toJson(),
|
||||||
'checkInAt': checkInAt?.toIso8601String(),
|
'checkInAt': checkInAt?.toIso8601String(),
|
||||||
|
'hasReview': hasReview,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,5 +67,6 @@ class AssignedWorker extends Equatable {
|
|||||||
fullName,
|
fullName,
|
||||||
status,
|
status,
|
||||||
checkInAt,
|
checkInAt,
|
||||||
|
hasReview,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ class ShiftWithWorkers extends Equatable {
|
|||||||
required this.requiredWorkerCount,
|
required this.requiredWorkerCount,
|
||||||
required this.assignedWorkerCount,
|
required this.assignedWorkerCount,
|
||||||
this.assignedWorkers = const <AssignedWorker>[],
|
this.assignedWorkers = const <AssignedWorker>[],
|
||||||
|
this.locationName = '',
|
||||||
|
this.locationAddress = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Deserialises a [ShiftWithWorkers] from a V2 API JSON map.
|
/// Deserialises a [ShiftWithWorkers] from a V2 API JSON map.
|
||||||
@@ -30,6 +32,8 @@ class ShiftWithWorkers extends Equatable {
|
|||||||
return ShiftWithWorkers(
|
return ShiftWithWorkers(
|
||||||
shiftId: json['shiftId'] as String,
|
shiftId: json['shiftId'] as String,
|
||||||
roleName: json['roleName'] as String? ?? '',
|
roleName: json['roleName'] as String? ?? '',
|
||||||
|
locationName: json['locationName'] as String? ?? '',
|
||||||
|
locationAddress: json['locationAddress'] as String? ?? '',
|
||||||
timeRange: TimeRange.fromJson(json['timeRange'] as Map<String, dynamic>),
|
timeRange: TimeRange.fromJson(json['timeRange'] as Map<String, dynamic>),
|
||||||
requiredWorkerCount: (json['requiredWorkerCount'] as num).toInt(),
|
requiredWorkerCount: (json['requiredWorkerCount'] as num).toInt(),
|
||||||
assignedWorkerCount: (json['assignedWorkerCount'] as num).toInt(),
|
assignedWorkerCount: (json['assignedWorkerCount'] as num).toInt(),
|
||||||
@@ -55,11 +59,19 @@ class ShiftWithWorkers extends Equatable {
|
|||||||
/// List of assigned workers with their statuses.
|
/// List of assigned workers with their statuses.
|
||||||
final List<AssignedWorker> assignedWorkers;
|
final List<AssignedWorker> assignedWorkers;
|
||||||
|
|
||||||
|
/// Location or hub name for this shift.
|
||||||
|
final String locationName;
|
||||||
|
|
||||||
|
/// Street address for this shift.
|
||||||
|
final String locationAddress;
|
||||||
|
|
||||||
/// Serialises this [ShiftWithWorkers] to a JSON map.
|
/// Serialises this [ShiftWithWorkers] to a JSON map.
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
'shiftId': shiftId,
|
'shiftId': shiftId,
|
||||||
'roleName': roleName,
|
'roleName': roleName,
|
||||||
|
'locationName': locationName,
|
||||||
|
'locationAddress': locationAddress,
|
||||||
'timeRange': timeRange.toJson(),
|
'timeRange': timeRange.toJson(),
|
||||||
'requiredWorkerCount': requiredWorkerCount,
|
'requiredWorkerCount': requiredWorkerCount,
|
||||||
'assignedWorkerCount': assignedWorkerCount,
|
'assignedWorkerCount': assignedWorkerCount,
|
||||||
@@ -76,5 +88,7 @@ class ShiftWithWorkers extends Equatable {
|
|||||||
requiredWorkerCount,
|
requiredWorkerCount,
|
||||||
assignedWorkerCount,
|
assignedWorkerCount,
|
||||||
assignedWorkers,
|
assignedWorkers,
|
||||||
|
locationName,
|
||||||
|
locationAddress,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/utc_parser.dart';
|
||||||
|
|
||||||
/// A time range with start and end timestamps.
|
/// A time range with start and end timestamps.
|
||||||
///
|
///
|
||||||
/// Used within [ShiftWithWorkers] for shift time windows.
|
/// Used within [ShiftWithWorkers] for shift time windows.
|
||||||
@@ -13,8 +15,8 @@ class TimeRange extends Equatable {
|
|||||||
/// Deserialises a [TimeRange] from a V2 API JSON map.
|
/// Deserialises a [TimeRange] from a V2 API JSON map.
|
||||||
factory TimeRange.fromJson(Map<String, dynamic> json) {
|
factory TimeRange.fromJson(Map<String, dynamic> json) {
|
||||||
return TimeRange(
|
return TimeRange(
|
||||||
startsAt: DateTime.parse(json['startsAt'] as String),
|
startsAt: parseUtcToLocal(json['startsAt'] as String),
|
||||||
endsAt: DateTime.parse(json['endsAt'] as String),
|
endsAt: parseUtcToLocal(json['endsAt'] as String),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart';
|
|||||||
|
|
||||||
import 'package:krow_domain/src/entities/enums/invoice_status.dart';
|
import 'package:krow_domain/src/entities/enums/invoice_status.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/utc_parser.dart';
|
||||||
|
|
||||||
/// An invoice issued to a business for services rendered.
|
/// An invoice issued to a business for services rendered.
|
||||||
///
|
///
|
||||||
/// Returned by `GET /client/billing/invoices/*`.
|
/// Returned by `GET /client/billing/invoices/*`.
|
||||||
@@ -25,12 +27,8 @@ class Invoice extends Equatable {
|
|||||||
invoiceNumber: json['invoiceNumber'] as String,
|
invoiceNumber: json['invoiceNumber'] as String,
|
||||||
amountCents: (json['amountCents'] as num).toInt(),
|
amountCents: (json['amountCents'] as num).toInt(),
|
||||||
status: InvoiceStatus.fromJson(json['status'] as String?),
|
status: InvoiceStatus.fromJson(json['status'] as String?),
|
||||||
dueDate: json['dueDate'] != null
|
dueDate: tryParseUtcToLocal(json['dueDate'] as String?),
|
||||||
? DateTime.parse(json['dueDate'] as String)
|
paymentDate: tryParseUtcToLocal(json['paymentDate'] as String?),
|
||||||
: null,
|
|
||||||
paymentDate: json['paymentDate'] != null
|
|
||||||
? DateTime.parse(json['paymentDate'] as String)
|
|
||||||
: null,
|
|
||||||
vendorId: json['vendorId'] as String?,
|
vendorId: json['vendorId'] as String?,
|
||||||
vendorName: json['vendorName'] as String?,
|
vendorName: json['vendorName'] as String?,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/utc_parser.dart';
|
||||||
|
|
||||||
/// A single data point in the staff payment chart.
|
/// A single data point in the staff payment chart.
|
||||||
///
|
///
|
||||||
/// Returned by `GET /staff/payments/chart`.
|
/// Returned by `GET /staff/payments/chart`.
|
||||||
@@ -13,7 +15,7 @@ class PaymentChartPoint extends Equatable {
|
|||||||
/// Deserialises a [PaymentChartPoint] from a V2 API JSON map.
|
/// Deserialises a [PaymentChartPoint] from a V2 API JSON map.
|
||||||
factory PaymentChartPoint.fromJson(Map<String, dynamic> json) {
|
factory PaymentChartPoint.fromJson(Map<String, dynamic> json) {
|
||||||
return PaymentChartPoint(
|
return PaymentChartPoint(
|
||||||
bucket: DateTime.parse(json['bucket'] as String),
|
bucket: parseUtcToLocal(json['bucket'] as String),
|
||||||
amountCents: (json['amountCents'] as num).toInt(),
|
amountCents: (json['amountCents'] as num).toInt(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart';
|
|||||||
|
|
||||||
import 'package:krow_domain/src/entities/enums/payment_status.dart';
|
import 'package:krow_domain/src/entities/enums/payment_status.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/utc_parser.dart';
|
||||||
|
|
||||||
/// A single payment record for a staff member.
|
/// A single payment record for a staff member.
|
||||||
///
|
///
|
||||||
/// Returned by `GET /staff/payments/history`.
|
/// Returned by `GET /staff/payments/history`.
|
||||||
@@ -23,7 +25,7 @@ class PaymentRecord extends Equatable {
|
|||||||
return PaymentRecord(
|
return PaymentRecord(
|
||||||
paymentId: json['paymentId'] as String,
|
paymentId: json['paymentId'] as String,
|
||||||
amountCents: (json['amountCents'] as num).toInt(),
|
amountCents: (json['amountCents'] as num).toInt(),
|
||||||
date: DateTime.parse(json['date'] as String),
|
date: parseUtcToLocal(json['date'] as String),
|
||||||
status: PaymentStatus.fromJson(json['status'] as String?),
|
status: PaymentStatus.fromJson(json['status'] as String?),
|
||||||
shiftName: json['shiftName'] as String?,
|
shiftName: json['shiftName'] as String?,
|
||||||
location: json['location'] as String?,
|
location: json['location'] as String?,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/utc_parser.dart';
|
||||||
|
|
||||||
/// A single time-card entry for a completed shift.
|
/// A single time-card entry for a completed shift.
|
||||||
///
|
///
|
||||||
/// Returned by `GET /staff/profile/time-card`.
|
/// Returned by `GET /staff/profile/time-card`.
|
||||||
@@ -19,15 +21,11 @@ class TimeCardEntry extends Equatable {
|
|||||||
/// Deserialises a [TimeCardEntry] from a V2 API JSON map.
|
/// Deserialises a [TimeCardEntry] from a V2 API JSON map.
|
||||||
factory TimeCardEntry.fromJson(Map<String, dynamic> json) {
|
factory TimeCardEntry.fromJson(Map<String, dynamic> json) {
|
||||||
return TimeCardEntry(
|
return TimeCardEntry(
|
||||||
date: DateTime.parse(json['date'] as String),
|
date: parseUtcToLocal(json['date'] as String),
|
||||||
shiftName: json['shiftName'] as String,
|
shiftName: json['shiftName'] as String,
|
||||||
location: json['location'] as String?,
|
location: json['location'] as String?,
|
||||||
clockInAt: json['clockInAt'] != null
|
clockInAt: tryParseUtcToLocal(json['clockInAt'] as String?),
|
||||||
? DateTime.parse(json['clockInAt'] as String)
|
clockOutAt: tryParseUtcToLocal(json['clockOutAt'] as String?),
|
||||||
: null,
|
|
||||||
clockOutAt: json['clockOutAt'] != null
|
|
||||||
? DateTime.parse(json['clockOutAt'] as String)
|
|
||||||
: null,
|
|
||||||
minutesWorked: (json['minutesWorked'] as num).toInt(),
|
minutesWorked: (json['minutesWorked'] as num).toInt(),
|
||||||
hourlyRateCents: json['hourlyRateCents'] != null
|
hourlyRateCents: json['hourlyRateCents'] != null
|
||||||
? (json['hourlyRateCents'] as num).toInt()
|
? (json['hourlyRateCents'] as num).toInt()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:equatable/equatable.dart';
|
|||||||
import 'package:krow_domain/src/entities/enums/order_type.dart';
|
import 'package:krow_domain/src/entities/enums/order_type.dart';
|
||||||
import 'package:krow_domain/src/entities/enums/shift_status.dart';
|
import 'package:krow_domain/src/entities/enums/shift_status.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/utc_parser.dart';
|
||||||
import 'assigned_worker_summary.dart';
|
import 'assigned_worker_summary.dart';
|
||||||
|
|
||||||
/// A line item within an order, representing a role needed for a shift.
|
/// A line item within an order, representing a role needed for a shift.
|
||||||
@@ -25,6 +26,16 @@ class OrderItem extends Equatable {
|
|||||||
this.locationName,
|
this.locationName,
|
||||||
required this.status,
|
required this.status,
|
||||||
this.workers = const <AssignedWorkerSummary>[],
|
this.workers = const <AssignedWorkerSummary>[],
|
||||||
|
this.eventName = '',
|
||||||
|
this.clientName = '',
|
||||||
|
this.hourlyRate = 0.0,
|
||||||
|
this.hours = 0.0,
|
||||||
|
this.totalValue = 0.0,
|
||||||
|
this.locationAddress,
|
||||||
|
this.startTime,
|
||||||
|
this.endTime,
|
||||||
|
this.hubManagerId,
|
||||||
|
this.hubManagerName,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Deserialises an [OrderItem] from a V2 API JSON map.
|
/// Deserialises an [OrderItem] from a V2 API JSON map.
|
||||||
@@ -42,9 +53,9 @@ class OrderItem extends Equatable {
|
|||||||
orderId: json['orderId'] as String,
|
orderId: json['orderId'] as String,
|
||||||
orderType: OrderType.fromJson(json['orderType'] as String?),
|
orderType: OrderType.fromJson(json['orderType'] as String?),
|
||||||
roleName: json['roleName'] as String,
|
roleName: json['roleName'] as String,
|
||||||
date: DateTime.parse(json['date'] as String),
|
date: parseUtcToLocal(json['date'] as String),
|
||||||
startsAt: DateTime.parse(json['startsAt'] as String),
|
startsAt: parseUtcToLocal(json['startsAt'] as String),
|
||||||
endsAt: DateTime.parse(json['endsAt'] as String),
|
endsAt: parseUtcToLocal(json['endsAt'] as String),
|
||||||
requiredWorkerCount: (json['requiredWorkerCount'] as num).toInt(),
|
requiredWorkerCount: (json['requiredWorkerCount'] as num).toInt(),
|
||||||
filledCount: (json['filledCount'] as num).toInt(),
|
filledCount: (json['filledCount'] as num).toInt(),
|
||||||
hourlyRateCents: (json['hourlyRateCents'] as num).toInt(),
|
hourlyRateCents: (json['hourlyRateCents'] as num).toInt(),
|
||||||
@@ -52,6 +63,16 @@ class OrderItem extends Equatable {
|
|||||||
locationName: json['locationName'] as String?,
|
locationName: json['locationName'] as String?,
|
||||||
status: ShiftStatus.fromJson(json['status'] as String?),
|
status: ShiftStatus.fromJson(json['status'] as String?),
|
||||||
workers: workersList,
|
workers: workersList,
|
||||||
|
eventName: json['eventName'] as String? ?? '',
|
||||||
|
clientName: json['clientName'] as String? ?? '',
|
||||||
|
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
hours: (json['hours'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
totalValue: (json['totalValue'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
locationAddress: json['locationAddress'] as String?,
|
||||||
|
startTime: json['startTime'] as String?,
|
||||||
|
endTime: json['endTime'] as String?,
|
||||||
|
hubManagerId: json['hubManagerId'] as String?,
|
||||||
|
hubManagerName: json['hubManagerName'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +118,36 @@ class OrderItem extends Equatable {
|
|||||||
/// Assigned workers for this line item.
|
/// Assigned workers for this line item.
|
||||||
final List<AssignedWorkerSummary> workers;
|
final List<AssignedWorkerSummary> workers;
|
||||||
|
|
||||||
|
/// Event/order name.
|
||||||
|
final String eventName;
|
||||||
|
|
||||||
|
/// Client/business name.
|
||||||
|
final String clientName;
|
||||||
|
|
||||||
|
/// Billing rate in dollars per hour.
|
||||||
|
final double hourlyRate;
|
||||||
|
|
||||||
|
/// Duration of the shift in fractional hours.
|
||||||
|
final double hours;
|
||||||
|
|
||||||
|
/// Total cost in dollars (rate x workers x hours).
|
||||||
|
final double totalValue;
|
||||||
|
|
||||||
|
/// Full street address of the location.
|
||||||
|
final String? locationAddress;
|
||||||
|
|
||||||
|
/// Display start time string (HH:MM UTC).
|
||||||
|
final String? startTime;
|
||||||
|
|
||||||
|
/// Display end time string (HH:MM UTC).
|
||||||
|
final String? endTime;
|
||||||
|
|
||||||
|
/// Hub manager's business membership ID.
|
||||||
|
final String? hubManagerId;
|
||||||
|
|
||||||
|
/// Hub manager's display name.
|
||||||
|
final String? hubManagerName;
|
||||||
|
|
||||||
/// Serialises this [OrderItem] to a JSON map.
|
/// Serialises this [OrderItem] to a JSON map.
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
@@ -114,6 +165,16 @@ class OrderItem extends Equatable {
|
|||||||
'locationName': locationName,
|
'locationName': locationName,
|
||||||
'status': status.toJson(),
|
'status': status.toJson(),
|
||||||
'workers': workers.map((AssignedWorkerSummary w) => w.toJson()).toList(),
|
'workers': workers.map((AssignedWorkerSummary w) => w.toJson()).toList(),
|
||||||
|
'eventName': eventName,
|
||||||
|
'clientName': clientName,
|
||||||
|
'hourlyRate': hourlyRate,
|
||||||
|
'hours': hours,
|
||||||
|
'totalValue': totalValue,
|
||||||
|
'locationAddress': locationAddress,
|
||||||
|
'startTime': startTime,
|
||||||
|
'endTime': endTime,
|
||||||
|
'hubManagerId': hubManagerId,
|
||||||
|
'hubManagerName': hubManagerName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,5 +194,15 @@ class OrderItem extends Equatable {
|
|||||||
locationName,
|
locationName,
|
||||||
status,
|
status,
|
||||||
workers,
|
workers,
|
||||||
|
eventName,
|
||||||
|
clientName,
|
||||||
|
hourlyRate,
|
||||||
|
hours,
|
||||||
|
totalValue,
|
||||||
|
locationAddress,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
hubManagerId,
|
||||||
|
hubManagerName,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/utc_parser.dart';
|
||||||
|
|
||||||
/// A preview of an order for reordering purposes.
|
/// A preview of an order for reordering purposes.
|
||||||
///
|
///
|
||||||
/// Returned by `GET /client/orders/:id/reorder-preview`.
|
/// Returned by `GET /client/orders/:id/reorder-preview`.
|
||||||
@@ -31,12 +33,8 @@ class OrderPreview extends Equatable {
|
|||||||
orderId: json['orderId'] as String,
|
orderId: json['orderId'] as String,
|
||||||
title: json['title'] as String,
|
title: json['title'] as String,
|
||||||
description: json['description'] as String?,
|
description: json['description'] as String?,
|
||||||
startsAt: json['startsAt'] != null
|
startsAt: tryParseUtcToLocal(json['startsAt'] as String?),
|
||||||
? DateTime.parse(json['startsAt'] as String)
|
endsAt: tryParseUtcToLocal(json['endsAt'] as String?),
|
||||||
: null,
|
|
||||||
endsAt: json['endsAt'] != null
|
|
||||||
? DateTime.parse(json['endsAt'] as String)
|
|
||||||
: null,
|
|
||||||
locationName: json['locationName'] as String?,
|
locationName: json['locationName'] as String?,
|
||||||
locationAddress: json['locationAddress'] as String?,
|
locationAddress: json['locationAddress'] as String?,
|
||||||
metadata: json['metadata'] is Map
|
metadata: json['metadata'] is Map
|
||||||
@@ -128,8 +126,8 @@ class OrderPreviewShift extends Equatable {
|
|||||||
shiftId: json['shiftId'] as String,
|
shiftId: json['shiftId'] as String,
|
||||||
shiftCode: json['shiftCode'] as String,
|
shiftCode: json['shiftCode'] as String,
|
||||||
title: json['title'] as String,
|
title: json['title'] as String,
|
||||||
startsAt: DateTime.parse(json['startsAt'] as String),
|
startsAt: parseUtcToLocal(json['startsAt'] as String),
|
||||||
endsAt: DateTime.parse(json['endsAt'] as String),
|
endsAt: parseUtcToLocal(json['endsAt'] as String),
|
||||||
roles: rolesList,
|
roles: rolesList,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart';
|
|||||||
|
|
||||||
import 'package:krow_domain/src/entities/enums/order_type.dart';
|
import 'package:krow_domain/src/entities/enums/order_type.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/utc_parser.dart';
|
||||||
|
|
||||||
/// A recently completed order available for reordering.
|
/// A recently completed order available for reordering.
|
||||||
///
|
///
|
||||||
/// Returned by `GET /client/reorders`.
|
/// Returned by `GET /client/reorders`.
|
||||||
@@ -21,9 +23,7 @@ class RecentOrder extends Equatable {
|
|||||||
return RecentOrder(
|
return RecentOrder(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
title: json['title'] as String,
|
title: json['title'] as String,
|
||||||
date: json['date'] != null
|
date: tryParseUtcToLocal(json['date'] as String?),
|
||||||
? DateTime.parse(json['date'] as String)
|
|
||||||
: null,
|
|
||||||
hubName: json['hubName'] as String?,
|
hubName: json['hubName'] as String?,
|
||||||
positionCount: (json['positionCount'] as num).toInt(),
|
positionCount: (json['positionCount'] as num).toInt(),
|
||||||
orderType: OrderType.fromJson(json['orderType'] as String?),
|
orderType: OrderType.fromJson(json['orderType'] as String?),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/utc_parser.dart';
|
||||||
|
|
||||||
/// Status of a staff certificate.
|
/// Status of a staff certificate.
|
||||||
enum CertificateStatus {
|
enum CertificateStatus {
|
||||||
/// Certificate uploaded, pending verification.
|
/// Certificate uploaded, pending verification.
|
||||||
@@ -45,8 +47,8 @@ class StaffCertificate extends Equatable {
|
|||||||
fileUri: json['fileUri'] as String?,
|
fileUri: json['fileUri'] as String?,
|
||||||
issuer: json['issuer'] as String?,
|
issuer: json['issuer'] as String?,
|
||||||
certificateNumber: json['certificateNumber'] as String?,
|
certificateNumber: json['certificateNumber'] as String?,
|
||||||
issuedAt: json['issuedAt'] != null ? DateTime.parse(json['issuedAt'] as String) : null,
|
issuedAt: tryParseUtcToLocal(json['issuedAt'] as String?),
|
||||||
expiresAt: json['expiresAt'] != null ? DateTime.parse(json['expiresAt'] as String) : null,
|
expiresAt: tryParseUtcToLocal(json['expiresAt'] as String?),
|
||||||
status: _parseStatus(json['status'] as String?),
|
status: _parseStatus(json['status'] as String?),
|
||||||
verificationStatus: json['verificationStatus'] as String?,
|
verificationStatus: json['verificationStatus'] as String?,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/utc_parser.dart';
|
||||||
|
|
||||||
/// Status of a profile document.
|
/// Status of a profile document.
|
||||||
enum ProfileDocumentStatus {
|
enum ProfileDocumentStatus {
|
||||||
/// Document has not been uploaded yet.
|
/// Document has not been uploaded yet.
|
||||||
@@ -59,7 +61,7 @@ class ProfileDocument extends Equatable {
|
|||||||
staffDocumentId: json['staffDocumentId'] as String?,
|
staffDocumentId: json['staffDocumentId'] as String?,
|
||||||
fileUri: json['fileUri'] as String?,
|
fileUri: json['fileUri'] as String?,
|
||||||
status: _parseStatus(json['status'] as String?),
|
status: _parseStatus(json['status'] as String?),
|
||||||
expiresAt: json['expiresAt'] != null ? DateTime.parse(json['expiresAt'] as String) : null,
|
expiresAt: tryParseUtcToLocal(json['expiresAt'] as String?),
|
||||||
metadata: (json['metadata'] as Map<String, dynamic>?) ?? const <String, dynamic>{},
|
metadata: (json['metadata'] as Map<String, dynamic>?) ?? const <String, dynamic>{},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/utc_parser.dart';
|
||||||
|
|
||||||
/// A review left for a staff member after an assignment.
|
/// A review left for a staff member after an assignment.
|
||||||
///
|
///
|
||||||
/// Maps to the V2 `staff_reviews` table.
|
/// Maps to the V2 `staff_reviews` table.
|
||||||
@@ -35,9 +37,7 @@ class StaffRating extends Equatable {
|
|||||||
rating: (json['rating'] as num).toInt(),
|
rating: (json['rating'] as num).toInt(),
|
||||||
reviewText: json['reviewText'] as String?,
|
reviewText: json['reviewText'] as String?,
|
||||||
tags: tagsList,
|
tags: tagsList,
|
||||||
createdAt: json['createdAt'] != null
|
createdAt: tryParseUtcToLocal(json['createdAt'] as String?),
|
||||||
? DateTime.parse(json['createdAt'] as String)
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// Aggregated reliability and performance statistics for a staff member.
|
||||||
|
///
|
||||||
|
/// Returned by `GET /staff/profile/stats`.
|
||||||
|
class StaffReliabilityStats extends Equatable {
|
||||||
|
/// Creates a [StaffReliabilityStats] instance.
|
||||||
|
const StaffReliabilityStats({
|
||||||
|
required this.staffId,
|
||||||
|
this.totalShifts = 0,
|
||||||
|
this.averageRating = 0,
|
||||||
|
this.ratingCount = 0,
|
||||||
|
this.onTimeRate = 0,
|
||||||
|
this.noShowCount = 0,
|
||||||
|
this.cancellationCount = 0,
|
||||||
|
this.reliabilityScore = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Deserialises from a V2 API JSON map.
|
||||||
|
factory StaffReliabilityStats.fromJson(Map<String, dynamic> json) {
|
||||||
|
return StaffReliabilityStats(
|
||||||
|
staffId: json['staffId'] as String,
|
||||||
|
totalShifts: (json['totalShifts'] as num?)?.toInt() ?? 0,
|
||||||
|
averageRating: (json['averageRating'] as num?)?.toDouble() ?? 0,
|
||||||
|
ratingCount: (json['ratingCount'] as num?)?.toInt() ?? 0,
|
||||||
|
onTimeRate: (json['onTimeRate'] as num?)?.toDouble() ?? 0,
|
||||||
|
noShowCount: (json['noShowCount'] as num?)?.toInt() ?? 0,
|
||||||
|
cancellationCount: (json['cancellationCount'] as num?)?.toInt() ?? 0,
|
||||||
|
reliabilityScore: (json['reliabilityScore'] as num?)?.toDouble() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The staff member's unique identifier.
|
||||||
|
final String staffId;
|
||||||
|
|
||||||
|
/// Total completed shifts.
|
||||||
|
final int totalShifts;
|
||||||
|
|
||||||
|
/// Average rating from client reviews (0-5).
|
||||||
|
final double averageRating;
|
||||||
|
|
||||||
|
/// Number of ratings received.
|
||||||
|
final int ratingCount;
|
||||||
|
|
||||||
|
/// Percentage of shifts clocked in on time (0-100).
|
||||||
|
final double onTimeRate;
|
||||||
|
|
||||||
|
/// Number of no-show incidents.
|
||||||
|
final int noShowCount;
|
||||||
|
|
||||||
|
/// Number of worker-initiated cancellations.
|
||||||
|
final int cancellationCount;
|
||||||
|
|
||||||
|
/// Composite reliability score (0-100).
|
||||||
|
///
|
||||||
|
/// Weighted: 45% on-time rate + 35% completion rate + 20% rating score.
|
||||||
|
final double reliabilityScore;
|
||||||
|
|
||||||
|
/// Serialises to a JSON map.
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'staffId': staffId,
|
||||||
|
'totalShifts': totalShifts,
|
||||||
|
'averageRating': averageRating,
|
||||||
|
'ratingCount': ratingCount,
|
||||||
|
'onTimeRate': onTimeRate,
|
||||||
|
'noShowCount': noShowCount,
|
||||||
|
'cancellationCount': cancellationCount,
|
||||||
|
'reliabilityScore': reliabilityScore,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => <Object?>[
|
||||||
|
staffId,
|
||||||
|
totalShifts,
|
||||||
|
averageRating,
|
||||||
|
ratingCount,
|
||||||
|
onTimeRate,
|
||||||
|
noShowCount,
|
||||||
|
cancellationCount,
|
||||||
|
reliabilityScore,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/utc_parser.dart';
|
||||||
|
|
||||||
/// Coverage report with daily breakdown.
|
/// Coverage report with daily breakdown.
|
||||||
///
|
///
|
||||||
/// Returned by `GET /client/reports/coverage`.
|
/// Returned by `GET /client/reports/coverage`.
|
||||||
@@ -75,7 +77,7 @@ class CoverageDayPoint extends Equatable {
|
|||||||
/// Deserialises a [CoverageDayPoint] from a V2 API JSON map.
|
/// Deserialises a [CoverageDayPoint] from a V2 API JSON map.
|
||||||
factory CoverageDayPoint.fromJson(Map<String, dynamic> json) {
|
factory CoverageDayPoint.fromJson(Map<String, dynamic> json) {
|
||||||
return CoverageDayPoint(
|
return CoverageDayPoint(
|
||||||
day: DateTime.parse(json['day'] as String),
|
day: parseUtcToLocal(json['day'] as String),
|
||||||
needed: (json['needed'] as num).toInt(),
|
needed: (json['needed'] as num).toInt(),
|
||||||
filled: (json['filled'] as num).toInt(),
|
filled: (json['filled'] as num).toInt(),
|
||||||
coveragePercentage: (json['coveragePercentage'] as num).toDouble(),
|
coveragePercentage: (json['coveragePercentage'] as num).toDouble(),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/utc_parser.dart';
|
||||||
|
|
||||||
/// Staffing and spend forecast report.
|
/// Staffing and spend forecast report.
|
||||||
///
|
///
|
||||||
/// Returned by `GET /client/reports/forecast`.
|
/// Returned by `GET /client/reports/forecast`.
|
||||||
@@ -83,7 +85,7 @@ class ForecastWeek extends Equatable {
|
|||||||
/// Deserialises a [ForecastWeek] from a V2 API JSON map.
|
/// Deserialises a [ForecastWeek] from a V2 API JSON map.
|
||||||
factory ForecastWeek.fromJson(Map<String, dynamic> json) {
|
factory ForecastWeek.fromJson(Map<String, dynamic> json) {
|
||||||
return ForecastWeek(
|
return ForecastWeek(
|
||||||
week: DateTime.parse(json['week'] as String),
|
week: parseUtcToLocal(json['week'] as String),
|
||||||
shiftCount: (json['shiftCount'] as num).toInt(),
|
shiftCount: (json['shiftCount'] as num).toInt(),
|
||||||
workerHours: (json['workerHours'] as num).toDouble(),
|
workerHours: (json['workerHours'] as num).toDouble(),
|
||||||
forecastSpendCents: (json['forecastSpendCents'] as num).toInt(),
|
forecastSpendCents: (json['forecastSpendCents'] as num).toInt(),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/utc_parser.dart';
|
||||||
|
|
||||||
/// No-show report with per-worker incident details.
|
/// No-show report with per-worker incident details.
|
||||||
///
|
///
|
||||||
/// Returned by `GET /client/reports/no-show`.
|
/// Returned by `GET /client/reports/no-show`.
|
||||||
@@ -143,7 +145,7 @@ class NoShowIncident extends Equatable {
|
|||||||
shiftId: json['shiftId'] as String,
|
shiftId: json['shiftId'] as String,
|
||||||
shiftTitle: json['shiftTitle'] as String,
|
shiftTitle: json['shiftTitle'] as String,
|
||||||
roleName: json['roleName'] as String,
|
roleName: json['roleName'] as String,
|
||||||
date: DateTime.parse(json['date'] as String),
|
date: parseUtcToLocal(json['date'] as String),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/utc_parser.dart';
|
||||||
import '../financial/spend_item.dart';
|
import '../financial/spend_item.dart';
|
||||||
|
|
||||||
/// Spend report with total, chart data points, and category breakdown.
|
/// Spend report with total, chart data points, and category breakdown.
|
||||||
@@ -71,7 +72,7 @@ class SpendDataPoint extends Equatable {
|
|||||||
/// Deserialises a [SpendDataPoint] from a V2 API JSON map.
|
/// Deserialises a [SpendDataPoint] from a V2 API JSON map.
|
||||||
factory SpendDataPoint.fromJson(Map<String, dynamic> json) {
|
factory SpendDataPoint.fromJson(Map<String, dynamic> json) {
|
||||||
return SpendDataPoint(
|
return SpendDataPoint(
|
||||||
bucket: DateTime.parse(json['bucket'] as String),
|
bucket: parseUtcToLocal(json['bucket'] as String),
|
||||||
amountCents: (json['amountCents'] as num).toInt(),
|
amountCents: (json['amountCents'] as num).toInt(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import 'package:krow_domain/src/core/utils/utc_parser.dart';
|
||||||
import 'package:krow_domain/src/entities/enums/assignment_status.dart';
|
import 'package:krow_domain/src/entities/enums/assignment_status.dart';
|
||||||
import 'package:krow_domain/src/entities/enums/order_type.dart';
|
import 'package:krow_domain/src/entities/enums/order_type.dart';
|
||||||
|
|
||||||
@@ -33,9 +34,9 @@ class AssignedShift extends Equatable {
|
|||||||
shiftId: json['shiftId'] as String,
|
shiftId: json['shiftId'] as String,
|
||||||
roleName: json['roleName'] as String,
|
roleName: json['roleName'] as String,
|
||||||
location: json['location'] as String? ?? '',
|
location: json['location'] as String? ?? '',
|
||||||
date: DateTime.parse(json['date'] as String),
|
date: parseUtcToLocal(json['date'] as String),
|
||||||
startTime: DateTime.parse(json['startTime'] as String),
|
startTime: parseUtcToLocal(json['startTime'] as String),
|
||||||
endTime: DateTime.parse(json['endTime'] as String),
|
endTime: parseUtcToLocal(json['endTime'] as String),
|
||||||
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
||||||
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
|
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
|
||||||
totalRateCents: json['totalRateCents'] as int? ?? 0,
|
totalRateCents: json['totalRateCents'] as int? ?? 0,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import 'package:krow_domain/src/core/utils/utc_parser.dart';
|
||||||
|
|
||||||
/// A shift whose assignment was cancelled.
|
/// A shift whose assignment was cancelled.
|
||||||
///
|
///
|
||||||
/// Returned by `GET /staff/shifts/cancelled`. Shows past assignments
|
/// Returned by `GET /staff/shifts/cancelled`. Shows past assignments
|
||||||
@@ -13,6 +15,14 @@ class CancelledShift extends Equatable {
|
|||||||
required this.location,
|
required this.location,
|
||||||
required this.date,
|
required this.date,
|
||||||
this.cancellationReason,
|
this.cancellationReason,
|
||||||
|
this.roleName,
|
||||||
|
this.clientName,
|
||||||
|
this.startTime,
|
||||||
|
this.endTime,
|
||||||
|
this.hourlyRateCents,
|
||||||
|
this.hourlyRate,
|
||||||
|
this.totalRateCents,
|
||||||
|
this.totalRate,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Deserialises from the V2 API JSON response.
|
/// Deserialises from the V2 API JSON response.
|
||||||
@@ -22,8 +32,16 @@ class CancelledShift extends Equatable {
|
|||||||
shiftId: json['shiftId'] as String,
|
shiftId: json['shiftId'] as String,
|
||||||
title: json['title'] as String? ?? '',
|
title: json['title'] as String? ?? '',
|
||||||
location: json['location'] as String? ?? '',
|
location: json['location'] as String? ?? '',
|
||||||
date: DateTime.parse(json['date'] as String),
|
date: parseUtcToLocal(json['date'] as String),
|
||||||
cancellationReason: json['cancellationReason'] as String?,
|
cancellationReason: json['cancellationReason'] as String?,
|
||||||
|
roleName: json['roleName'] as String?,
|
||||||
|
clientName: json['clientName'] as String?,
|
||||||
|
startTime: tryParseUtcToLocal(json['startTime'] as String?),
|
||||||
|
endTime: tryParseUtcToLocal(json['endTime'] as String?),
|
||||||
|
hourlyRateCents: json['hourlyRateCents'] as int?,
|
||||||
|
hourlyRate: (json['hourlyRate'] as num?)?.toDouble(),
|
||||||
|
totalRateCents: json['totalRateCents'] as int?,
|
||||||
|
totalRate: (json['totalRate'] as num?)?.toDouble(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +63,30 @@ class CancelledShift extends Equatable {
|
|||||||
/// Reason for cancellation, from assignment metadata.
|
/// Reason for cancellation, from assignment metadata.
|
||||||
final String? cancellationReason;
|
final String? cancellationReason;
|
||||||
|
|
||||||
|
/// Display name of the role.
|
||||||
|
final String? roleName;
|
||||||
|
|
||||||
|
/// Name of the client/business.
|
||||||
|
final String? clientName;
|
||||||
|
|
||||||
|
/// Scheduled start time.
|
||||||
|
final DateTime? startTime;
|
||||||
|
|
||||||
|
/// Scheduled end time.
|
||||||
|
final DateTime? endTime;
|
||||||
|
|
||||||
|
/// Pay rate in cents per hour.
|
||||||
|
final int? hourlyRateCents;
|
||||||
|
|
||||||
|
/// Pay rate in dollars per hour.
|
||||||
|
final double? hourlyRate;
|
||||||
|
|
||||||
|
/// Total pay for this shift in cents.
|
||||||
|
final int? totalRateCents;
|
||||||
|
|
||||||
|
/// Total pay for this shift in dollars.
|
||||||
|
final double? totalRate;
|
||||||
|
|
||||||
/// Serialises to JSON.
|
/// Serialises to JSON.
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
@@ -54,6 +96,14 @@ class CancelledShift extends Equatable {
|
|||||||
'location': location,
|
'location': location,
|
||||||
'date': date.toIso8601String(),
|
'date': date.toIso8601String(),
|
||||||
'cancellationReason': cancellationReason,
|
'cancellationReason': cancellationReason,
|
||||||
|
'roleName': roleName,
|
||||||
|
'clientName': clientName,
|
||||||
|
'startTime': startTime?.toIso8601String(),
|
||||||
|
'endTime': endTime?.toIso8601String(),
|
||||||
|
'hourlyRateCents': hourlyRateCents,
|
||||||
|
'hourlyRate': hourlyRate,
|
||||||
|
'totalRateCents': totalRateCents,
|
||||||
|
'totalRate': totalRate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,5 +115,13 @@ class CancelledShift extends Equatable {
|
|||||||
location,
|
location,
|
||||||
date,
|
date,
|
||||||
cancellationReason,
|
cancellationReason,
|
||||||
|
roleName,
|
||||||
|
clientName,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
hourlyRateCents,
|
||||||
|
hourlyRate,
|
||||||
|
totalRateCents,
|
||||||
|
totalRate,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
|
||||||
|
import 'package:krow_domain/src/core/utils/utc_parser.dart';
|
||||||
|
import 'package:krow_domain/src/entities/enums/assignment_status.dart';
|
||||||
|
import 'package:krow_domain/src/entities/enums/payment_status.dart';
|
||||||
|
|
||||||
/// A shift the staff member has completed.
|
/// A shift the staff member has completed.
|
||||||
///
|
///
|
||||||
@@ -34,12 +37,12 @@ class CompletedShift extends Equatable {
|
|||||||
title: json['title'] as String? ?? '',
|
title: json['title'] as String? ?? '',
|
||||||
location: json['location'] as String? ?? '',
|
location: json['location'] as String? ?? '',
|
||||||
clientName: json['clientName'] as String? ?? '',
|
clientName: json['clientName'] as String? ?? '',
|
||||||
date: DateTime.parse(json['date'] as String),
|
date: parseUtcToLocal(json['date'] as String),
|
||||||
startTime: json['startTime'] != null
|
startTime: json['startTime'] != null
|
||||||
? DateTime.parse(json['startTime'] as String)
|
? parseUtcToLocal(json['startTime'] as String)
|
||||||
: DateTime.now(),
|
: DateTime.now(),
|
||||||
endTime: json['endTime'] != null
|
endTime: json['endTime'] != null
|
||||||
? DateTime.parse(json['endTime'] as String)
|
? parseUtcToLocal(json['endTime'] as String)
|
||||||
: DateTime.now(),
|
: DateTime.now(),
|
||||||
minutesWorked: json['minutesWorked'] as int? ?? 0,
|
minutesWorked: json['minutesWorked'] as int? ?? 0,
|
||||||
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import 'package:krow_domain/src/core/utils/utc_parser.dart';
|
||||||
import 'package:krow_domain/src/entities/enums/order_type.dart';
|
import 'package:krow_domain/src/entities/enums/order_type.dart';
|
||||||
|
|
||||||
/// An open shift available for the staff member to apply to.
|
/// An open shift available for the staff member to apply to.
|
||||||
@@ -32,9 +33,9 @@ class OpenShift extends Equatable {
|
|||||||
roleName: json['roleName'] as String,
|
roleName: json['roleName'] as String,
|
||||||
clientName: json['clientName'] as String? ?? '',
|
clientName: json['clientName'] as String? ?? '',
|
||||||
location: json['location'] as String? ?? '',
|
location: json['location'] as String? ?? '',
|
||||||
date: DateTime.parse(json['date'] as String),
|
date: parseUtcToLocal(json['date'] as String),
|
||||||
startTime: DateTime.parse(json['startTime'] as String),
|
startTime: parseUtcToLocal(json['startTime'] as String),
|
||||||
endTime: DateTime.parse(json['endTime'] as String),
|
endTime: parseUtcToLocal(json['endTime'] as String),
|
||||||
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
||||||
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
|
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
|
||||||
orderType: OrderType.fromJson(json['orderType'] as String?),
|
orderType: OrderType.fromJson(json['orderType'] as String?),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import 'package:krow_domain/src/core/utils/utc_parser.dart';
|
||||||
|
|
||||||
/// An assignment awaiting the staff member's acceptance.
|
/// An assignment awaiting the staff member's acceptance.
|
||||||
///
|
///
|
||||||
/// Returned by `GET /staff/shifts/pending`. These are assignments with
|
/// Returned by `GET /staff/shifts/pending`. These are assignments with
|
||||||
@@ -15,6 +17,11 @@ class PendingAssignment extends Equatable {
|
|||||||
required this.endTime,
|
required this.endTime,
|
||||||
required this.location,
|
required this.location,
|
||||||
required this.responseDeadline,
|
required this.responseDeadline,
|
||||||
|
this.clientName,
|
||||||
|
this.hourlyRateCents,
|
||||||
|
this.hourlyRate,
|
||||||
|
this.totalRateCents,
|
||||||
|
this.totalRate,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Deserialises from the V2 API JSON response.
|
/// Deserialises from the V2 API JSON response.
|
||||||
@@ -24,10 +31,15 @@ class PendingAssignment extends Equatable {
|
|||||||
shiftId: json['shiftId'] as String,
|
shiftId: json['shiftId'] as String,
|
||||||
title: json['title'] as String? ?? '',
|
title: json['title'] as String? ?? '',
|
||||||
roleName: json['roleName'] as String,
|
roleName: json['roleName'] as String,
|
||||||
startTime: DateTime.parse(json['startTime'] as String),
|
startTime: parseUtcToLocal(json['startTime'] as String),
|
||||||
endTime: DateTime.parse(json['endTime'] as String),
|
endTime: parseUtcToLocal(json['endTime'] as String),
|
||||||
location: json['location'] as String? ?? '',
|
location: json['location'] as String? ?? '',
|
||||||
responseDeadline: DateTime.parse(json['responseDeadline'] as String),
|
responseDeadline: parseUtcToLocal(json['responseDeadline'] as String),
|
||||||
|
clientName: json['clientName'] as String?,
|
||||||
|
hourlyRateCents: json['hourlyRateCents'] as int?,
|
||||||
|
hourlyRate: (json['hourlyRate'] as num?)?.toDouble(),
|
||||||
|
totalRateCents: json['totalRateCents'] as int?,
|
||||||
|
totalRate: (json['totalRate'] as num?)?.toDouble(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +67,21 @@ class PendingAssignment extends Equatable {
|
|||||||
/// Deadline by which the worker must respond.
|
/// Deadline by which the worker must respond.
|
||||||
final DateTime responseDeadline;
|
final DateTime responseDeadline;
|
||||||
|
|
||||||
|
/// Name of the client/business.
|
||||||
|
final String? clientName;
|
||||||
|
|
||||||
|
/// Pay rate in cents per hour.
|
||||||
|
final int? hourlyRateCents;
|
||||||
|
|
||||||
|
/// Pay rate in dollars per hour.
|
||||||
|
final double? hourlyRate;
|
||||||
|
|
||||||
|
/// Total pay for this shift in cents.
|
||||||
|
final int? totalRateCents;
|
||||||
|
|
||||||
|
/// Total pay for this shift in dollars.
|
||||||
|
final double? totalRate;
|
||||||
|
|
||||||
/// Serialises to JSON.
|
/// Serialises to JSON.
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
@@ -66,6 +93,11 @@ class PendingAssignment extends Equatable {
|
|||||||
'endTime': endTime.toIso8601String(),
|
'endTime': endTime.toIso8601String(),
|
||||||
'location': location,
|
'location': location,
|
||||||
'responseDeadline': responseDeadline.toIso8601String(),
|
'responseDeadline': responseDeadline.toIso8601String(),
|
||||||
|
'clientName': clientName,
|
||||||
|
'hourlyRateCents': hourlyRateCents,
|
||||||
|
'hourlyRate': hourlyRate,
|
||||||
|
'totalRateCents': totalRateCents,
|
||||||
|
'totalRate': totalRate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,5 +111,10 @@ class PendingAssignment extends Equatable {
|
|||||||
endTime,
|
endTime,
|
||||||
location,
|
location,
|
||||||
responseDeadline,
|
responseDeadline,
|
||||||
|
clientName,
|
||||||
|
hourlyRateCents,
|
||||||
|
hourlyRate,
|
||||||
|
totalRateCents,
|
||||||
|
totalRate,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import 'package:krow_domain/src/core/utils/utc_parser.dart';
|
||||||
import 'package:krow_domain/src/entities/enums/shift_status.dart';
|
import 'package:krow_domain/src/entities/enums/shift_status.dart';
|
||||||
|
|
||||||
/// Core shift entity aligned with the V2 `shifts` table.
|
/// Core shift entity aligned with the V2 `shifts` table.
|
||||||
@@ -48,10 +49,10 @@ class Shift extends Equatable {
|
|||||||
clientName ??
|
clientName ??
|
||||||
'',
|
'',
|
||||||
status: ShiftStatus.fromJson(json['status'] as String?),
|
status: ShiftStatus.fromJson(json['status'] as String?),
|
||||||
startsAt: DateTime.parse(
|
startsAt: parseUtcToLocal(
|
||||||
json['startsAt'] as String? ?? json['startTime'] as String,
|
json['startsAt'] as String? ?? json['startTime'] as String,
|
||||||
),
|
),
|
||||||
endsAt: DateTime.parse(
|
endsAt: parseUtcToLocal(
|
||||||
json['endsAt'] as String? ?? json['endTime'] as String,
|
json['endsAt'] as String? ?? json['endTime'] as String,
|
||||||
),
|
),
|
||||||
timezone: json['timezone'] as String? ?? 'UTC',
|
timezone: json['timezone'] as String? ?? 'UTC',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import 'package:krow_domain/src/core/utils/utc_parser.dart';
|
||||||
import 'package:krow_domain/src/entities/enums/application_status.dart';
|
import 'package:krow_domain/src/entities/enums/application_status.dart';
|
||||||
import 'package:krow_domain/src/entities/enums/assignment_status.dart';
|
import 'package:krow_domain/src/entities/enums/assignment_status.dart';
|
||||||
import 'package:krow_domain/src/entities/enums/order_type.dart';
|
import 'package:krow_domain/src/entities/enums/order_type.dart';
|
||||||
@@ -42,6 +43,7 @@ class ShiftDetail extends Equatable {
|
|||||||
this.nfcTagId,
|
this.nfcTagId,
|
||||||
this.breakDurationMinutes,
|
this.breakDurationMinutes,
|
||||||
this.isBreakPaid = false,
|
this.isBreakPaid = false,
|
||||||
|
this.cancellationReason,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Deserialises from the V2 API JSON response.
|
/// Deserialises from the V2 API JSON response.
|
||||||
@@ -55,9 +57,9 @@ class ShiftDetail extends Equatable {
|
|||||||
clientName: json['clientName'] as String? ?? '',
|
clientName: json['clientName'] as String? ?? '',
|
||||||
latitude: Shift.parseDouble(json['latitude']),
|
latitude: Shift.parseDouble(json['latitude']),
|
||||||
longitude: Shift.parseDouble(json['longitude']),
|
longitude: Shift.parseDouble(json['longitude']),
|
||||||
date: DateTime.parse(json['date'] as String),
|
date: parseUtcToLocal(json['date'] as String),
|
||||||
startTime: DateTime.parse(json['startTime'] as String),
|
startTime: parseUtcToLocal(json['startTime'] as String),
|
||||||
endTime: DateTime.parse(json['endTime'] as String),
|
endTime: parseUtcToLocal(json['endTime'] as String),
|
||||||
roleId: json['roleId'] as String,
|
roleId: json['roleId'] as String,
|
||||||
roleName: json['roleName'] as String,
|
roleName: json['roleName'] as String,
|
||||||
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
||||||
@@ -79,6 +81,7 @@ class ShiftDetail extends Equatable {
|
|||||||
nfcTagId: json['nfcTagId'] as String?,
|
nfcTagId: json['nfcTagId'] as String?,
|
||||||
breakDurationMinutes: json['breakDurationMinutes'] as int?,
|
breakDurationMinutes: json['breakDurationMinutes'] as int?,
|
||||||
isBreakPaid: json['isBreakPaid'] as bool? ?? false,
|
isBreakPaid: json['isBreakPaid'] as bool? ?? false,
|
||||||
|
cancellationReason: json['cancellationReason'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +169,9 @@ class ShiftDetail extends Equatable {
|
|||||||
/// Whether the break is paid.
|
/// Whether the break is paid.
|
||||||
final bool isBreakPaid;
|
final bool isBreakPaid;
|
||||||
|
|
||||||
|
/// Reason the shift was cancelled, if applicable.
|
||||||
|
final String? cancellationReason;
|
||||||
|
|
||||||
/// Duration of the shift in hours.
|
/// Duration of the shift in hours.
|
||||||
double get durationHours {
|
double get durationHours {
|
||||||
return endTime.difference(startTime).inMinutes / 60;
|
return endTime.difference(startTime).inMinutes / 60;
|
||||||
@@ -205,6 +211,7 @@ class ShiftDetail extends Equatable {
|
|||||||
'nfcTagId': nfcTagId,
|
'nfcTagId': nfcTagId,
|
||||||
'breakDurationMinutes': breakDurationMinutes,
|
'breakDurationMinutes': breakDurationMinutes,
|
||||||
'isBreakPaid': isBreakPaid,
|
'isBreakPaid': isBreakPaid,
|
||||||
|
'cancellationReason': cancellationReason,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,5 +245,6 @@ class ShiftDetail extends Equatable {
|
|||||||
nfcTagId,
|
nfcTagId,
|
||||||
breakDurationMinutes,
|
breakDurationMinutes,
|
||||||
isBreakPaid,
|
isBreakPaid,
|
||||||
|
cancellationReason,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import 'package:krow_domain/src/core/utils/utc_parser.dart';
|
||||||
import 'package:krow_domain/src/entities/enums/attendance_status_type.dart';
|
import 'package:krow_domain/src/entities/enums/attendance_status_type.dart';
|
||||||
|
|
||||||
/// A shift assigned to the staff member for today.
|
/// A shift assigned to the staff member for today.
|
||||||
@@ -33,8 +34,8 @@ class TodayShift extends Equatable {
|
|||||||
shiftId: json['shiftId'] as String,
|
shiftId: json['shiftId'] as String,
|
||||||
roleName: json['roleName'] as String,
|
roleName: json['roleName'] as String,
|
||||||
location: json['location'] as String? ?? '',
|
location: json['location'] as String? ?? '',
|
||||||
startTime: DateTime.parse(json['startTime'] as String),
|
startTime: parseUtcToLocal(json['startTime'] as String),
|
||||||
endTime: DateTime.parse(json['endTime'] as String),
|
endTime: parseUtcToLocal(json['endTime'] as String),
|
||||||
attendanceStatus: AttendanceStatusType.fromJson(json['attendanceStatus'] as String?),
|
attendanceStatus: AttendanceStatusType.fromJson(json['attendanceStatus'] as String?),
|
||||||
clientName: json['clientName'] as String? ?? '',
|
clientName: json['clientName'] as String? ?? '',
|
||||||
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
||||||
@@ -42,9 +43,7 @@ class TodayShift extends Equatable {
|
|||||||
totalRateCents: json['totalRateCents'] as int? ?? 0,
|
totalRateCents: json['totalRateCents'] as int? ?? 0,
|
||||||
totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0,
|
totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0,
|
||||||
locationAddress: json['locationAddress'] as String?,
|
locationAddress: json['locationAddress'] as String?,
|
||||||
clockInAt: json['clockInAt'] != null
|
clockInAt: tryParseUtcToLocal(json['clockInAt'] as String?),
|
||||||
? DateTime.parse(json['clockInAt'] as String)
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/utc_parser.dart';
|
||||||
|
|
||||||
/// Membership status within a business.
|
/// Membership status within a business.
|
||||||
enum BusinessMembershipStatus {
|
enum BusinessMembershipStatus {
|
||||||
/// The user has been invited but has not accepted.
|
/// The user has been invited but has not accepted.
|
||||||
@@ -63,8 +65,8 @@ class BusinessMembership extends Equatable {
|
|||||||
businessName: json['businessName'] as String?,
|
businessName: json['businessName'] as String?,
|
||||||
businessSlug: json['businessSlug'] as String?,
|
businessSlug: json['businessSlug'] as String?,
|
||||||
metadata: (json['metadata'] as Map<String, dynamic>?) ?? const <String, dynamic>{},
|
metadata: (json['metadata'] as Map<String, dynamic>?) ?? const <String, dynamic>{},
|
||||||
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'] as String) : null,
|
createdAt: tryParseUtcToLocal(json['createdAt'] as String?),
|
||||||
updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'] as String) : null,
|
updatedAt: tryParseUtcToLocal(json['updatedAt'] as String?),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart';
|
|||||||
|
|
||||||
import 'package:krow_domain/krow_domain.dart' show OnboardingStatus, StaffStatus;
|
import 'package:krow_domain/krow_domain.dart' show OnboardingStatus, StaffStatus;
|
||||||
|
|
||||||
|
import '../../core/utils/utc_parser.dart';
|
||||||
|
|
||||||
/// Represents a worker profile in the KROW platform.
|
/// Represents a worker profile in the KROW platform.
|
||||||
///
|
///
|
||||||
/// Maps to the V2 `staffs` table. Linked to a [User] via [userId].
|
/// Maps to the V2 `staffs` table. Linked to a [User] via [userId].
|
||||||
@@ -47,8 +49,8 @@ class Staff extends Equatable {
|
|||||||
workforceId: json['workforceId'] as String?,
|
workforceId: json['workforceId'] as String?,
|
||||||
vendorId: json['vendorId'] as String?,
|
vendorId: json['vendorId'] as String?,
|
||||||
workforceNumber: json['workforceNumber'] as String?,
|
workforceNumber: json['workforceNumber'] as String?,
|
||||||
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'] as String) : null,
|
createdAt: tryParseUtcToLocal(json['createdAt'] as String?),
|
||||||
updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'] as String) : null,
|
updatedAt: tryParseUtcToLocal(json['updatedAt'] as String?),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/utc_parser.dart';
|
||||||
|
|
||||||
/// Account status for a platform user.
|
/// Account status for a platform user.
|
||||||
enum UserStatus {
|
enum UserStatus {
|
||||||
/// User is active and can sign in.
|
/// User is active and can sign in.
|
||||||
@@ -37,8 +39,8 @@ class User extends Equatable {
|
|||||||
phone: json['phone'] as String?,
|
phone: json['phone'] as String?,
|
||||||
status: _parseUserStatus(json['status'] as String?),
|
status: _parseUserStatus(json['status'] as String?),
|
||||||
metadata: (json['metadata'] as Map<String, dynamic>?) ?? const <String, dynamic>{},
|
metadata: (json['metadata'] as Map<String, dynamic>?) ?? const <String, dynamic>{},
|
||||||
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'] as String) : null,
|
createdAt: tryParseUtcToLocal(json['createdAt'] as String?),
|
||||||
updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'] as String) : null,
|
updatedAt: tryParseUtcToLocal(json['updatedAt'] as String?),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ class _CoverageShiftListState extends State<CoverageShiftList> {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
ShiftHeader(
|
ShiftHeader(
|
||||||
title: shift.roleName,
|
title: shift.roleName,
|
||||||
|
locationName: shift.locationName,
|
||||||
startTime: _formatTime(shift.timeRange.startsAt),
|
startTime: _formatTime(shift.timeRange.startsAt),
|
||||||
current: shift.assignedWorkerCount,
|
current: shift.assignedWorkerCount,
|
||||||
total: shift.requiredWorkerCount,
|
total: shift.requiredWorkerCount,
|
||||||
@@ -226,9 +227,10 @@ class _CoverageShiftListState extends State<CoverageShiftList> {
|
|||||||
worker: worker,
|
worker: worker,
|
||||||
shiftStartTime: _formatTime(shift.timeRange.startsAt),
|
shiftStartTime: _formatTime(shift.timeRange.startsAt),
|
||||||
showRateButton:
|
showRateButton:
|
||||||
worker.status == AssignmentStatus.checkedIn ||
|
!worker.hasReview &&
|
||||||
worker.status == AssignmentStatus.checkedOut ||
|
(worker.status == AssignmentStatus.checkedIn ||
|
||||||
worker.status == AssignmentStatus.completed,
|
worker.status == AssignmentStatus.checkedOut ||
|
||||||
|
worker.status == AssignmentStatus.completed),
|
||||||
showCancelButton:
|
showCancelButton:
|
||||||
DateTime.now().isAfter(shift.timeRange.startsAt) &&
|
DateTime.now().isAfter(shift.timeRange.startsAt) &&
|
||||||
(worker.status == AssignmentStatus.noShow ||
|
(worker.status == AssignmentStatus.noShow ||
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class ShiftHeader extends StatelessWidget {
|
|||||||
required this.lateCount,
|
required this.lateCount,
|
||||||
required this.isExpanded,
|
required this.isExpanded,
|
||||||
required this.onToggle,
|
required this.onToggle,
|
||||||
|
this.locationName,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,6 +58,9 @@ class ShiftHeader extends StatelessWidget {
|
|||||||
/// Callback invoked when the header is tapped to expand or collapse.
|
/// Callback invoked when the header is tapped to expand or collapse.
|
||||||
final VoidCallback onToggle;
|
final VoidCallback onToggle;
|
||||||
|
|
||||||
|
/// Optional location or hub name for the shift.
|
||||||
|
final String? locationName;
|
||||||
|
|
||||||
/// Returns the status colour based on [coveragePercent].
|
/// Returns the status colour based on [coveragePercent].
|
||||||
///
|
///
|
||||||
/// Green for >= 100 %, yellow for >= 80 %, red otherwise.
|
/// Green for >= 100 %, yellow for >= 80 %, red otherwise.
|
||||||
@@ -110,6 +114,29 @@ class ShiftHeader extends StatelessWidget {
|
|||||||
title,
|
title,
|
||||||
style: UiTypography.body1b.textPrimary,
|
style: UiTypography.body1b.textPrimary,
|
||||||
),
|
),
|
||||||
|
if (locationName != null &&
|
||||||
|
locationName!.isNotEmpty) ...<Widget>[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
const Icon(
|
||||||
|
UiIcons.mapPin,
|
||||||
|
size: 10,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
locationName!,
|
||||||
|
style: UiTypography.body3r.copyWith(
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: UiConstants.space1),
|
const SizedBox(height: UiConstants.space1),
|
||||||
Row(
|
Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ class ReorderWidget extends StatelessWidget {
|
|||||||
borderRadius: UiConstants.radiusLg,
|
borderRadius: UiConstants.radiusLg,
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
UiIcons.building,
|
UiIcons.briefcase,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: UiColors.primary,
|
color: UiColors.primary,
|
||||||
),
|
),
|
||||||
@@ -104,18 +104,6 @@ class ReorderWidget extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Column(
|
|
||||||
// crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
// children: <Widget>[
|
|
||||||
// // ASSUMPTION: No i18n key for 'positions' under
|
|
||||||
// // reorder section — carrying forward existing
|
|
||||||
// // hardcoded string pattern for this migration.
|
|
||||||
// Text(
|
|
||||||
// '${order.positionCount} positions',
|
|
||||||
// style: UiTypography.footnote2r.textSecondary,
|
|
||||||
// ),
|
|
||||||
// ],
|
|
||||||
// ),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space3),
|
const SizedBox(height: UiConstants.space3),
|
||||||
@@ -130,7 +118,7 @@ class ReorderWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space2),
|
const SizedBox(width: UiConstants.space2),
|
||||||
_Badge(
|
_Badge(
|
||||||
icon: UiIcons.building,
|
icon: UiIcons.users,
|
||||||
text: '${order.positionCount}',
|
text: '${order.positionCount}',
|
||||||
color: UiColors.textSecondary,
|
color: UiColors.textSecondary,
|
||||||
bg: UiColors.buttonSecondaryStill,
|
bg: UiColors.buttonSecondaryStill,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class OneTimeOrderPositionArgument extends UseCaseArgument {
|
|||||||
required this.endTime,
|
required this.endTime,
|
||||||
this.roleName,
|
this.roleName,
|
||||||
this.lunchBreak,
|
this.lunchBreak,
|
||||||
|
this.hourlyRateCents,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The role ID for this position.
|
/// The role ID for this position.
|
||||||
@@ -30,9 +31,19 @@ class OneTimeOrderPositionArgument extends UseCaseArgument {
|
|||||||
/// Break duration label (e.g. `'MIN_30'`, `'NO_BREAK'`), if set.
|
/// Break duration label (e.g. `'MIN_30'`, `'NO_BREAK'`), if set.
|
||||||
final String? lunchBreak;
|
final String? lunchBreak;
|
||||||
|
|
||||||
|
/// Hourly rate in cents for this position, if set.
|
||||||
|
final int? hourlyRateCents;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props =>
|
List<Object?> get props => <Object?>[
|
||||||
<Object?>[roleId, roleName, workerCount, startTime, endTime, lunchBreak];
|
roleId,
|
||||||
|
roleName,
|
||||||
|
workerCount,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
lunchBreak,
|
||||||
|
hourlyRateCents,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Typed arguments for [CreateOneTimeOrderUseCase].
|
/// Typed arguments for [CreateOneTimeOrderUseCase].
|
||||||
@@ -63,6 +74,40 @@ class OneTimeOrderArguments extends UseCaseArgument {
|
|||||||
/// The selected vendor ID, if applicable.
|
/// The selected vendor ID, if applicable.
|
||||||
final String? vendorId;
|
final String? vendorId;
|
||||||
|
|
||||||
|
/// Serialises these arguments into the V2 API payload shape.
|
||||||
|
///
|
||||||
|
/// Times and dates are converted to UTC so the backend's
|
||||||
|
/// `combineDateAndTime` helper receives the correct values.
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final String firstStartTime =
|
||||||
|
positions.isNotEmpty ? positions.first.startTime : '00:00';
|
||||||
|
final String utcOrderDate = toUtcDateIso(orderDate, firstStartTime);
|
||||||
|
|
||||||
|
final List<Map<String, dynamic>> positionsList =
|
||||||
|
positions.map((OneTimeOrderPositionArgument p) {
|
||||||
|
return <String, dynamic>{
|
||||||
|
if (p.roleName != null) 'roleName': p.roleName,
|
||||||
|
if (p.roleId.isNotEmpty) 'roleId': p.roleId,
|
||||||
|
'workerCount': p.workerCount,
|
||||||
|
'startTime': toUtcTimeHHmm(orderDate, p.startTime),
|
||||||
|
'endTime': toUtcTimeHHmm(orderDate, p.endTime),
|
||||||
|
if (p.lunchBreak != null &&
|
||||||
|
p.lunchBreak != 'NO_BREAK' &&
|
||||||
|
p.lunchBreak!.isNotEmpty)
|
||||||
|
'lunchBreakMinutes': breakMinutesFromLabel(p.lunchBreak!),
|
||||||
|
if (p.hourlyRateCents != null) 'hourlyRateCents': p.hourlyRateCents,
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return <String, dynamic>{
|
||||||
|
'hubId': hubId,
|
||||||
|
'eventName': eventName,
|
||||||
|
'orderDate': utcOrderDate,
|
||||||
|
'positions': positionsList,
|
||||||
|
if (vendorId != null) 'vendorId': vendorId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props =>
|
List<Object?> get props =>
|
||||||
<Object?>[hubId, eventName, orderDate, positions, vendorId];
|
<Object?>[hubId, eventName, orderDate, positions, vendorId];
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class PermanentOrderPositionArgument extends UseCaseArgument {
|
|||||||
required this.startTime,
|
required this.startTime,
|
||||||
required this.endTime,
|
required this.endTime,
|
||||||
this.roleName,
|
this.roleName,
|
||||||
|
this.hourlyRateCents,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The role ID for this position.
|
/// The role ID for this position.
|
||||||
@@ -26,9 +27,18 @@ class PermanentOrderPositionArgument extends UseCaseArgument {
|
|||||||
/// Shift end time in HH:mm format.
|
/// Shift end time in HH:mm format.
|
||||||
final String endTime;
|
final String endTime;
|
||||||
|
|
||||||
|
/// Hourly rate in cents for this position, if set.
|
||||||
|
final int? hourlyRateCents;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props =>
|
List<Object?> get props => <Object?>[
|
||||||
<Object?>[roleId, roleName, workerCount, startTime, endTime];
|
roleId,
|
||||||
|
roleName,
|
||||||
|
workerCount,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
hourlyRateCents,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Typed arguments for [CreatePermanentOrderUseCase].
|
/// Typed arguments for [CreatePermanentOrderUseCase].
|
||||||
@@ -63,6 +73,52 @@ class PermanentOrderArguments extends UseCaseArgument {
|
|||||||
/// The selected vendor ID, if applicable.
|
/// The selected vendor ID, if applicable.
|
||||||
final String? vendorId;
|
final String? vendorId;
|
||||||
|
|
||||||
|
/// Day-of-week labels in Sunday-first order, matching the V2 API convention.
|
||||||
|
static const List<String> _dayLabels = <String>[
|
||||||
|
'SUN',
|
||||||
|
'MON',
|
||||||
|
'TUE',
|
||||||
|
'WED',
|
||||||
|
'THU',
|
||||||
|
'FRI',
|
||||||
|
'SAT',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Serialises these arguments into the V2 API payload shape.
|
||||||
|
///
|
||||||
|
/// Times and dates are converted to UTC so the backend's
|
||||||
|
/// `combineDateAndTime` helper receives the correct values.
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final String firstStartTime =
|
||||||
|
positions.isNotEmpty ? positions.first.startTime : '00:00';
|
||||||
|
final String utcStartDate = toUtcDateIso(startDate, firstStartTime);
|
||||||
|
|
||||||
|
final List<int> daysOfWeekList = daysOfWeek
|
||||||
|
.map((String day) => _dayLabels.indexOf(day) % 7)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final List<Map<String, dynamic>> positionsList =
|
||||||
|
positions.map((PermanentOrderPositionArgument p) {
|
||||||
|
return <String, dynamic>{
|
||||||
|
if (p.roleName != null) 'roleName': p.roleName,
|
||||||
|
if (p.roleId.isNotEmpty) 'roleId': p.roleId,
|
||||||
|
'workerCount': p.workerCount,
|
||||||
|
'startTime': toUtcTimeHHmm(startDate, p.startTime),
|
||||||
|
'endTime': toUtcTimeHHmm(startDate, p.endTime),
|
||||||
|
if (p.hourlyRateCents != null) 'hourlyRateCents': p.hourlyRateCents,
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return <String, dynamic>{
|
||||||
|
'hubId': hubId,
|
||||||
|
'eventName': eventName,
|
||||||
|
'startDate': utcStartDate,
|
||||||
|
'daysOfWeek': daysOfWeekList,
|
||||||
|
'positions': positionsList,
|
||||||
|
if (vendorId != null) 'vendorId': vendorId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => <Object?>[
|
List<Object?> get props => <Object?>[
|
||||||
hubId,
|
hubId,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class RecurringOrderPositionArgument extends UseCaseArgument {
|
|||||||
required this.startTime,
|
required this.startTime,
|
||||||
required this.endTime,
|
required this.endTime,
|
||||||
this.roleName,
|
this.roleName,
|
||||||
|
this.hourlyRateCents,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The role ID for this position.
|
/// The role ID for this position.
|
||||||
@@ -26,9 +27,18 @@ class RecurringOrderPositionArgument extends UseCaseArgument {
|
|||||||
/// Shift end time in HH:mm format.
|
/// Shift end time in HH:mm format.
|
||||||
final String endTime;
|
final String endTime;
|
||||||
|
|
||||||
|
/// Hourly rate in cents for this position, if set.
|
||||||
|
final int? hourlyRateCents;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props =>
|
List<Object?> get props => <Object?>[
|
||||||
<Object?>[roleId, roleName, workerCount, startTime, endTime];
|
roleId,
|
||||||
|
roleName,
|
||||||
|
workerCount,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
hourlyRateCents,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Typed arguments for [CreateRecurringOrderUseCase].
|
/// Typed arguments for [CreateRecurringOrderUseCase].
|
||||||
@@ -67,6 +77,54 @@ class RecurringOrderArguments extends UseCaseArgument {
|
|||||||
/// The selected vendor ID, if applicable.
|
/// The selected vendor ID, if applicable.
|
||||||
final String? vendorId;
|
final String? vendorId;
|
||||||
|
|
||||||
|
/// Day-of-week labels in Sunday-first order, matching the V2 API convention.
|
||||||
|
static const List<String> _dayLabels = <String>[
|
||||||
|
'SUN',
|
||||||
|
'MON',
|
||||||
|
'TUE',
|
||||||
|
'WED',
|
||||||
|
'THU',
|
||||||
|
'FRI',
|
||||||
|
'SAT',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Serialises these arguments into the V2 API payload shape.
|
||||||
|
///
|
||||||
|
/// Times and dates are converted to UTC so the backend's
|
||||||
|
/// `combineDateAndTime` helper receives the correct values.
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final String firstStartTime =
|
||||||
|
positions.isNotEmpty ? positions.first.startTime : '00:00';
|
||||||
|
final String utcStartDate = toUtcDateIso(startDate, firstStartTime);
|
||||||
|
final String utcEndDate = toUtcDateIso(endDate, firstStartTime);
|
||||||
|
|
||||||
|
final List<int> recurrenceDaysList = recurringDays
|
||||||
|
.map((String day) => _dayLabels.indexOf(day) % 7)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final List<Map<String, dynamic>> positionsList =
|
||||||
|
positions.map((RecurringOrderPositionArgument p) {
|
||||||
|
return <String, dynamic>{
|
||||||
|
if (p.roleName != null) 'roleName': p.roleName,
|
||||||
|
if (p.roleId.isNotEmpty) 'roleId': p.roleId,
|
||||||
|
'workerCount': p.workerCount,
|
||||||
|
'startTime': toUtcTimeHHmm(startDate, p.startTime),
|
||||||
|
'endTime': toUtcTimeHHmm(startDate, p.endTime),
|
||||||
|
if (p.hourlyRateCents != null) 'hourlyRateCents': p.hourlyRateCents,
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return <String, dynamic>{
|
||||||
|
'hubId': hubId,
|
||||||
|
'eventName': eventName,
|
||||||
|
'startDate': utcStartDate,
|
||||||
|
'endDate': utcEndDate,
|
||||||
|
'recurrenceDays': recurrenceDaysList,
|
||||||
|
'positions': positionsList,
|
||||||
|
if (vendorId != null) 'vendorId': vendorId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => <Object?>[
|
List<Object?> get props => <Object?>[
|
||||||
hubId,
|
hubId,
|
||||||
|
|||||||
@@ -1,49 +1,19 @@
|
|||||||
import 'package:krow_core/core.dart';
|
|
||||||
|
|
||||||
import '../arguments/one_time_order_arguments.dart';
|
import '../arguments/one_time_order_arguments.dart';
|
||||||
import '../repositories/client_create_order_repository_interface.dart';
|
import '../repositories/client_create_order_repository_interface.dart';
|
||||||
|
|
||||||
/// Use case for creating a one-time staffing order.
|
/// Use case for creating a one-time staffing order.
|
||||||
///
|
///
|
||||||
/// Builds the V2 API payload from typed [OneTimeOrderArguments] and
|
/// Delegates payload construction to [OneTimeOrderArguments.toJson] and
|
||||||
/// delegates submission to the repository. Payload construction (date
|
/// submission to the repository.
|
||||||
/// formatting, position mapping, break-minutes conversion) is business
|
class CreateOneTimeOrderUseCase {
|
||||||
/// logic that belongs here, not in the BLoC.
|
|
||||||
class CreateOneTimeOrderUseCase
|
|
||||||
implements UseCase<OneTimeOrderArguments, void> {
|
|
||||||
/// Creates a [CreateOneTimeOrderUseCase].
|
/// Creates a [CreateOneTimeOrderUseCase].
|
||||||
const CreateOneTimeOrderUseCase(this._repository);
|
const CreateOneTimeOrderUseCase(this._repository);
|
||||||
|
|
||||||
/// The create-order repository.
|
/// The create-order repository.
|
||||||
final ClientCreateOrderRepositoryInterface _repository;
|
final ClientCreateOrderRepositoryInterface _repository;
|
||||||
|
|
||||||
@override
|
/// Creates a one-time order from the given arguments.
|
||||||
Future<void> call(OneTimeOrderArguments input) {
|
Future<void> call(OneTimeOrderArguments input) {
|
||||||
final String orderDate = formatDateToIso(input.orderDate);
|
return _repository.createOneTimeOrder(input.toJson());
|
||||||
|
|
||||||
final List<Map<String, dynamic>> positions =
|
|
||||||
input.positions.map((OneTimeOrderPositionArgument p) {
|
|
||||||
return <String, dynamic>{
|
|
||||||
if (p.roleName != null) 'roleName': p.roleName,
|
|
||||||
if (p.roleId.isNotEmpty) 'roleId': p.roleId,
|
|
||||||
'workerCount': p.workerCount,
|
|
||||||
'startTime': p.startTime,
|
|
||||||
'endTime': p.endTime,
|
|
||||||
if (p.lunchBreak != null &&
|
|
||||||
p.lunchBreak != 'NO_BREAK' &&
|
|
||||||
p.lunchBreak!.isNotEmpty)
|
|
||||||
'lunchBreakMinutes': breakMinutesFromLabel(p.lunchBreak!),
|
|
||||||
};
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
final Map<String, dynamic> payload = <String, dynamic>{
|
|
||||||
'hubId': input.hubId,
|
|
||||||
'eventName': input.eventName,
|
|
||||||
'orderDate': orderDate,
|
|
||||||
'positions': positions,
|
|
||||||
if (input.vendorId != null) 'vendorId': input.vendorId,
|
|
||||||
};
|
|
||||||
|
|
||||||
return _repository.createOneTimeOrder(payload);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +1,19 @@
|
|||||||
import 'package:krow_core/core.dart';
|
|
||||||
|
|
||||||
import '../arguments/permanent_order_arguments.dart';
|
import '../arguments/permanent_order_arguments.dart';
|
||||||
import '../repositories/client_create_order_repository_interface.dart';
|
import '../repositories/client_create_order_repository_interface.dart';
|
||||||
|
|
||||||
/// Day-of-week labels in Sunday-first order, matching the V2 API convention.
|
|
||||||
const List<String> _dayLabels = <String>[
|
|
||||||
'SUN',
|
|
||||||
'MON',
|
|
||||||
'TUE',
|
|
||||||
'WED',
|
|
||||||
'THU',
|
|
||||||
'FRI',
|
|
||||||
'SAT',
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Use case for creating a permanent staffing order.
|
/// Use case for creating a permanent staffing order.
|
||||||
///
|
///
|
||||||
/// Builds the V2 API payload from typed [PermanentOrderArguments] and
|
/// Delegates payload construction to [PermanentOrderArguments.toJson] and
|
||||||
/// delegates submission to the repository. Payload construction (date
|
/// submission to the repository.
|
||||||
/// formatting, day-of-week mapping, position mapping) is business
|
class CreatePermanentOrderUseCase {
|
||||||
/// logic that belongs here, not in the BLoC.
|
|
||||||
class CreatePermanentOrderUseCase
|
|
||||||
implements UseCase<PermanentOrderArguments, void> {
|
|
||||||
/// Creates a [CreatePermanentOrderUseCase].
|
/// Creates a [CreatePermanentOrderUseCase].
|
||||||
const CreatePermanentOrderUseCase(this._repository);
|
const CreatePermanentOrderUseCase(this._repository);
|
||||||
|
|
||||||
/// The create-order repository.
|
/// The create-order repository.
|
||||||
final ClientCreateOrderRepositoryInterface _repository;
|
final ClientCreateOrderRepositoryInterface _repository;
|
||||||
|
|
||||||
@override
|
/// Creates a permanent order from the given arguments.
|
||||||
Future<void> call(PermanentOrderArguments input) {
|
Future<void> call(PermanentOrderArguments input) {
|
||||||
final String startDate = formatDateToIso(input.startDate);
|
return _repository.createPermanentOrder(input.toJson());
|
||||||
|
|
||||||
final List<int> daysOfWeek = input.daysOfWeek
|
|
||||||
.map((String day) => _dayLabels.indexOf(day) % 7)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
final List<Map<String, dynamic>> positions =
|
|
||||||
input.positions.map((PermanentOrderPositionArgument p) {
|
|
||||||
return <String, dynamic>{
|
|
||||||
if (p.roleName != null) 'roleName': p.roleName,
|
|
||||||
if (p.roleId.isNotEmpty) 'roleId': p.roleId,
|
|
||||||
'workerCount': p.workerCount,
|
|
||||||
'startTime': p.startTime,
|
|
||||||
'endTime': p.endTime,
|
|
||||||
};
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
final Map<String, dynamic> payload = <String, dynamic>{
|
|
||||||
'hubId': input.hubId,
|
|
||||||
'eventName': input.eventName,
|
|
||||||
'startDate': startDate,
|
|
||||||
'daysOfWeek': daysOfWeek,
|
|
||||||
'positions': positions,
|
|
||||||
if (input.vendorId != null) 'vendorId': input.vendorId,
|
|
||||||
};
|
|
||||||
|
|
||||||
return _repository.createPermanentOrder(payload);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +1,19 @@
|
|||||||
import 'package:krow_core/core.dart';
|
|
||||||
|
|
||||||
import '../arguments/recurring_order_arguments.dart';
|
import '../arguments/recurring_order_arguments.dart';
|
||||||
import '../repositories/client_create_order_repository_interface.dart';
|
import '../repositories/client_create_order_repository_interface.dart';
|
||||||
|
|
||||||
/// Day-of-week labels in Sunday-first order, matching the V2 API convention.
|
|
||||||
const List<String> _dayLabels = <String>[
|
|
||||||
'SUN',
|
|
||||||
'MON',
|
|
||||||
'TUE',
|
|
||||||
'WED',
|
|
||||||
'THU',
|
|
||||||
'FRI',
|
|
||||||
'SAT',
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Use case for creating a recurring staffing order.
|
/// Use case for creating a recurring staffing order.
|
||||||
///
|
///
|
||||||
/// Builds the V2 API payload from typed [RecurringOrderArguments] and
|
/// Delegates payload construction to [RecurringOrderArguments.toJson] and
|
||||||
/// delegates submission to the repository. Payload construction (date
|
/// submission to the repository.
|
||||||
/// formatting, recurrence-day mapping, position mapping) is business
|
class CreateRecurringOrderUseCase {
|
||||||
/// logic that belongs here, not in the BLoC.
|
|
||||||
class CreateRecurringOrderUseCase
|
|
||||||
implements UseCase<RecurringOrderArguments, void> {
|
|
||||||
/// Creates a [CreateRecurringOrderUseCase].
|
/// Creates a [CreateRecurringOrderUseCase].
|
||||||
const CreateRecurringOrderUseCase(this._repository);
|
const CreateRecurringOrderUseCase(this._repository);
|
||||||
|
|
||||||
/// The create-order repository.
|
/// The create-order repository.
|
||||||
final ClientCreateOrderRepositoryInterface _repository;
|
final ClientCreateOrderRepositoryInterface _repository;
|
||||||
|
|
||||||
@override
|
/// Creates a recurring order from the given arguments.
|
||||||
Future<void> call(RecurringOrderArguments input) {
|
Future<void> call(RecurringOrderArguments input) {
|
||||||
final String startDate = formatDateToIso(input.startDate);
|
return _repository.createRecurringOrder(input.toJson());
|
||||||
final String endDate = formatDateToIso(input.endDate);
|
|
||||||
|
|
||||||
final List<int> recurrenceDays = input.recurringDays
|
|
||||||
.map((String day) => _dayLabels.indexOf(day) % 7)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
final List<Map<String, dynamic>> positions =
|
|
||||||
input.positions.map((RecurringOrderPositionArgument p) {
|
|
||||||
return <String, dynamic>{
|
|
||||||
if (p.roleName != null) 'roleName': p.roleName,
|
|
||||||
if (p.roleId.isNotEmpty) 'roleId': p.roleId,
|
|
||||||
'workerCount': p.workerCount,
|
|
||||||
'startTime': p.startTime,
|
|
||||||
'endTime': p.endTime,
|
|
||||||
};
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
final Map<String, dynamic> payload = <String, dynamic>{
|
|
||||||
'hubId': input.hubId,
|
|
||||||
'eventName': input.eventName,
|
|
||||||
'startDate': startDate,
|
|
||||||
'endDate': endDate,
|
|
||||||
'recurrenceDays': recurrenceDays,
|
|
||||||
'positions': positions,
|
|
||||||
if (input.vendorId != null) 'vendorId': input.vendorId,
|
|
||||||
};
|
|
||||||
|
|
||||||
return _repository.createRecurringOrder(payload);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -265,6 +265,8 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
|||||||
startTime: p.startTime,
|
startTime: p.startTime,
|
||||||
endTime: p.endTime,
|
endTime: p.endTime,
|
||||||
lunchBreak: p.lunchBreak,
|
lunchBreak: p.lunchBreak,
|
||||||
|
hourlyRateCents:
|
||||||
|
role != null ? (role.costPerHour * 100).round() : null,
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
|
|||||||
@@ -360,6 +360,8 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
|||||||
workerCount: p.count,
|
workerCount: p.count,
|
||||||
startTime: p.startTime,
|
startTime: p.startTime,
|
||||||
endTime: p.endTime,
|
endTime: p.endTime,
|
||||||
|
hourlyRateCents:
|
||||||
|
role != null ? (role.costPerHour * 100).round() : null,
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
|
|||||||
@@ -380,6 +380,8 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
|||||||
workerCount: p.count,
|
workerCount: p.count,
|
||||||
startTime: p.startTime,
|
startTime: p.startTime,
|
||||||
endTime: p.endTime,
|
endTime: p.endTime,
|
||||||
|
hourlyRateCents:
|
||||||
|
role != null ? (role.costPerHour * 100).round() : null,
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ class ViewOrdersRepositoryImpl implements ViewOrdersRepositoryInterface {
|
|||||||
final ApiResponse response = await _api.get(
|
final ApiResponse response = await _api.get(
|
||||||
ClientEndpoints.ordersView,
|
ClientEndpoints.ordersView,
|
||||||
params: <String, dynamic>{
|
params: <String, dynamic>{
|
||||||
'startDate': start.toIso8601String(),
|
'startDate': start.toUtc().toIso8601String(),
|
||||||
'endDate': end.toIso8601String(),
|
'endDate': end.toUtc().toIso8601String(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
|
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
|
||||||
|
|||||||
@@ -48,13 +48,13 @@ class OrderEditSheetState extends State<OrderEditSheet> {
|
|||||||
_orderNameController = TextEditingController(text: widget.order.roleName);
|
_orderNameController = TextEditingController(text: widget.order.roleName);
|
||||||
|
|
||||||
final String startHH =
|
final String startHH =
|
||||||
widget.order.startsAt.toLocal().hour.toString().padLeft(2, '0');
|
widget.order.startsAt.hour.toString().padLeft(2, '0');
|
||||||
final String startMM =
|
final String startMM =
|
||||||
widget.order.startsAt.toLocal().minute.toString().padLeft(2, '0');
|
widget.order.startsAt.minute.toString().padLeft(2, '0');
|
||||||
final String endHH =
|
final String endHH =
|
||||||
widget.order.endsAt.toLocal().hour.toString().padLeft(2, '0');
|
widget.order.endsAt.hour.toString().padLeft(2, '0');
|
||||||
final String endMM =
|
final String endMM =
|
||||||
widget.order.endsAt.toLocal().minute.toString().padLeft(2, '0');
|
widget.order.endsAt.minute.toString().padLeft(2, '0');
|
||||||
|
|
||||||
_positions = <Map<String, dynamic>>[
|
_positions = <Map<String, dynamic>>[
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
|
|||||||
@@ -77,9 +77,8 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
|
|||||||
|
|
||||||
/// Formats a [DateTime] to a display time string (e.g. "9:00 AM").
|
/// Formats a [DateTime] to a display time string (e.g. "9:00 AM").
|
||||||
String _formatTime({required DateTime dateTime}) {
|
String _formatTime({required DateTime dateTime}) {
|
||||||
final DateTime local = dateTime.toLocal();
|
final int hour24 = dateTime.hour;
|
||||||
final int hour24 = local.hour;
|
final int minute = dateTime.minute;
|
||||||
final int minute = local.minute;
|
|
||||||
final String ampm = hour24 >= 12 ? 'PM' : 'AM';
|
final String ampm = hour24 >= 12 ? 'PM' : 'AM';
|
||||||
int hour = hour24 % 12;
|
int hour = hour24 % 12;
|
||||||
if (hour == 0) hour = 12;
|
if (hour == 0) hour = 12;
|
||||||
@@ -124,7 +123,9 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
|
|||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
final double hours = _computeHours(order);
|
final double hours = _computeHours(order);
|
||||||
final double cost = order.totalCostCents / 100.0;
|
final double cost = order.totalValue > 0
|
||||||
|
? order.totalValue
|
||||||
|
: order.totalCostCents / 100.0;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|||||||
@@ -229,12 +229,20 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
|||||||
event: event,
|
event: event,
|
||||||
activeShiftId: newStatus.activeShiftId,
|
activeShiftId: newStatus.activeShiftId,
|
||||||
);
|
);
|
||||||
} on AppException catch (_) {
|
} on AppException catch (e) {
|
||||||
// The clock-in API call failed. Re-fetch attendance status to
|
// The backend returns 409 ALREADY_CLOCKED_IN when the worker has
|
||||||
// reconcile: if the worker is already clocked in (e.g. duplicate
|
// an active attendance session. This is a normal idempotency
|
||||||
// session from Postgres constraint 23505), treat it as success.
|
// signal — re-fetch the authoritative status and emit success
|
||||||
|
// without surfacing an error snackbar.
|
||||||
|
final bool isAlreadyClockedIn =
|
||||||
|
e is ApiException && e.apiCode == 'ALREADY_CLOCKED_IN';
|
||||||
|
|
||||||
|
// Re-fetch attendance status to reconcile local state with
|
||||||
|
// the backend (handles both ALREADY_CLOCKED_IN and legacy
|
||||||
|
// Postgres constraint 23505 duplicates).
|
||||||
final AttendanceStatus currentStatus = await _getAttendanceStatus();
|
final AttendanceStatus currentStatus = await _getAttendanceStatus();
|
||||||
if (currentStatus.isClockedIn) {
|
|
||||||
|
if (isAlreadyClockedIn || currentStatus.isClockedIn) {
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
status: ClockInStatus.success,
|
status: ClockInStatus.success,
|
||||||
attendance: currentStatus,
|
attendance: currentStatus,
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ class ProfileRepositoryImpl implements ProfileRepositoryInterface {
|
|||||||
return ProfileSectionStatus.fromJson(json);
|
return ProfileSectionStatus.fromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<StaffReliabilityStats> getReliabilityStats() async {
|
||||||
|
final ApiResponse response =
|
||||||
|
await _api.get(StaffEndpoints.profileStats);
|
||||||
|
final Map<String, dynamic> json =
|
||||||
|
response.data as Map<String, dynamic>;
|
||||||
|
return StaffReliabilityStats.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> signOut() async {
|
Future<void> signOut() async {
|
||||||
await _api.post(AuthEndpoints.signOut);
|
await _api.post(AuthEndpoints.signOut);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import 'package:krow_domain/krow_domain.dart';
|
|||||||
/// Abstract interface for the staff profile repository.
|
/// Abstract interface for the staff profile repository.
|
||||||
///
|
///
|
||||||
/// Defines the contract for fetching staff profile data,
|
/// Defines the contract for fetching staff profile data,
|
||||||
/// section completion statuses, and signing out.
|
/// section completion statuses, reliability stats, and signing out.
|
||||||
abstract interface class ProfileRepositoryInterface {
|
abstract interface class ProfileRepositoryInterface {
|
||||||
/// Fetches the staff profile from the backend.
|
/// Fetches the staff profile from the backend.
|
||||||
Future<Staff> getStaffProfile();
|
Future<Staff> getStaffProfile();
|
||||||
@@ -11,6 +11,9 @@ abstract interface class ProfileRepositoryInterface {
|
|||||||
/// Fetches the profile section completion statuses.
|
/// Fetches the profile section completion statuses.
|
||||||
Future<ProfileSectionStatus> getProfileSections();
|
Future<ProfileSectionStatus> getProfileSections();
|
||||||
|
|
||||||
|
/// Fetches reliability and performance statistics for the staff member.
|
||||||
|
Future<StaffReliabilityStats> getReliabilityStats();
|
||||||
|
|
||||||
/// Signs out the current user.
|
/// Signs out the current user.
|
||||||
Future<void> signOut();
|
Future<void> signOut();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart';
|
||||||
|
|
||||||
|
/// Use case for retrieving the staff member's reliability statistics.
|
||||||
|
class GetReliabilityStatsUseCase
|
||||||
|
implements NoInputUseCase<StaffReliabilityStats> {
|
||||||
|
/// Creates a [GetReliabilityStatsUseCase] with the required [repository].
|
||||||
|
GetReliabilityStatsUseCase(this._repository);
|
||||||
|
|
||||||
|
final ProfileRepositoryInterface _repository;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<StaffReliabilityStats> call() {
|
||||||
|
return _repository.getReliabilityStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:krow_core/core.dart';
|
|||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
import 'package:staff_profile/src/domain/usecases/get_profile_sections_usecase.dart';
|
import 'package:staff_profile/src/domain/usecases/get_profile_sections_usecase.dart';
|
||||||
|
import 'package:staff_profile/src/domain/usecases/get_reliability_stats_usecase.dart';
|
||||||
import 'package:staff_profile/src/domain/usecases/get_staff_profile_usecase.dart';
|
import 'package:staff_profile/src/domain/usecases/get_staff_profile_usecase.dart';
|
||||||
import 'package:staff_profile/src/domain/usecases/sign_out_usecase.dart';
|
import 'package:staff_profile/src/domain/usecases/sign_out_usecase.dart';
|
||||||
import 'package:staff_profile/src/presentation/blocs/profile_state.dart';
|
import 'package:staff_profile/src/presentation/blocs/profile_state.dart';
|
||||||
@@ -10,21 +11,24 @@ import 'package:staff_profile/src/presentation/blocs/profile_state.dart';
|
|||||||
/// Cubit for managing the Profile feature state.
|
/// Cubit for managing the Profile feature state.
|
||||||
///
|
///
|
||||||
/// Delegates all data fetching to use cases, following Clean Architecture.
|
/// Delegates all data fetching to use cases, following Clean Architecture.
|
||||||
/// Loads the staff profile and section completion statuses in a single flow.
|
/// Loads the staff profile, section statuses, and reliability stats.
|
||||||
class ProfileCubit extends Cubit<ProfileState>
|
class ProfileCubit extends Cubit<ProfileState>
|
||||||
with BlocErrorHandler<ProfileState> {
|
with BlocErrorHandler<ProfileState> {
|
||||||
/// Creates a [ProfileCubit] with the required use cases.
|
/// Creates a [ProfileCubit] with the required use cases.
|
||||||
ProfileCubit({
|
ProfileCubit({
|
||||||
required GetStaffProfileUseCase getStaffProfileUseCase,
|
required GetStaffProfileUseCase getStaffProfileUseCase,
|
||||||
required GetProfileSectionsUseCase getProfileSectionsUseCase,
|
required GetProfileSectionsUseCase getProfileSectionsUseCase,
|
||||||
|
required GetReliabilityStatsUseCase getReliabilityStatsUseCase,
|
||||||
required SignOutUseCase signOutUseCase,
|
required SignOutUseCase signOutUseCase,
|
||||||
}) : _getStaffProfileUseCase = getStaffProfileUseCase,
|
}) : _getStaffProfileUseCase = getStaffProfileUseCase,
|
||||||
_getProfileSectionsUseCase = getProfileSectionsUseCase,
|
_getProfileSectionsUseCase = getProfileSectionsUseCase,
|
||||||
|
_getReliabilityStatsUseCase = getReliabilityStatsUseCase,
|
||||||
_signOutUseCase = signOutUseCase,
|
_signOutUseCase = signOutUseCase,
|
||||||
super(const ProfileState());
|
super(const ProfileState());
|
||||||
|
|
||||||
final GetStaffProfileUseCase _getStaffProfileUseCase;
|
final GetStaffProfileUseCase _getStaffProfileUseCase;
|
||||||
final GetProfileSectionsUseCase _getProfileSectionsUseCase;
|
final GetProfileSectionsUseCase _getProfileSectionsUseCase;
|
||||||
|
final GetReliabilityStatsUseCase _getReliabilityStatsUseCase;
|
||||||
final SignOutUseCase _signOutUseCase;
|
final SignOutUseCase _signOutUseCase;
|
||||||
|
|
||||||
/// Loads the staff member's profile.
|
/// Loads the staff member's profile.
|
||||||
@@ -62,6 +66,19 @@ class ProfileCubit extends Cubit<ProfileState>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Loads reliability and performance statistics for the staff member.
|
||||||
|
Future<void> loadReliabilityStats() async {
|
||||||
|
await handleError(
|
||||||
|
emit: emit,
|
||||||
|
action: () async {
|
||||||
|
final StaffReliabilityStats stats =
|
||||||
|
await _getReliabilityStatsUseCase();
|
||||||
|
emit(state.copyWith(reliabilityStats: stats));
|
||||||
|
},
|
||||||
|
onError: (String _) => state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Signs out the current user.
|
/// Signs out the current user.
|
||||||
Future<void> signOut() async {
|
Future<void> signOut() async {
|
||||||
if (state.status == ProfileStatus.loading) {
|
if (state.status == ProfileStatus.loading) {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class ProfileState extends Equatable {
|
|||||||
const ProfileState({
|
const ProfileState({
|
||||||
this.status = ProfileStatus.initial,
|
this.status = ProfileStatus.initial,
|
||||||
this.profile,
|
this.profile,
|
||||||
|
this.reliabilityStats,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
this.personalInfoComplete,
|
this.personalInfoComplete,
|
||||||
this.emergencyContactsComplete,
|
this.emergencyContactsComplete,
|
||||||
@@ -37,40 +38,45 @@ class ProfileState extends Equatable {
|
|||||||
this.documentsComplete,
|
this.documentsComplete,
|
||||||
this.certificatesComplete,
|
this.certificatesComplete,
|
||||||
});
|
});
|
||||||
/// Current status of the profile feature
|
|
||||||
|
/// Current status of the profile feature.
|
||||||
final ProfileStatus status;
|
final ProfileStatus status;
|
||||||
|
|
||||||
/// The staff member's profile object (null if not loaded)
|
/// The staff member's profile object (null if not loaded).
|
||||||
final Staff? profile;
|
final Staff? profile;
|
||||||
|
|
||||||
/// Error message if status is error
|
/// Reliability and performance statistics (null if not loaded).
|
||||||
|
final StaffReliabilityStats? reliabilityStats;
|
||||||
|
|
||||||
|
/// Error message if status is error.
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
|
||||||
/// Whether personal information is complete
|
/// Whether personal information is complete.
|
||||||
final bool? personalInfoComplete;
|
final bool? personalInfoComplete;
|
||||||
|
|
||||||
/// Whether emergency contacts are complete
|
/// Whether emergency contacts are complete.
|
||||||
final bool? emergencyContactsComplete;
|
final bool? emergencyContactsComplete;
|
||||||
|
|
||||||
/// Whether experience information is complete
|
/// Whether experience information is complete.
|
||||||
final bool? experienceComplete;
|
final bool? experienceComplete;
|
||||||
|
|
||||||
/// Whether tax forms are complete
|
/// Whether tax forms are complete.
|
||||||
final bool? taxFormsComplete;
|
final bool? taxFormsComplete;
|
||||||
|
|
||||||
/// Whether attire options are complete
|
/// Whether attire options are complete.
|
||||||
final bool? attireComplete;
|
final bool? attireComplete;
|
||||||
|
|
||||||
/// Whether documents are complete
|
/// Whether documents are complete.
|
||||||
final bool? documentsComplete;
|
final bool? documentsComplete;
|
||||||
|
|
||||||
/// Whether certificates are complete
|
/// Whether certificates are complete.
|
||||||
final bool? certificatesComplete;
|
final bool? certificatesComplete;
|
||||||
|
|
||||||
/// Creates a copy of this state with updated values.
|
/// Creates a copy of this state with updated values.
|
||||||
ProfileState copyWith({
|
ProfileState copyWith({
|
||||||
ProfileStatus? status,
|
ProfileStatus? status,
|
||||||
Staff? profile,
|
Staff? profile,
|
||||||
|
StaffReliabilityStats? reliabilityStats,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
bool? personalInfoComplete,
|
bool? personalInfoComplete,
|
||||||
bool? emergencyContactsComplete,
|
bool? emergencyContactsComplete,
|
||||||
@@ -83,6 +89,7 @@ class ProfileState extends Equatable {
|
|||||||
return ProfileState(
|
return ProfileState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
profile: profile ?? this.profile,
|
profile: profile ?? this.profile,
|
||||||
|
reliabilityStats: reliabilityStats ?? this.reliabilityStats,
|
||||||
errorMessage: errorMessage ?? this.errorMessage,
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
personalInfoComplete: personalInfoComplete ?? this.personalInfoComplete,
|
personalInfoComplete: personalInfoComplete ?? this.personalInfoComplete,
|
||||||
emergencyContactsComplete: emergencyContactsComplete ?? this.emergencyContactsComplete,
|
emergencyContactsComplete: emergencyContactsComplete ?? this.emergencyContactsComplete,
|
||||||
@@ -98,6 +105,7 @@ class ProfileState extends Equatable {
|
|||||||
List<Object?> get props => <Object?>[
|
List<Object?> get props => <Object?>[
|
||||||
status,
|
status,
|
||||||
profile,
|
profile,
|
||||||
|
reliabilityStats,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
personalInfoComplete,
|
personalInfoComplete,
|
||||||
emergencyContactsComplete,
|
emergencyContactsComplete,
|
||||||
|
|||||||
@@ -37,10 +37,11 @@ class StaffProfilePage extends StatelessWidget {
|
|||||||
value: cubit,
|
value: cubit,
|
||||||
child: BlocConsumer<ProfileCubit, ProfileState>(
|
child: BlocConsumer<ProfileCubit, ProfileState>(
|
||||||
listener: (BuildContext context, ProfileState state) {
|
listener: (BuildContext context, ProfileState state) {
|
||||||
// Load section statuses when profile loads successfully
|
// Load section statuses and reliability stats when profile loads
|
||||||
if (state.status == ProfileStatus.loaded &&
|
if (state.status == ProfileStatus.loaded &&
|
||||||
state.personalInfoComplete == null) {
|
state.personalInfoComplete == null) {
|
||||||
cubit.loadSectionStatuses();
|
cubit.loadSectionStatuses();
|
||||||
|
cubit.loadReliabilityStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.status == ProfileStatus.signedOut) {
|
if (state.status == ProfileStatus.signedOut) {
|
||||||
@@ -100,16 +101,16 @@ class StaffProfilePage extends StatelessWidget {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
// Reliability Stats
|
// Reliability Stats
|
||||||
ReliabilityStatsCard(
|
ReliabilityStatsCard(
|
||||||
totalShifts: 0,
|
totalShifts: state.reliabilityStats?.totalShifts,
|
||||||
averageRating: profile.averageRating,
|
averageRating: state.reliabilityStats?.averageRating,
|
||||||
onTimeRate: 0,
|
onTimeRate: state.reliabilityStats?.onTimeRate.round(),
|
||||||
noShowCount: 0,
|
noShowCount: state.reliabilityStats?.noShowCount,
|
||||||
cancellationCount: 0,
|
cancellationCount: state.reliabilityStats?.cancellationCount,
|
||||||
),
|
),
|
||||||
|
|
||||||
// Reliability Score Bar
|
// Reliability Score Bar
|
||||||
const ReliabilityScoreBar(
|
ReliabilityScoreBar(
|
||||||
reliabilityScore: 0,
|
reliabilityScore: state.reliabilityStats?.reliabilityScore.round(),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Ordered sections
|
// Ordered sections
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:krow_domain/krow_domain.dart';
|
|||||||
import 'package:staff_profile/src/data/repositories/profile_repository_impl.dart';
|
import 'package:staff_profile/src/data/repositories/profile_repository_impl.dart';
|
||||||
import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart';
|
import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart';
|
||||||
import 'package:staff_profile/src/domain/usecases/get_profile_sections_usecase.dart';
|
import 'package:staff_profile/src/domain/usecases/get_profile_sections_usecase.dart';
|
||||||
|
import 'package:staff_profile/src/domain/usecases/get_reliability_stats_usecase.dart';
|
||||||
import 'package:staff_profile/src/domain/usecases/get_staff_profile_usecase.dart';
|
import 'package:staff_profile/src/domain/usecases/get_staff_profile_usecase.dart';
|
||||||
import 'package:staff_profile/src/domain/usecases/sign_out_usecase.dart';
|
import 'package:staff_profile/src/domain/usecases/sign_out_usecase.dart';
|
||||||
import 'package:staff_profile/src/presentation/blocs/profile_cubit.dart';
|
import 'package:staff_profile/src/presentation/blocs/profile_cubit.dart';
|
||||||
@@ -44,12 +45,18 @@ class StaffProfileModule extends Module {
|
|||||||
i.get<ProfileRepositoryInterface>(),
|
i.get<ProfileRepositoryInterface>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
i.addLazySingleton<GetReliabilityStatsUseCase>(
|
||||||
|
() => GetReliabilityStatsUseCase(
|
||||||
|
i.get<ProfileRepositoryInterface>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Cubit
|
// Cubit
|
||||||
i.addLazySingleton<ProfileCubit>(
|
i.addLazySingleton<ProfileCubit>(
|
||||||
() => ProfileCubit(
|
() => ProfileCubit(
|
||||||
getStaffProfileUseCase: i.get<GetStaffProfileUseCase>(),
|
getStaffProfileUseCase: i.get<GetStaffProfileUseCase>(),
|
||||||
getProfileSectionsUseCase: i.get<GetProfileSectionsUseCase>(),
|
getProfileSectionsUseCase: i.get<GetProfileSectionsUseCase>(),
|
||||||
|
getReliabilityStatsUseCase: i.get<GetReliabilityStatsUseCase>(),
|
||||||
signOutUseCase: i.get<SignOutUseCase>(),
|
signOutUseCase: i.get<SignOutUseCase>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
|||||||
final ApiResponse response = await _apiService.get(
|
final ApiResponse response = await _apiService.get(
|
||||||
StaffEndpoints.shiftsAssigned,
|
StaffEndpoints.shiftsAssigned,
|
||||||
params: <String, dynamic>{
|
params: <String, dynamic>{
|
||||||
'startDate': start.toIso8601String(),
|
'startDate': start.toUtc().toIso8601String(),
|
||||||
'endDate': end.toIso8601String(),
|
'endDate': end.toUtc().toIso8601String(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
final List<dynamic> items = _extractItems(response.data);
|
final List<dynamic> items = _extractItems(response.data);
|
||||||
@@ -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>);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
/// Combined result from loading all My Shifts tab data sources.
|
||||||
|
///
|
||||||
|
/// Holds assigned shifts, pending assignments, and cancelled shifts
|
||||||
|
/// fetched in parallel from the V2 API.
|
||||||
|
class MyShiftsData {
|
||||||
|
/// Creates a [MyShiftsData] instance.
|
||||||
|
const MyShiftsData({
|
||||||
|
required this.assignedShifts,
|
||||||
|
required this.pendingAssignments,
|
||||||
|
required this.cancelledShifts,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Assigned shifts for the requested date range.
|
||||||
|
final List<AssignedShift> assignedShifts;
|
||||||
|
|
||||||
|
/// Pending assignments awaiting worker acceptance.
|
||||||
|
final List<PendingAssignment> pendingAssignments;
|
||||||
|
|
||||||
|
/// Cancelled shift assignments.
|
||||||
|
final List<CancelledShift> cancelledShifts;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,40 @@
|
|||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
import 'package:staff_shifts/src/domain/arguments/get_my_shifts_arguments.dart';
|
||||||
|
import 'package:staff_shifts/src/domain/models/my_shifts_data.dart';
|
||||||
|
import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart';
|
||||||
|
|
||||||
|
/// Fetches all data needed for the My Shifts tab in a single call.
|
||||||
|
///
|
||||||
|
/// Calls [ShiftsRepositoryInterface.getAssignedShifts],
|
||||||
|
/// [ShiftsRepositoryInterface.getPendingAssignments], and
|
||||||
|
/// [ShiftsRepositoryInterface.getCancelledShifts] in parallel and returns
|
||||||
|
/// a unified [MyShiftsData].
|
||||||
|
class GetMyShiftsDataUseCase
|
||||||
|
extends UseCase<GetAssignedShiftsArguments, MyShiftsData> {
|
||||||
|
/// Creates a [GetMyShiftsDataUseCase].
|
||||||
|
GetMyShiftsDataUseCase(this._repository);
|
||||||
|
|
||||||
|
/// The shifts repository.
|
||||||
|
final ShiftsRepositoryInterface _repository;
|
||||||
|
|
||||||
|
/// Loads assigned, pending, and cancelled shifts for the given date range.
|
||||||
|
@override
|
||||||
|
Future<MyShiftsData> call(GetAssignedShiftsArguments arguments) async {
|
||||||
|
final List<Object> results = await Future.wait(<Future<Object>>[
|
||||||
|
_repository.getAssignedShifts(
|
||||||
|
start: arguments.start,
|
||||||
|
end: arguments.end,
|
||||||
|
),
|
||||||
|
_repository.getPendingAssignments(),
|
||||||
|
_repository.getCancelledShifts(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return MyShiftsData(
|
||||||
|
assignedShifts: results[0] as List<AssignedShift>,
|
||||||
|
pendingAssignments: results[1] as List<PendingAssignment>,
|
||||||
|
cancelledShifts: results[2] as List<CancelledShift>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
import 'package:staff_shifts/src/data/repositories_impl/shifts_repository_impl.dart';
|
||||||
|
import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart';
|
||||||
|
import 'package:staff_shifts/src/domain/usecases/book_order_usecase.dart';
|
||||||
|
import 'package:staff_shifts/src/domain/usecases/get_available_orders_usecase.dart';
|
||||||
|
import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_bloc.dart';
|
||||||
|
import 'package:staff_shifts/src/presentation/pages/order_details_page.dart';
|
||||||
|
|
||||||
|
/// DI module for the order details page.
|
||||||
|
///
|
||||||
|
/// Registers the repository, use cases, and BLoC needed to display
|
||||||
|
/// and book an [AvailableOrder] via the V2 API.
|
||||||
|
class OrderDetailsModule extends Module {
|
||||||
|
@override
|
||||||
|
List<Module> get imports => <Module>[CoreModule()];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void binds(Injector i) {
|
||||||
|
// Repository
|
||||||
|
i.add<ShiftsRepositoryInterface>(
|
||||||
|
() => ShiftsRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use cases
|
||||||
|
i.addLazySingleton(GetAvailableOrdersUseCase.new);
|
||||||
|
i.addLazySingleton(BookOrderUseCase.new);
|
||||||
|
|
||||||
|
// BLoC
|
||||||
|
i.add(
|
||||||
|
() => AvailableOrdersBloc(
|
||||||
|
getAvailableOrders: i.get(),
|
||||||
|
bookOrder: i.get(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void routes(RouteManager r) {
|
||||||
|
r.child(
|
||||||
|
'/',
|
||||||
|
child: (_) {
|
||||||
|
final AvailableOrder order = r.args.data as AvailableOrder;
|
||||||
|
return OrderDetailsPage(order: order);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:bloc/bloc.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/domain/usecases/accept_shift_usecase.dart';
|
||||||
import 'package:staff_shifts/src/domain/usecases/apply_for_shift_usecase.dart';
|
import 'package:staff_shifts/src/domain/usecases/apply_for_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/get_profile_completion_usecase.dart';
|
import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart';
|
||||||
@@ -18,10 +19,12 @@ class ShiftDetailsBloc extends Bloc<ShiftDetailsEvent, ShiftDetailsState>
|
|||||||
required this.getShiftDetail,
|
required this.getShiftDetail,
|
||||||
required this.applyForShift,
|
required this.applyForShift,
|
||||||
required this.declineShift,
|
required this.declineShift,
|
||||||
|
required this.acceptShift,
|
||||||
required this.getProfileCompletion,
|
required this.getProfileCompletion,
|
||||||
}) : super(ShiftDetailsInitial()) {
|
}) : super(ShiftDetailsInitial()) {
|
||||||
on<LoadShiftDetailsEvent>(_onLoadDetails);
|
on<LoadShiftDetailsEvent>(_onLoadDetails);
|
||||||
on<BookShiftDetailsEvent>(_onBookShift);
|
on<BookShiftDetailsEvent>(_onBookShift);
|
||||||
|
on<AcceptShiftDetailsEvent>(_onAcceptShift);
|
||||||
on<DeclineShiftDetailsEvent>(_onDeclineShift);
|
on<DeclineShiftDetailsEvent>(_onDeclineShift);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +37,9 @@ class ShiftDetailsBloc extends Bloc<ShiftDetailsEvent, ShiftDetailsState>
|
|||||||
/// Use case for declining a shift.
|
/// Use case for declining a shift.
|
||||||
final DeclineShiftUseCase declineShift;
|
final DeclineShiftUseCase declineShift;
|
||||||
|
|
||||||
|
/// Use case for accepting an assigned shift.
|
||||||
|
final AcceptShiftUseCase acceptShift;
|
||||||
|
|
||||||
/// Use case for checking profile completion.
|
/// Use case for checking profile completion.
|
||||||
final GetProfileCompletionUseCase getProfileCompletion;
|
final GetProfileCompletionUseCase getProfileCompletion;
|
||||||
|
|
||||||
@@ -83,6 +89,25 @@ class ShiftDetailsBloc extends Bloc<ShiftDetailsEvent, ShiftDetailsState>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _onAcceptShift(
|
||||||
|
AcceptShiftDetailsEvent event,
|
||||||
|
Emitter<ShiftDetailsState> emit,
|
||||||
|
) async {
|
||||||
|
await handleError(
|
||||||
|
emit: emit.call,
|
||||||
|
action: () async {
|
||||||
|
await acceptShift(event.shiftId);
|
||||||
|
emit(
|
||||||
|
ShiftActionSuccess(
|
||||||
|
'shift_accepted',
|
||||||
|
shiftDate: event.date,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (String errorKey) => ShiftDetailsError(errorKey),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onDeclineShift(
|
Future<void> _onDeclineShift(
|
||||||
DeclineShiftDetailsEvent event,
|
DeclineShiftDetailsEvent event,
|
||||||
Emitter<ShiftDetailsState> emit,
|
Emitter<ShiftDetailsState> emit,
|
||||||
|
|||||||
@@ -26,6 +26,21 @@ class BookShiftDetailsEvent extends ShiftDetailsEvent {
|
|||||||
List<Object?> get props => [shiftId, roleId, date];
|
List<Object?> get props => [shiftId, roleId, date];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Event dispatched when the worker accepts an already-assigned shift.
|
||||||
|
class AcceptShiftDetailsEvent extends ShiftDetailsEvent {
|
||||||
|
/// The shift to accept.
|
||||||
|
final String shiftId;
|
||||||
|
|
||||||
|
/// Optional date used for post-action navigation.
|
||||||
|
final DateTime? date;
|
||||||
|
|
||||||
|
/// Creates an [AcceptShiftDetailsEvent].
|
||||||
|
const AcceptShiftDetailsEvent(this.shiftId, {this.date});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => <Object?>[shiftId, date];
|
||||||
|
}
|
||||||
|
|
||||||
class DeclineShiftDetailsEvent extends ShiftDetailsEvent {
|
class DeclineShiftDetailsEvent extends ShiftDetailsEvent {
|
||||||
final String shiftId;
|
final String shiftId;
|
||||||
const DeclineShiftDetailsEvent(this.shiftId);
|
const DeclineShiftDetailsEvent(this.shiftId);
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import 'package:staff_shifts/src/domain/arguments/get_available_shifts_arguments
|
|||||||
import 'package:staff_shifts/src/domain/arguments/get_my_shifts_arguments.dart';
|
import 'package:staff_shifts/src/domain/arguments/get_my_shifts_arguments.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/models/my_shifts_data.dart';
|
||||||
import 'package:staff_shifts/src/domain/usecases/get_available_shifts_usecase.dart';
|
import 'package:staff_shifts/src/domain/usecases/get_available_shifts_usecase.dart';
|
||||||
import 'package:staff_shifts/src/domain/usecases/get_cancelled_shifts_usecase.dart';
|
import 'package:staff_shifts/src/domain/usecases/get_cancelled_shifts_usecase.dart';
|
||||||
import 'package:staff_shifts/src/domain/usecases/get_history_shifts_usecase.dart';
|
import 'package:staff_shifts/src/domain/usecases/get_history_shifts_usecase.dart';
|
||||||
|
import 'package:staff_shifts/src/domain/usecases/get_my_shifts_data_usecase.dart';
|
||||||
import 'package:staff_shifts/src/domain/usecases/get_my_shifts_usecase.dart';
|
import 'package:staff_shifts/src/domain/usecases/get_my_shifts_usecase.dart';
|
||||||
import 'package:staff_shifts/src/domain/usecases/get_pending_assignments_usecase.dart';
|
import 'package:staff_shifts/src/domain/usecases/get_pending_assignments_usecase.dart';
|
||||||
import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart';
|
import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart';
|
||||||
@@ -34,6 +36,7 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
|||||||
required this.acceptShift,
|
required this.acceptShift,
|
||||||
required this.declineShift,
|
required this.declineShift,
|
||||||
required this.submitForApproval,
|
required this.submitForApproval,
|
||||||
|
required this.getMyShiftsData,
|
||||||
}) : super(const ShiftsState()) {
|
}) : super(const ShiftsState()) {
|
||||||
on<LoadShiftsEvent>(_onLoadShifts);
|
on<LoadShiftsEvent>(_onLoadShifts);
|
||||||
on<LoadHistoryShiftsEvent>(_onLoadHistoryShifts);
|
on<LoadHistoryShiftsEvent>(_onLoadHistoryShifts);
|
||||||
@@ -74,6 +77,9 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
|||||||
/// Use case for submitting a shift for timesheet approval.
|
/// Use case for submitting a shift for timesheet approval.
|
||||||
final SubmitForApprovalUseCase submitForApproval;
|
final SubmitForApprovalUseCase submitForApproval;
|
||||||
|
|
||||||
|
/// Use case that loads assigned, pending, and cancelled shifts in parallel.
|
||||||
|
final GetMyShiftsDataUseCase getMyShiftsData;
|
||||||
|
|
||||||
Future<void> _onLoadShifts(
|
Future<void> _onLoadShifts(
|
||||||
LoadShiftsEvent event,
|
LoadShiftsEvent event,
|
||||||
Emitter<ShiftsState> emit,
|
Emitter<ShiftsState> emit,
|
||||||
@@ -86,29 +92,16 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
|||||||
emit: emit.call,
|
emit: emit.call,
|
||||||
action: () async {
|
action: () async {
|
||||||
final List<DateTime> days = getCalendarDaysForOffset(0);
|
final List<DateTime> days = getCalendarDaysForOffset(0);
|
||||||
|
final MyShiftsData data = await getMyShiftsData(
|
||||||
// Load assigned, pending, and cancelled shifts in parallel.
|
GetAssignedShiftsArguments(start: days.first, end: days.last),
|
||||||
final List<Object> results = await Future.wait(<Future<Object>>[
|
);
|
||||||
getAssignedShifts(
|
|
||||||
GetAssignedShiftsArguments(start: days.first, end: days.last),
|
|
||||||
),
|
|
||||||
getPendingAssignments(),
|
|
||||||
getCancelledShifts(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
final List<AssignedShift> myShiftsResult =
|
|
||||||
results[0] as List<AssignedShift>;
|
|
||||||
final List<PendingAssignment> pendingResult =
|
|
||||||
results[1] as List<PendingAssignment>;
|
|
||||||
final List<CancelledShift> cancelledResult =
|
|
||||||
results[2] as List<CancelledShift>;
|
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
status: ShiftsStatus.loaded,
|
status: ShiftsStatus.loaded,
|
||||||
myShifts: myShiftsResult,
|
myShifts: data.assignedShifts,
|
||||||
pendingShifts: pendingResult,
|
pendingShifts: data.pendingAssignments,
|
||||||
cancelledShifts: cancelledResult,
|
cancelledShifts: data.cancelledShifts,
|
||||||
availableShifts: const <OpenShift>[],
|
availableShifts: const <OpenShift>[],
|
||||||
historyShifts: const <CompletedShift>[],
|
historyShifts: const <CompletedShift>[],
|
||||||
availableLoading: false,
|
availableLoading: false,
|
||||||
@@ -250,18 +243,23 @@ class ShiftsBloc extends Bloc<ShiftsEvent, ShiftsState>
|
|||||||
LoadShiftsForRangeEvent event,
|
LoadShiftsForRangeEvent event,
|
||||||
Emitter<ShiftsState> emit,
|
Emitter<ShiftsState> emit,
|
||||||
) async {
|
) async {
|
||||||
emit(state.copyWith(myShifts: const <AssignedShift>[], myShiftsLoaded: false));
|
emit(state.copyWith(
|
||||||
|
myShifts: const <AssignedShift>[],
|
||||||
|
myShiftsLoaded: false,
|
||||||
|
));
|
||||||
await handleError(
|
await handleError(
|
||||||
emit: emit.call,
|
emit: emit.call,
|
||||||
action: () async {
|
action: () async {
|
||||||
final List<AssignedShift> myShiftsResult = await getAssignedShifts(
|
final MyShiftsData data = await getMyShiftsData(
|
||||||
GetAssignedShiftsArguments(start: event.start, end: event.end),
|
GetAssignedShiftsArguments(start: event.start, end: event.end),
|
||||||
);
|
);
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
status: ShiftsStatus.loaded,
|
status: ShiftsStatus.loaded,
|
||||||
myShifts: myShiftsResult,
|
myShifts: data.assignedShifts,
|
||||||
|
pendingShifts: data.pendingAssignments,
|
||||||
|
cancelledShifts: data.cancelledShifts,
|
||||||
myShiftsLoaded: true,
|
myShiftsLoaded: true,
|
||||||
clearErrorMessage: true,
|
clearErrorMessage: true,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_bloc.dart';
|
||||||
|
import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_event.dart';
|
||||||
|
import 'package:staff_shifts/src/presentation/blocs/available_orders/available_orders_state.dart';
|
||||||
|
import 'package:staff_shifts/src/presentation/widgets/order_details/order_details_bottom_bar.dart';
|
||||||
|
import 'package:staff_shifts/src/presentation/widgets/order_details/order_details_header.dart';
|
||||||
|
import 'package:staff_shifts/src/presentation/widgets/order_details/order_schedule_section.dart';
|
||||||
|
import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_location_section.dart';
|
||||||
|
import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_stats_row.dart';
|
||||||
|
|
||||||
|
/// Page displaying full details for an available order.
|
||||||
|
///
|
||||||
|
/// Allows the staff member to review order details and book/apply.
|
||||||
|
/// Uses [AvailableOrdersBloc] for the booking flow.
|
||||||
|
class OrderDetailsPage extends StatefulWidget {
|
||||||
|
/// Creates an [OrderDetailsPage].
|
||||||
|
const OrderDetailsPage({super.key, required this.order});
|
||||||
|
|
||||||
|
/// The available order to display.
|
||||||
|
final AvailableOrder order;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<OrderDetailsPage> createState() => _OrderDetailsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OrderDetailsPageState extends State<OrderDetailsPage> {
|
||||||
|
/// Whether the action (booking) dialog is currently showing.
|
||||||
|
bool _actionDialogOpen = false;
|
||||||
|
|
||||||
|
/// Whether a booking request has been initiated.
|
||||||
|
bool _isBooking = false;
|
||||||
|
|
||||||
|
/// Formats a date-only string (e.g. "2026-03-24") to "Mar 24".
|
||||||
|
String _formatDateShort(String dateStr) {
|
||||||
|
if (dateStr.isEmpty) return '';
|
||||||
|
try {
|
||||||
|
final DateTime date = DateTime.parse(dateStr);
|
||||||
|
return DateFormat('MMM d').format(date);
|
||||||
|
} catch (_) {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the duration in hours from the first shift start to end.
|
||||||
|
double _durationHours() {
|
||||||
|
final int minutes = widget.order.schedule.lastShiftEndsAt
|
||||||
|
.difference(widget.order.schedule.firstShiftStartsAt)
|
||||||
|
.inMinutes;
|
||||||
|
double hours = minutes / 60;
|
||||||
|
if (hours < 0) hours += 24;
|
||||||
|
return hours.roundToDouble();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider<AvailableOrdersBloc>(
|
||||||
|
create: (_) => Modular.get<AvailableOrdersBloc>(),
|
||||||
|
child: BlocConsumer<AvailableOrdersBloc, AvailableOrdersState>(
|
||||||
|
listener: _onStateChanged,
|
||||||
|
builder: (BuildContext context, AvailableOrdersState state) {
|
||||||
|
return _buildScaffold(context, state);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onStateChanged(BuildContext context, AvailableOrdersState state) {
|
||||||
|
// Booking succeeded
|
||||||
|
if (state.lastBooking != null) {
|
||||||
|
_closeActionDialog(context);
|
||||||
|
final bool isPending = state.lastBooking!.status == 'PENDING';
|
||||||
|
UiSnackbar.show(
|
||||||
|
context,
|
||||||
|
message: isPending
|
||||||
|
? t.available_orders.order_booked_pending
|
||||||
|
: t.available_orders.order_booked_confirmed,
|
||||||
|
type: UiSnackbarType.success,
|
||||||
|
);
|
||||||
|
Modular.to.toShifts(initialTab: 'find', refreshAvailable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Booking failed
|
||||||
|
if (state.errorMessage != null && _isBooking) {
|
||||||
|
_closeActionDialog(context);
|
||||||
|
UiSnackbar.show(
|
||||||
|
context,
|
||||||
|
message: translateErrorKey(state.errorMessage!),
|
||||||
|
type: UiSnackbarType.error,
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_isBooking = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildScaffold(BuildContext context, AvailableOrdersState state) {
|
||||||
|
final AvailableOrder order = widget.order;
|
||||||
|
final bool isLongTerm = order.orderType == OrderType.permanent;
|
||||||
|
final double durationHours = _durationHours();
|
||||||
|
final double estimatedTotal = order.hourlyRate * durationHours;
|
||||||
|
final int spotsLeft = order.requiredWorkerCount - order.filledCount;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: UiAppBar(
|
||||||
|
centerTitle: false,
|
||||||
|
onLeadingPressed: () => Modular.to.toShifts(),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: <Widget>[
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
OrderDetailsHeader(order: order),
|
||||||
|
const Divider(height: 1, thickness: 0.5),
|
||||||
|
ShiftStatsRow(
|
||||||
|
estimatedTotal:
|
||||||
|
isLongTerm ? order.hourlyRate : estimatedTotal,
|
||||||
|
hourlyRate: order.hourlyRate,
|
||||||
|
duration: isLongTerm ? 0 : durationHours,
|
||||||
|
totalLabel: isLongTerm
|
||||||
|
? context.t.staff_shifts.shift_details.hourly_rate
|
||||||
|
: context.t.staff_shifts.shift_details.est_total,
|
||||||
|
hourlyRateLabel:
|
||||||
|
context.t.staff_shifts.shift_details.hourly_rate,
|
||||||
|
hoursLabel: context.t.staff_shifts.shift_details.hours,
|
||||||
|
),
|
||||||
|
const Divider(height: 1, thickness: 0.5),
|
||||||
|
OrderScheduleSection(
|
||||||
|
schedule: order.schedule,
|
||||||
|
scheduleLabel:
|
||||||
|
context.t.available_orders.schedule_label,
|
||||||
|
dateRangeLabel:
|
||||||
|
context.t.available_orders.date_range_label,
|
||||||
|
clockInLabel:
|
||||||
|
context.t.staff_shifts.shift_details.start_time,
|
||||||
|
clockOutLabel:
|
||||||
|
context.t.staff_shifts.shift_details.end_time,
|
||||||
|
shiftsCountLabel: t.available_orders.shifts_count(
|
||||||
|
count: order.schedule.totalShifts,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1, thickness: 0.5),
|
||||||
|
ShiftLocationSection(
|
||||||
|
location: order.location,
|
||||||
|
address: order.locationAddress,
|
||||||
|
locationLabel:
|
||||||
|
context.t.staff_shifts.shift_details.location,
|
||||||
|
tbdLabel: context.t.staff_shifts.shift_details.tbd,
|
||||||
|
getDirectionLabel:
|
||||||
|
context.t.staff_shifts.shift_details.get_direction,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
OrderDetailsBottomBar(
|
||||||
|
instantBook: order.instantBook,
|
||||||
|
spotsLeft: spotsLeft,
|
||||||
|
bookingInProgress: state.bookingInProgress,
|
||||||
|
onBook: () => _bookOrder(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows the confirmation dialog before booking.
|
||||||
|
void _bookOrder(BuildContext context) {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) => AlertDialog(
|
||||||
|
title: Text(t.available_orders.book_dialog.title),
|
||||||
|
content: Text(
|
||||||
|
t.available_orders.book_dialog.message(
|
||||||
|
count: widget.order.schedule.totalShifts,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Modular.to.popSafe(),
|
||||||
|
child: Text(Translations.of(context).common.cancel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Modular.to.popSafe();
|
||||||
|
_showBookingDialog(context);
|
||||||
|
BlocProvider.of<AvailableOrdersBloc>(context).add(
|
||||||
|
BookOrderEvent(
|
||||||
|
orderId: widget.order.orderId,
|
||||||
|
roleId: widget.order.roleId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(foregroundColor: UiColors.success),
|
||||||
|
child: Text(t.available_orders.book_dialog.confirm),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows a non-dismissible dialog while the booking is in progress.
|
||||||
|
void _showBookingDialog(BuildContext context) {
|
||||||
|
if (_actionDialogOpen) return;
|
||||||
|
_actionDialogOpen = true;
|
||||||
|
_isBooking = true;
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
useRootNavigator: true,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (BuildContext ctx) => AlertDialog(
|
||||||
|
title: Text(t.available_orders.booking_dialog.title),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: <Widget>[
|
||||||
|
const SizedBox(
|
||||||
|
height: 36,
|
||||||
|
width: 36,
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
Text(
|
||||||
|
widget.order.roleName,
|
||||||
|
style: UiTypography.body2b.textPrimary,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space1),
|
||||||
|
Text(
|
||||||
|
'${_formatDateShort(widget.order.schedule.startDate)} - '
|
||||||
|
'${_formatDateShort(widget.order.schedule.endDate)} '
|
||||||
|
'\u2022 ${widget.order.schedule.totalShifts} shifts',
|
||||||
|
style: UiTypography.body3r.textSecondary,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).then((_) {
|
||||||
|
_actionDialogOpen = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes the action dialog if it is open.
|
||||||
|
void _closeActionDialog(BuildContext context) {
|
||||||
|
if (!_actionDialogOpen) return;
|
||||||
|
Navigator.of(context, rootNavigator: true).pop();
|
||||||
|
_actionDialogOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_
|
|||||||
import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_state.dart';
|
import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_state.dart';
|
||||||
import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_date_time_section.dart';
|
import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_date_time_section.dart';
|
||||||
import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_description_section.dart';
|
import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_description_section.dart';
|
||||||
|
import 'package:staff_shifts/src/presentation/widgets/shift_details/cancellation_reason_banner.dart';
|
||||||
import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart';
|
import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart';
|
||||||
import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_details_header.dart';
|
import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_details_header.dart';
|
||||||
import 'package:staff_shifts/src/presentation/widgets/shift_details_page_skeleton.dart';
|
import 'package:staff_shifts/src/presentation/widgets/shift_details_page_skeleton.dart';
|
||||||
@@ -117,6 +118,15 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
|||||||
icon: UiIcons.sparkles,
|
icon: UiIcons.sparkles,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (detail.assignmentStatus ==
|
||||||
|
AssignmentStatus.cancelled &&
|
||||||
|
detail.cancellationReason != null &&
|
||||||
|
detail.cancellationReason!.isNotEmpty)
|
||||||
|
CancellationReasonBanner(
|
||||||
|
reason: detail.cancellationReason!,
|
||||||
|
titleLabel: context.t.staff_shifts.shift_details
|
||||||
|
.shift_cancelled,
|
||||||
|
),
|
||||||
ShiftDetailsHeader(detail: detail),
|
ShiftDetailsHeader(detail: detail),
|
||||||
const Divider(height: 1, thickness: 0.5),
|
const Divider(height: 1, thickness: 0.5),
|
||||||
ShiftStatsRow(
|
ShiftStatsRow(
|
||||||
@@ -140,6 +150,8 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
|||||||
ShiftLocationSection(
|
ShiftLocationSection(
|
||||||
location: detail.location,
|
location: detail.location,
|
||||||
address: detail.address ?? '',
|
address: detail.address ?? '',
|
||||||
|
latitude: detail.latitude,
|
||||||
|
longitude: detail.longitude,
|
||||||
locationLabel: context.t.staff_shifts.shift_details.location,
|
locationLabel: context.t.staff_shifts.shift_details.location,
|
||||||
tbdLabel: context.t.staff_shifts.shift_details.tbd,
|
tbdLabel: context.t.staff_shifts.shift_details.tbd,
|
||||||
getDirectionLabel: context.t.staff_shifts.shift_details.get_direction,
|
getDirectionLabel: context.t.staff_shifts.shift_details.get_direction,
|
||||||
@@ -164,9 +176,9 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
|||||||
).add(DeclineShiftDetailsEvent(detail.shiftId)),
|
).add(DeclineShiftDetailsEvent(detail.shiftId)),
|
||||||
onAccept: () =>
|
onAccept: () =>
|
||||||
BlocProvider.of<ShiftDetailsBloc>(context).add(
|
BlocProvider.of<ShiftDetailsBloc>(context).add(
|
||||||
BookShiftDetailsEvent(
|
AcceptShiftDetailsEvent(
|
||||||
detail.shiftId,
|
detail.shiftId,
|
||||||
roleId: detail.roleId,
|
date: detail.date,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -260,6 +272,8 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
|
|||||||
switch (key) {
|
switch (key) {
|
||||||
case 'shift_booked':
|
case 'shift_booked':
|
||||||
return context.t.staff_shifts.shift_details.shift_booked;
|
return context.t.staff_shifts.shift_details.shift_booked;
|
||||||
|
case 'shift_accepted':
|
||||||
|
return context.t.staff_shifts.shift_details.shift_accepted;
|
||||||
case 'shift_declined_success':
|
case 'shift_declined_success':
|
||||||
return context.t.staff_shifts.shift_details.shift_declined_success;
|
return context.t.staff_shifts.shift_details.shift_declined_success;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -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,162 +92,170 @@ 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>>[
|
||||||
|
BlocProvider<ShiftsBloc>.value(value: _bloc),
|
||||||
|
BlocProvider<AvailableOrdersBloc>.value(value: _ordersBloc),
|
||||||
|
],
|
||||||
child: BlocConsumer<ShiftsBloc, ShiftsState>(
|
child: BlocConsumer<ShiftsBloc, ShiftsState>(
|
||||||
listener: (context, state) {
|
listener: (BuildContext context, ShiftsState state) {
|
||||||
if (state.status == ShiftsStatus.error &&
|
if (state.status == ShiftsStatus.error &&
|
||||||
state.errorMessage != null) {
|
state.errorMessage != null) {
|
||||||
UiSnackbar.show(
|
UiSnackbar.show(
|
||||||
context,
|
context,
|
||||||
message: translateErrorKey(state.errorMessage!),
|
message: translateErrorKey(state.errorMessage!),
|
||||||
type: UiSnackbarType.error,
|
type: UiSnackbarType.error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
builder: (context, state) {
|
builder: (BuildContext context, ShiftsState state) {
|
||||||
if (_pendingAvailableRefresh && state.status == ShiftsStatus.loaded) {
|
if (_pendingAvailableRefresh &&
|
||||||
_pendingAvailableRefresh = false;
|
state.status == ShiftsStatus.loaded) {
|
||||||
_bloc.add(const LoadAvailableShiftsEvent(force: true));
|
_pendingAvailableRefresh = false;
|
||||||
}
|
_ordersBloc.add(const LoadAvailableOrdersEvent());
|
||||||
final bool baseLoaded = state.status == ShiftsStatus.loaded;
|
}
|
||||||
final List<AssignedShift> myShifts = state.myShifts;
|
final bool baseLoaded = state.status == ShiftsStatus.loaded;
|
||||||
final List<OpenShift> availableJobs = state.availableShifts;
|
final List<AssignedShift> myShifts = state.myShifts;
|
||||||
final bool availableLoading = state.availableLoading;
|
final List<PendingAssignment> pendingAssignments =
|
||||||
final bool availableLoaded = state.availableLoaded;
|
state.pendingShifts;
|
||||||
final List<PendingAssignment> pendingAssignments = state.pendingShifts;
|
final List<CancelledShift> cancelledShifts = state.cancelledShifts;
|
||||||
final List<CancelledShift> cancelledShifts = state.cancelledShifts;
|
final List<CompletedShift> historyShifts = state.historyShifts;
|
||||||
final List<CompletedShift> historyShifts = state.historyShifts;
|
final bool historyLoading = state.historyLoading;
|
||||||
final bool historyLoading = state.historyLoading;
|
final bool historyLoaded = state.historyLoaded;
|
||||||
final bool historyLoaded = state.historyLoaded;
|
final bool myShiftsLoaded = state.myShiftsLoaded;
|
||||||
final bool myShiftsLoaded = state.myShiftsLoaded;
|
|
||||||
final bool blockTabsForFind = _prioritizeFind && !availableLoaded;
|
|
||||||
|
|
||||||
// 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTabContent(
|
Widget _buildTabContent(
|
||||||
@@ -253,9 +263,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 +277,17 @@ 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,
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
case ShiftTabType.history:
|
case ShiftTabType.history:
|
||||||
if (historyLoading) {
|
if (historyLoading) {
|
||||||
@@ -296,7 +309,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 +320,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 +337,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 +379,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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
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, pay (total + hourly), time, date, client, location,
|
||||||
|
/// and schedule chips. Tapping the card navigates to the order details page.
|
||||||
|
class AvailableOrderCard extends StatelessWidget {
|
||||||
|
/// Creates an [AvailableOrderCard].
|
||||||
|
const AvailableOrderCard({
|
||||||
|
super.key,
|
||||||
|
required this.order,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The available order to display.
|
||||||
|
final AvailableOrder order;
|
||||||
|
|
||||||
|
/// Callback when the user taps the card.
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
/// Formats a DateTime to a time string like "3:30pm".
|
||||||
|
String _formatTime(DateTime time) {
|
||||||
|
return DateFormat('h:mma').format(time).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats a date-only string (e.g. "2026-03-24") to "Mar 24".
|
||||||
|
String _formatDateShort(String dateStr) {
|
||||||
|
if (dateStr.isEmpty) return '';
|
||||||
|
try {
|
||||||
|
final DateTime date = DateTime.parse(dateStr);
|
||||||
|
return DateFormat('MMM d').format(date);
|
||||||
|
} catch (_) {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the duration in hours from the first shift start to end.
|
||||||
|
double _durationHours() {
|
||||||
|
final int minutes = order.schedule.lastShiftEndsAt
|
||||||
|
.difference(order.schedule.firstShiftStartsAt)
|
||||||
|
.inMinutes;
|
||||||
|
double hours = minutes / 60;
|
||||||
|
if (hours < 0) hours += 24;
|
||||||
|
return hours.roundToDouble();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 bool isLongTerm = order.orderType == OrderType.permanent;
|
||||||
|
final double durationHours = _durationHours();
|
||||||
|
final double estimatedTotal = order.hourlyRate * durationHours;
|
||||||
|
final String dateRange =
|
||||||
|
'${_formatDateShort(schedule.startDate)} - ${_formatDateShort(schedule.endDate)}';
|
||||||
|
final String timeRange =
|
||||||
|
'${_formatTime(schedule.firstShiftStartsAt)} - ${_formatTime(schedule.lastShiftEndsAt)}';
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white,
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
border: Border.all(color: UiColors.border, width: 0.5),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space4),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
// -- Badge row --
|
||||||
|
_buildBadgeRow(spotsLeft),
|
||||||
|
const SizedBox(height: UiConstants.space3),
|
||||||
|
|
||||||
|
// -- Main content row: icon + details + pay --
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
// Role icon
|
||||||
|
Container(
|
||||||
|
width: UiConstants.space10,
|
||||||
|
height: UiConstants.space10,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.tagInProgress,
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(
|
||||||
|
UiIcons.briefcase,
|
||||||
|
color: UiColors.primary,
|
||||||
|
size: UiConstants.space5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space3),
|
||||||
|
|
||||||
|
// Details + pay
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
// Role name + pay headline
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
spacing: UiConstants.space1,
|
||||||
|
children: <Widget>[
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
order.roleName,
|
||||||
|
style: UiTypography.body1m.textPrimary,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
isLongTerm
|
||||||
|
? '\$${order.hourlyRate.toInt()}/hr'
|
||||||
|
: '\$${estimatedTotal.toStringAsFixed(0)}',
|
||||||
|
style: UiTypography.title1m.textPrimary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Time subtitle + pay detail
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
spacing: UiConstants.space1,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
timeRange,
|
||||||
|
style: UiTypography.body3r.textSecondary,
|
||||||
|
),
|
||||||
|
if (!isLongTerm)
|
||||||
|
Text(
|
||||||
|
'\$${order.hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h',
|
||||||
|
style: UiTypography.footnote2r.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space3),
|
||||||
|
|
||||||
|
// -- Date --
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
const Icon(
|
||||||
|
UiIcons.calendar,
|
||||||
|
size: UiConstants.space3,
|
||||||
|
color: UiColors.mutedForeground,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space1),
|
||||||
|
Text(
|
||||||
|
dateRange,
|
||||||
|
style: UiTypography.body3r.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space1),
|
||||||
|
|
||||||
|
// -- Client name --
|
||||||
|
if (order.clientName.isNotEmpty)
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
const Icon(
|
||||||
|
UiIcons.building,
|
||||||
|
size: UiConstants.space3,
|
||||||
|
color: UiColors.mutedForeground,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space1),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
order.clientName,
|
||||||
|
style: UiTypography.body3r.textSecondary,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// -- Address --
|
||||||
|
if (order.locationAddress.isNotEmpty) ...<Widget>[
|
||||||
|
const SizedBox(height: UiConstants.space1),
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
const Icon(
|
||||||
|
UiIcons.mapPin,
|
||||||
|
size: UiConstants.space3,
|
||||||
|
color: UiColors.mutedForeground,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space1),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
order.locationAddress,
|
||||||
|
style: UiTypography.body3r.textSecondary,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// -- Schedule: days of week chips --
|
||||||
|
if (schedule.daysOfWeek.isNotEmpty) ...<Widget>[
|
||||||
|
const SizedBox(height: UiConstants.space3),
|
||||||
|
Wrap(
|
||||||
|
spacing: UiConstants.space1,
|
||||||
|
runSpacing: UiConstants.space1,
|
||||||
|
children: schedule.daysOfWeek
|
||||||
|
.map((DayOfWeek day) => _buildDayChip(day))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space1),
|
||||||
|
Text(
|
||||||
|
t.available_orders.shifts_count(
|
||||||
|
count: schedule.totalShifts,
|
||||||
|
),
|
||||||
|
style: UiTypography.footnote2r.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the horizontal row of badge chips at the top of the card.
|
||||||
|
Widget _buildBadgeRow(int spotsLeft) {
|
||||||
|
return Wrap(
|
||||||
|
spacing: UiConstants.space2,
|
||||||
|
runSpacing: UiConstants.space1,
|
||||||
|
children: <Widget>[
|
||||||
|
// Order type badge
|
||||||
|
_buildBadge(
|
||||||
|
label: _orderTypeLabel(order.orderType),
|
||||||
|
backgroundColor: UiColors.background,
|
||||||
|
textColor: UiColors.textSecondary,
|
||||||
|
borderColor: UiColors.border,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Spots left badge
|
||||||
|
if (spotsLeft > 0)
|
||||||
|
_buildBadge(
|
||||||
|
label: t.available_orders.spots_left(count: spotsLeft),
|
||||||
|
backgroundColor: UiColors.tagPending,
|
||||||
|
textColor: UiColors.textWarning,
|
||||||
|
borderColor: UiColors.textWarning.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Instant book badge
|
||||||
|
if (order.instantBook)
|
||||||
|
_buildBadge(
|
||||||
|
label: t.available_orders.instant_book,
|
||||||
|
backgroundColor: UiColors.success.withValues(alpha: 0.1),
|
||||||
|
textColor: UiColors.success,
|
||||||
|
borderColor: UiColors.success.withValues(alpha: 0.3),
|
||||||
|
icon: UiIcons.zap,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Dispatch team badge
|
||||||
|
if (order.dispatchTeam.isNotEmpty)
|
||||||
|
_buildBadge(
|
||||||
|
label: _dispatchTeamLabel(order.dispatchTeam),
|
||||||
|
backgroundColor: UiColors.primary.withValues(alpha: 0.08),
|
||||||
|
textColor: UiColors.primary,
|
||||||
|
borderColor: UiColors.primary.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a single badge chip with optional leading icon.
|
||||||
|
Widget _buildBadge({
|
||||||
|
required String label,
|
||||||
|
required Color backgroundColor,
|
||||||
|
required Color textColor,
|
||||||
|
required Color borderColor,
|
||||||
|
IconData? icon,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space2,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: UiConstants.radiusSm,
|
||||||
|
border: Border.all(color: borderColor),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: <Widget>[
|
||||||
|
if (icon != null) ...<Widget>[
|
||||||
|
Icon(icon, size: 10, color: textColor),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: UiTypography.footnote2m.copyWith(color: textColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a small chip showing a day-of-week abbreviation.
|
||||||
|
Widget _buildDayChip(DayOfWeek day) {
|
||||||
|
final String label = day.value.isNotEmpty
|
||||||
|
? '${day.value[0]}${day.value.substring(1).toLowerCase()}'
|
||||||
|
: '';
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space2,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.primary.withValues(alpha: 0.08),
|
||||||
|
borderRadius: UiConstants.radiusSm,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: UiTypography.footnote2m.copyWith(color: UiColors.primary),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// A bottom action bar for the order details page.
|
||||||
|
///
|
||||||
|
/// Displays a contextual CTA button based on order booking state:
|
||||||
|
/// fully staffed, instant book, or standard apply.
|
||||||
|
class OrderDetailsBottomBar extends StatelessWidget {
|
||||||
|
/// Creates an [OrderDetailsBottomBar].
|
||||||
|
const OrderDetailsBottomBar({
|
||||||
|
super.key,
|
||||||
|
required this.instantBook,
|
||||||
|
required this.spotsLeft,
|
||||||
|
required this.bookingInProgress,
|
||||||
|
required this.onBook,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Whether the order supports instant booking (no approval needed).
|
||||||
|
final bool instantBook;
|
||||||
|
|
||||||
|
/// Number of spots still available.
|
||||||
|
final int spotsLeft;
|
||||||
|
|
||||||
|
/// Whether a booking request is currently in flight.
|
||||||
|
final bool bookingInProgress;
|
||||||
|
|
||||||
|
/// Callback when the user taps the book/apply button.
|
||||||
|
final VoidCallback onBook;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
UiConstants.space5,
|
||||||
|
UiConstants.space4,
|
||||||
|
UiConstants.space5,
|
||||||
|
MediaQuery.of(context).padding.bottom + UiConstants.space4,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: UiColors.white,
|
||||||
|
border: Border(top: BorderSide(color: UiColors.border)),
|
||||||
|
),
|
||||||
|
child: _buildButton(context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildButton(BuildContext context) {
|
||||||
|
// Loading state
|
||||||
|
if (bookingInProgress) {
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: UiButton.primary(
|
||||||
|
onPressed: null,
|
||||||
|
child: const SizedBox(
|
||||||
|
width: UiConstants.iconMd,
|
||||||
|
height: UiConstants.iconMd,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: UiColors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fully staffed
|
||||||
|
if (spotsLeft <= 0) {
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: UiButton.primary(
|
||||||
|
onPressed: null,
|
||||||
|
text: t.available_orders.fully_staffed,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: UiButton.primary(
|
||||||
|
onPressed: onBook,
|
||||||
|
text: 'Book Shift',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
/// Size of the role icon container in the order details header.
|
||||||
|
const double _kIconContainerSize = 68.0;
|
||||||
|
|
||||||
|
/// A header widget for the order details page.
|
||||||
|
///
|
||||||
|
/// Displays the role icon, role name, client name, and a row of status badges
|
||||||
|
/// (order type, spots left, instant book, dispatch team).
|
||||||
|
class OrderDetailsHeader extends StatelessWidget {
|
||||||
|
/// Creates an [OrderDetailsHeader].
|
||||||
|
const OrderDetailsHeader({super.key, required this.order});
|
||||||
|
|
||||||
|
/// The available order entity.
|
||||||
|
final AvailableOrder order;
|
||||||
|
|
||||||
|
/// Returns a human-readable label for the order type.
|
||||||
|
String _orderTypeLabel(OrderType type) {
|
||||||
|
switch (type) {
|
||||||
|
case OrderType.oneTime:
|
||||||
|
return t.staff_shifts.filter.one_day;
|
||||||
|
case OrderType.recurring:
|
||||||
|
return t.staff_shifts.filter.multi_day;
|
||||||
|
case OrderType.permanent:
|
||||||
|
return t.staff_shifts.filter.long_term;
|
||||||
|
case OrderType.rapid:
|
||||||
|
return 'Rapid';
|
||||||
|
case OrderType.unknown:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a capitalised short label for a dispatch team value.
|
||||||
|
String _dispatchTeamLabel(String team) {
|
||||||
|
switch (team.toUpperCase()) {
|
||||||
|
case 'CORE':
|
||||||
|
return 'Core';
|
||||||
|
case 'CERTIFIED_LOCATION':
|
||||||
|
return 'Certified';
|
||||||
|
case 'MARKETPLACE':
|
||||||
|
return 'Marketplace';
|
||||||
|
default:
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final int spotsLeft = order.requiredWorkerCount - order.filledCount;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space6,
|
||||||
|
vertical: UiConstants.space4,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
spacing: UiConstants.space6,
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
spacing: UiConstants.space4,
|
||||||
|
children: <Widget>[
|
||||||
|
Container(
|
||||||
|
width: _kIconContainerSize,
|
||||||
|
height: _kIconContainerSize,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.primary.withAlpha(20),
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
border: Border.all(color: UiColors.primary, width: 0.5),
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(
|
||||||
|
UiIcons.briefcase,
|
||||||
|
color: UiColors.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
order.roleName,
|
||||||
|
style: UiTypography.headline1b.textPrimary,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
order.clientName,
|
||||||
|
style: UiTypography.body2r.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_buildBadgeRow(spotsLeft),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the horizontal row of badge chips below the header.
|
||||||
|
Widget _buildBadgeRow(int spotsLeft) {
|
||||||
|
return Wrap(
|
||||||
|
spacing: UiConstants.space2,
|
||||||
|
runSpacing: UiConstants.space1,
|
||||||
|
children: <Widget>[
|
||||||
|
// Order type badge
|
||||||
|
_buildBadge(
|
||||||
|
label: _orderTypeLabel(order.orderType),
|
||||||
|
backgroundColor: UiColors.background,
|
||||||
|
textColor: UiColors.textSecondary,
|
||||||
|
borderColor: UiColors.border,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Spots left badge
|
||||||
|
if (spotsLeft > 0)
|
||||||
|
_buildBadge(
|
||||||
|
label: t.available_orders.spots_left(count: spotsLeft),
|
||||||
|
backgroundColor: UiColors.tagPending,
|
||||||
|
textColor: UiColors.textWarning,
|
||||||
|
borderColor: UiColors.textWarning.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Instant book badge
|
||||||
|
if (order.instantBook)
|
||||||
|
_buildBadge(
|
||||||
|
label: t.available_orders.instant_book,
|
||||||
|
backgroundColor: UiColors.success.withValues(alpha: 0.1),
|
||||||
|
textColor: UiColors.success,
|
||||||
|
borderColor: UiColors.success.withValues(alpha: 0.3),
|
||||||
|
icon: UiIcons.zap,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Dispatch team badge
|
||||||
|
if (order.dispatchTeam.isNotEmpty)
|
||||||
|
_buildBadge(
|
||||||
|
label: _dispatchTeamLabel(order.dispatchTeam),
|
||||||
|
backgroundColor: UiColors.primary.withValues(alpha: 0.08),
|
||||||
|
textColor: UiColors.primary,
|
||||||
|
borderColor: UiColors.primary.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a single badge chip with optional leading icon.
|
||||||
|
Widget _buildBadge({
|
||||||
|
required String label,
|
||||||
|
required Color backgroundColor,
|
||||||
|
required Color textColor,
|
||||||
|
required Color borderColor,
|
||||||
|
IconData? icon,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space2,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: UiConstants.radiusSm,
|
||||||
|
border: Border.all(color: borderColor),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: <Widget>[
|
||||||
|
if (icon != null) ...<Widget>[
|
||||||
|
Icon(icon, size: 10, color: textColor),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: UiTypography.footnote2m.copyWith(color: textColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
/// A section displaying the schedule for an available order.
|
||||||
|
///
|
||||||
|
/// Shows a date range, Google Calendar-style day-of-week circles,
|
||||||
|
/// clock-in/clock-out time boxes, and total shift count.
|
||||||
|
/// Follows the same visual structure as [ShiftDateTimeSection].
|
||||||
|
class OrderScheduleSection extends StatelessWidget {
|
||||||
|
/// Creates an [OrderScheduleSection].
|
||||||
|
const OrderScheduleSection({
|
||||||
|
super.key,
|
||||||
|
required this.schedule,
|
||||||
|
required this.scheduleLabel,
|
||||||
|
required this.dateRangeLabel,
|
||||||
|
required this.clockInLabel,
|
||||||
|
required this.clockOutLabel,
|
||||||
|
required this.shiftsCountLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The order schedule data.
|
||||||
|
final AvailableOrderSchedule schedule;
|
||||||
|
|
||||||
|
/// Localised section title (e.g. "SCHEDULE").
|
||||||
|
final String scheduleLabel;
|
||||||
|
|
||||||
|
/// Localised label for the date range row (e.g. "Date Range").
|
||||||
|
final String dateRangeLabel;
|
||||||
|
|
||||||
|
/// Localised label for the clock-in time box (e.g. "START TIME").
|
||||||
|
final String clockInLabel;
|
||||||
|
|
||||||
|
/// Localised label for the clock-out time box (e.g. "END TIME").
|
||||||
|
final String clockOutLabel;
|
||||||
|
|
||||||
|
/// Localised shifts count text (e.g. "3 shift(s)").
|
||||||
|
final String shiftsCountLabel;
|
||||||
|
|
||||||
|
/// All seven days in ISO order for the day-of-week row.
|
||||||
|
static const List<DayOfWeek> _allDays = <DayOfWeek>[
|
||||||
|
DayOfWeek.mon,
|
||||||
|
DayOfWeek.tue,
|
||||||
|
DayOfWeek.wed,
|
||||||
|
DayOfWeek.thu,
|
||||||
|
DayOfWeek.fri,
|
||||||
|
DayOfWeek.sat,
|
||||||
|
DayOfWeek.sun,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Single-letter labels for each day (ISO order).
|
||||||
|
static const List<String> _dayLabels = <String>[
|
||||||
|
'M',
|
||||||
|
'T',
|
||||||
|
'W',
|
||||||
|
'T',
|
||||||
|
'F',
|
||||||
|
'S',
|
||||||
|
'S',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Formats a date-only string (e.g. "2026-03-24") to "Mar 24".
|
||||||
|
String _formatDateShort(String dateStr) {
|
||||||
|
if (dateStr.isEmpty) return '';
|
||||||
|
try {
|
||||||
|
final DateTime date = DateTime.parse(dateStr);
|
||||||
|
return DateFormat('MMM d').format(date);
|
||||||
|
} catch (_) {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats [DateTime] to a time string (e.g. "9:00 AM").
|
||||||
|
String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt);
|
||||||
|
|
||||||
|
/// Builds the date range display string including the year.
|
||||||
|
String _buildDateRangeText() {
|
||||||
|
final String start = _formatDateShort(schedule.startDate);
|
||||||
|
final String end = _formatDateShort(schedule.endDate);
|
||||||
|
// Extract year from endDate for display.
|
||||||
|
String year = '';
|
||||||
|
if (schedule.endDate.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final DateTime endDt = DateTime.parse(schedule.endDate);
|
||||||
|
year = ', ${endDt.year}';
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore parse errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '$start - $end$year';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
// Section title
|
||||||
|
Text(
|
||||||
|
scheduleLabel,
|
||||||
|
style: UiTypography.titleUppercase4b.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
|
||||||
|
// Date range row
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
const Icon(
|
||||||
|
UiIcons.calendar,
|
||||||
|
size: UiConstants.space5,
|
||||||
|
color: UiColors.textPrimary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space2),
|
||||||
|
Text(
|
||||||
|
_buildDateRangeText(),
|
||||||
|
style: UiTypography.title1m.textPrimary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
|
||||||
|
// Days-of-week circles (Google Calendar style)
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: <Widget>[
|
||||||
|
for (int i = 0; i < _allDays.length; i++)
|
||||||
|
_buildDayCircle(
|
||||||
|
_allDays[i],
|
||||||
|
_dayLabels[i],
|
||||||
|
schedule.daysOfWeek.contains(_allDays[i]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
|
||||||
|
// Clock in / Clock out time boxes
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Expanded(
|
||||||
|
child: _buildTimeBox(clockInLabel, schedule.firstShiftStartsAt),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space3),
|
||||||
|
Expanded(
|
||||||
|
child: _buildTimeBox(clockOutLabel, schedule.lastShiftEndsAt),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space8),
|
||||||
|
|
||||||
|
Text(
|
||||||
|
'TOTAL SHIFTS',
|
||||||
|
style: UiTypography.titleUppercase4b.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space2),
|
||||||
|
// Shifts count
|
||||||
|
Text(shiftsCountLabel, style: UiTypography.body1r),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a single day-of-week circle.
|
||||||
|
///
|
||||||
|
/// Active days are filled with the primary color and white text.
|
||||||
|
/// Inactive days use the background color and secondary text.
|
||||||
|
Widget _buildDayCircle(DayOfWeek day, String label, bool isActive) {
|
||||||
|
return Container(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: isActive ? UiColors.primary : UiColors.background,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
color: isActive ? UiColors.primary.withAlpha(40) : UiColors.background,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: isActive
|
||||||
|
? UiTypography.footnote1b.primary
|
||||||
|
: UiTypography.footnote2m.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a time-display box matching the [ShiftDateTimeSection] pattern.
|
||||||
|
Widget _buildTimeBox(String label, DateTime time) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.bgThird,
|
||||||
|
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: UiTypography.footnote2b.copyWith(
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space1),
|
||||||
|
Text(
|
||||||
|
_formatTime(time),
|
||||||
|
style: UiTypography.title1m
|
||||||
|
.copyWith(fontWeight: FontWeight.w700)
|
||||||
|
.textPrimary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,37 +14,25 @@ class ShiftCardBody extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Row(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
ShiftCardIcon(variant: data.variant),
|
Row(
|
||||||
const SizedBox(width: UiConstants.space3),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Expanded(
|
children: <Widget>[
|
||||||
child: Column(
|
ShiftCardIcon(variant: data.variant),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
const SizedBox(width: UiConstants.space3),
|
||||||
children: <Widget>[
|
Expanded(child: ShiftCardTitleRow(data: data)),
|
||||||
ShiftCardTitleRow(data: data),
|
],
|
||||||
const SizedBox(height: UiConstants.space2),
|
|
||||||
ShiftCardMetadataRows(data: data),
|
|
||||||
if (data.cancellationReason != null &&
|
|
||||||
data.cancellationReason!.isNotEmpty) ...<Widget>[
|
|
||||||
const SizedBox(height: UiConstants.space1),
|
|
||||||
Text(
|
|
||||||
data.cancellationReason!,
|
|
||||||
style: UiTypography.footnote2r.textSecondary,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: UiConstants.space2),
|
||||||
|
ShiftCardMetadataRows(data: data),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The 44x44 icon box with a gradient background.
|
/// The icon box matching the AvailableOrderCard style.
|
||||||
class ShiftCardIcon extends StatelessWidget {
|
class ShiftCardIcon extends StatelessWidget {
|
||||||
/// Creates a [ShiftCardIcon].
|
/// Creates a [ShiftCardIcon].
|
||||||
const ShiftCardIcon({super.key, required this.variant});
|
const ShiftCardIcon({super.key, required this.variant});
|
||||||
@@ -57,30 +45,19 @@ class ShiftCardIcon extends StatelessWidget {
|
|||||||
final bool isCancelled = variant == ShiftCardVariant.cancelled;
|
final bool isCancelled = variant == ShiftCardVariant.cancelled;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: 44,
|
width: UiConstants.space10,
|
||||||
height: 44,
|
height: UiConstants.space10,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: isCancelled
|
color: isCancelled
|
||||||
? null
|
? UiColors.primary.withValues(alpha: 0.05)
|
||||||
: LinearGradient(
|
: UiColors.tagInProgress,
|
||||||
colors: <Color>[
|
borderRadius: UiConstants.radiusLg,
|
||||||
UiColors.primary.withValues(alpha: 0.09),
|
|
||||||
UiColors.primary.withValues(alpha: 0.03),
|
|
||||||
],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
color: isCancelled ? UiColors.primary.withValues(alpha: 0.05) : null,
|
|
||||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
|
||||||
border: isCancelled
|
|
||||||
? null
|
|
||||||
: Border.all(color: UiColors.primary.withValues(alpha: 0.09)),
|
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: const Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
UiIcons.briefcase,
|
UiIcons.briefcase,
|
||||||
color: UiColors.primary,
|
color: UiColors.primary,
|
||||||
size: UiConstants.iconMd,
|
size: UiConstants.space5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class ShiftCardData {
|
|||||||
required this.date,
|
required this.date,
|
||||||
required this.variant,
|
required this.variant,
|
||||||
this.subtitle,
|
this.subtitle,
|
||||||
|
this.clientName,
|
||||||
this.startTime,
|
this.startTime,
|
||||||
this.endTime,
|
this.endTime,
|
||||||
this.hourlyRateCents,
|
this.hourlyRateCents,
|
||||||
@@ -57,9 +58,12 @@ class ShiftCardData {
|
|||||||
subtitle: shift.location,
|
subtitle: shift.location,
|
||||||
location: shift.location,
|
location: shift.location,
|
||||||
date: shift.date,
|
date: shift.date,
|
||||||
|
clientName: shift.clientName,
|
||||||
startTime: shift.startTime,
|
startTime: shift.startTime,
|
||||||
endTime: shift.endTime,
|
endTime: shift.endTime,
|
||||||
hourlyRateCents: shift.hourlyRateCents,
|
hourlyRateCents: shift.hourlyRateCents,
|
||||||
|
hourlyRate: shift.hourlyRate,
|
||||||
|
totalRate: shift.totalRate,
|
||||||
orderType: shift.orderType,
|
orderType: shift.orderType,
|
||||||
variant: _variantFromAssignmentStatus(shift.status),
|
variant: _variantFromAssignmentStatus(shift.status),
|
||||||
);
|
);
|
||||||
@@ -73,6 +77,7 @@ class ShiftCardData {
|
|||||||
subtitle: shift.title.isNotEmpty ? shift.title : null,
|
subtitle: shift.title.isNotEmpty ? shift.title : null,
|
||||||
location: shift.location,
|
location: shift.location,
|
||||||
date: shift.date,
|
date: shift.date,
|
||||||
|
clientName: shift.clientName,
|
||||||
startTime: shift.startTime,
|
startTime: shift.startTime,
|
||||||
endTime: shift.endTime,
|
endTime: shift.endTime,
|
||||||
hourlyRateCents: shift.hourlyRateCents,
|
hourlyRateCents: shift.hourlyRateCents,
|
||||||
@@ -91,6 +96,7 @@ class ShiftCardData {
|
|||||||
title: shift.title,
|
title: shift.title,
|
||||||
location: shift.location,
|
location: shift.location,
|
||||||
date: shift.date,
|
date: shift.date,
|
||||||
|
clientName: shift.clientName,
|
||||||
cancellationReason: shift.cancellationReason,
|
cancellationReason: shift.cancellationReason,
|
||||||
variant: ShiftCardVariant.cancelled,
|
variant: ShiftCardVariant.cancelled,
|
||||||
);
|
);
|
||||||
@@ -104,6 +110,7 @@ class ShiftCardData {
|
|||||||
subtitle: assignment.title.isNotEmpty ? assignment.title : null,
|
subtitle: assignment.title.isNotEmpty ? assignment.title : null,
|
||||||
location: assignment.location,
|
location: assignment.location,
|
||||||
date: assignment.startTime,
|
date: assignment.startTime,
|
||||||
|
clientName: assignment.clientName,
|
||||||
startTime: assignment.startTime,
|
startTime: assignment.startTime,
|
||||||
endTime: assignment.endTime,
|
endTime: assignment.endTime,
|
||||||
variant: ShiftCardVariant.pending,
|
variant: ShiftCardVariant.pending,
|
||||||
@@ -119,6 +126,9 @@ class ShiftCardData {
|
|||||||
/// Optional secondary text (e.g. location under the role name).
|
/// Optional secondary text (e.g. location under the role name).
|
||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
|
|
||||||
|
/// Client/business name.
|
||||||
|
final String? clientName;
|
||||||
|
|
||||||
/// Human-readable location label.
|
/// Human-readable location label.
|
||||||
final String location;
|
final String location;
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart';
|
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart';
|
||||||
|
|
||||||
/// Date, time, location, and worked-hours rows.
|
/// Date, client name, location, and worked-hours metadata rows.
|
||||||
|
///
|
||||||
|
/// Follows the AvailableOrderCard element ordering:
|
||||||
|
/// date -> client name -> location.
|
||||||
class ShiftCardMetadataRows extends StatelessWidget {
|
class ShiftCardMetadataRows extends StatelessWidget {
|
||||||
/// Creates a [ShiftCardMetadataRows].
|
/// Creates a [ShiftCardMetadataRows].
|
||||||
const ShiftCardMetadataRows({super.key, required this.data});
|
const ShiftCardMetadataRows({super.key, required this.data});
|
||||||
@@ -15,62 +18,71 @@ class ShiftCardMetadataRows extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
// Date and time row
|
// Date row (with optional worked duration for completed shifts).
|
||||||
Row(
|
Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
const Icon(
|
const Icon(
|
||||||
UiIcons.calendar,
|
UiIcons.calendar,
|
||||||
size: UiConstants.iconXs,
|
size: UiConstants.space3,
|
||||||
color: UiColors.iconSecondary,
|
color: UiColors.mutedForeground,
|
||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space1),
|
const SizedBox(width: UiConstants.space1),
|
||||||
Text(
|
Text(
|
||||||
_formatDate(context, data.date),
|
_formatDate(context, data.date),
|
||||||
style: UiTypography.footnote1r.textSecondary,
|
style: UiTypography.body3r.textSecondary,
|
||||||
),
|
),
|
||||||
if (data.startTime != null && data.endTime != null) ...<Widget>[
|
|
||||||
const SizedBox(width: UiConstants.space3),
|
|
||||||
const Icon(
|
|
||||||
UiIcons.clock,
|
|
||||||
size: UiConstants.iconXs,
|
|
||||||
color: UiColors.iconSecondary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: UiConstants.space1),
|
|
||||||
Text(
|
|
||||||
'${_formatTime(data.startTime!)} - ${_formatTime(data.endTime!)}',
|
|
||||||
style: UiTypography.footnote1r.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (data.minutesWorked != null) ...<Widget>[
|
if (data.minutesWorked != null) ...<Widget>[
|
||||||
const SizedBox(width: UiConstants.space3),
|
const SizedBox(width: UiConstants.space3),
|
||||||
const Icon(
|
const Icon(
|
||||||
UiIcons.clock,
|
UiIcons.clock,
|
||||||
size: UiConstants.iconXs,
|
size: UiConstants.space3,
|
||||||
color: UiColors.iconSecondary,
|
color: UiColors.mutedForeground,
|
||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space1),
|
const SizedBox(width: UiConstants.space1),
|
||||||
Text(
|
Text(
|
||||||
_formatWorkedDuration(data.minutesWorked!),
|
_formatWorkedDuration(data.minutesWorked!),
|
||||||
style: UiTypography.footnote1r.textSecondary,
|
style: UiTypography.body3r.textSecondary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
// Client name row.
|
||||||
|
if (data.clientName != null && data.clientName!.isNotEmpty) ...<Widget>[
|
||||||
|
const SizedBox(height: UiConstants.space1),
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
const Icon(
|
||||||
|
UiIcons.building,
|
||||||
|
size: UiConstants.space3,
|
||||||
|
color: UiColors.mutedForeground,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space1),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
data.clientName!,
|
||||||
|
style: UiTypography.body3r.textSecondary,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// Location row.
|
||||||
const SizedBox(height: UiConstants.space1),
|
const SizedBox(height: UiConstants.space1),
|
||||||
// Location row
|
|
||||||
Row(
|
Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
const Icon(
|
const Icon(
|
||||||
UiIcons.mapPin,
|
UiIcons.mapPin,
|
||||||
size: UiConstants.iconXs,
|
size: UiConstants.space3,
|
||||||
color: UiColors.iconSecondary,
|
color: UiColors.mutedForeground,
|
||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space1),
|
const SizedBox(width: UiConstants.space1),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
data.location,
|
data.location,
|
||||||
style: UiTypography.footnote1r.textSecondary,
|
style: UiTypography.body3r.textSecondary,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -80,6 +92,7 @@ class ShiftCardMetadataRows extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Formats [date] relative to today/tomorrow, or as "EEE, MMM d".
|
||||||
String _formatDate(BuildContext context, DateTime date) {
|
String _formatDate(BuildContext context, DateTime date) {
|
||||||
final DateTime now = DateTime.now();
|
final DateTime now = DateTime.now();
|
||||||
final DateTime today = DateTime(now.year, now.month, now.day);
|
final DateTime today = DateTime(now.year, now.month, now.day);
|
||||||
@@ -92,8 +105,7 @@ class ShiftCardMetadataRows extends StatelessWidget {
|
|||||||
return DateFormat('EEE, MMM d').format(date);
|
return DateFormat('EEE, MMM d').format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt);
|
/// Formats total minutes worked into a "Xh Ym" string.
|
||||||
|
|
||||||
String _formatWorkedDuration(int totalMinutes) {
|
String _formatWorkedDuration(int totalMinutes) {
|
||||||
final int hours = totalMinutes ~/ 60;
|
final int hours = totalMinutes ~/ 60;
|
||||||
final int mins = totalMinutes % 60;
|
final int mins = totalMinutes % 60;
|
||||||
|
|||||||
@@ -70,8 +70,8 @@ class ShiftCardStatusBadge extends StatelessWidget {
|
|||||||
case ShiftCardVariant.cancelled:
|
case ShiftCardVariant.cancelled:
|
||||||
return ShiftCardStatusStyle(
|
return ShiftCardStatusStyle(
|
||||||
label: context.t.staff_shifts.my_shifts_tab.card.cancelled,
|
label: context.t.staff_shifts.my_shifts_tab.card.cancelled,
|
||||||
foreground: UiColors.destructive,
|
foreground: UiColors.mutedForeground,
|
||||||
dot: UiColors.destructive,
|
dot: UiColors.mutedForeground,
|
||||||
);
|
);
|
||||||
case ShiftCardVariant.completed:
|
case ShiftCardVariant.completed:
|
||||||
return ShiftCardStatusStyle(
|
return ShiftCardStatusStyle(
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
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:intl/intl.dart';
|
||||||
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart';
|
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart';
|
||||||
|
|
||||||
/// Title row with optional pay summary on the right.
|
/// Title row showing role name + pay headline, with a time subtitle + pay detail
|
||||||
|
/// row below. Matches the AvailableOrderCard layout.
|
||||||
class ShiftCardTitleRow extends StatelessWidget {
|
class ShiftCardTitleRow extends StatelessWidget {
|
||||||
/// Creates a [ShiftCardTitleRow].
|
/// Creates a [ShiftCardTitleRow].
|
||||||
const ShiftCardTitleRow({super.key, required this.data});
|
const ShiftCardTitleRow({super.key, required this.data});
|
||||||
@@ -12,77 +14,78 @@ class ShiftCardTitleRow extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// Determine if we have enough data to show pay information.
|
||||||
final bool hasDirectRate = data.hourlyRate != null && data.hourlyRate! > 0;
|
final bool hasDirectRate = data.hourlyRate != null && data.hourlyRate! > 0;
|
||||||
final bool hasComputedRate =
|
final bool hasComputedRate =
|
||||||
data.hourlyRateCents != null &&
|
data.hourlyRateCents != null &&
|
||||||
data.startTime != null &&
|
data.startTime != null &&
|
||||||
data.endTime != null;
|
data.endTime != null;
|
||||||
|
final bool hasPay = hasDirectRate || hasComputedRate;
|
||||||
|
|
||||||
if (!hasDirectRate && !hasComputedRate) {
|
// Compute pay values when available.
|
||||||
return Text(
|
double hourlyRate = 0;
|
||||||
data.title,
|
double estimatedTotal = 0;
|
||||||
style: UiTypography.body2m.textPrimary,
|
double durationHours = 0;
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
);
|
if (hasPay) {
|
||||||
|
if (hasDirectRate && data.totalRate != null && data.totalRate! > 0) {
|
||||||
|
hourlyRate = data.hourlyRate!;
|
||||||
|
estimatedTotal = data.totalRate!;
|
||||||
|
durationHours = hourlyRate > 0 ? (estimatedTotal / hourlyRate) : 0;
|
||||||
|
} else if (hasComputedRate) {
|
||||||
|
hourlyRate = data.hourlyRateCents! / 100;
|
||||||
|
final int durationMinutes =
|
||||||
|
data.endTime!.difference(data.startTime!).inMinutes;
|
||||||
|
double hours = durationMinutes / 60;
|
||||||
|
if (hours < 0) hours += 24;
|
||||||
|
durationHours = hours.roundToDouble();
|
||||||
|
estimatedTotal = hourlyRate * durationHours;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer pre-computed values from the API when available.
|
return Column(
|
||||||
final double hourlyRate;
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
final double estimatedTotal;
|
|
||||||
final double durationHours;
|
|
||||||
|
|
||||||
if (hasDirectRate && data.totalRate != null && data.totalRate! > 0) {
|
|
||||||
hourlyRate = data.hourlyRate!;
|
|
||||||
estimatedTotal = data.totalRate!;
|
|
||||||
durationHours = hourlyRate > 0 ? (estimatedTotal / hourlyRate) : 0;
|
|
||||||
} else {
|
|
||||||
hourlyRate = data.hourlyRateCents! / 100;
|
|
||||||
final int durationMinutes = data.endTime!
|
|
||||||
.difference(data.startTime!)
|
|
||||||
.inMinutes;
|
|
||||||
double hours = durationMinutes / 60;
|
|
||||||
if (hours < 0) hours += 24;
|
|
||||||
durationHours = hours.roundToDouble();
|
|
||||||
estimatedTotal = hourlyRate * durationHours;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Expanded(
|
// Row 1: Title + Pay headline
|
||||||
child: Column(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
spacing: UiConstants.space1,
|
||||||
Text(
|
children: <Widget>[
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
data.title,
|
data.title,
|
||||||
style: UiTypography.body2m.textPrimary,
|
style: UiTypography.body1m.textPrimary,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
if (data.subtitle != null) ...<Widget>[
|
|
||||||
Text(
|
|
||||||
data.subtitle!,
|
|
||||||
style: UiTypography.body3r.textSecondary,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: UiConstants.space3),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: <Widget>[
|
|
||||||
Text(
|
|
||||||
'\$${estimatedTotal.toStringAsFixed(0)}',
|
|
||||||
style: UiTypography.title1m.textPrimary,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'\$${hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h',
|
|
||||||
style: UiTypography.footnote2r.textSecondary,
|
|
||||||
),
|
),
|
||||||
|
if (hasPay)
|
||||||
|
Text(
|
||||||
|
'\$${estimatedTotal.toStringAsFixed(0)}',
|
||||||
|
style: UiTypography.title1m.textPrimary,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
// Row 2: Time subtitle + pay detail
|
||||||
|
if (data.startTime != null && data.endTime != null)
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
spacing: UiConstants.space1,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
'${_formatTime(data.startTime!)} - ${_formatTime(data.endTime!)}',
|
||||||
|
style: UiTypography.body3r.textSecondary,
|
||||||
|
),
|
||||||
|
if (hasPay)
|
||||||
|
Text(
|
||||||
|
'\$${hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h',
|
||||||
|
style: UiTypography.footnote2r.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Formats a [DateTime] to a compact time string like "3:30pm".
|
||||||
|
String _formatTime(DateTime dt) => DateFormat('h:mma').format(dt).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// A banner displaying the cancellation reason for a cancelled shift.
|
||||||
|
///
|
||||||
|
/// Uses error styling to draw attention to the cancellation without being
|
||||||
|
/// overly alarming. Shown at the top of the shift details page when the
|
||||||
|
/// shift has been cancelled with a reason.
|
||||||
|
class CancellationReasonBanner extends StatelessWidget {
|
||||||
|
/// Creates a [CancellationReasonBanner].
|
||||||
|
const CancellationReasonBanner({
|
||||||
|
super.key,
|
||||||
|
required this.reason,
|
||||||
|
required this.titleLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The cancellation reason text.
|
||||||
|
final String reason;
|
||||||
|
|
||||||
|
/// Localized title label (e.g., "Shift Cancelled").
|
||||||
|
final String titleLabel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space5,
|
||||||
|
vertical: UiConstants.space4,
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.tagError,
|
||||||
|
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||||
|
border: Border.all(
|
||||||
|
color: UiColors.error.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
const Icon(
|
||||||
|
UiIcons.error,
|
||||||
|
color: UiColors.error,
|
||||||
|
size: UiConstants.iconMd,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space3),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
titleLabel,
|
||||||
|
style: UiTypography.body2b.copyWith(color: UiColors.error),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space1),
|
||||||
|
Text(
|
||||||
|
reason,
|
||||||
|
style: UiTypography.body3r.textPrimary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ class ShiftDateTimeSection extends StatelessWidget {
|
|||||||
const Icon(
|
const Icon(
|
||||||
UiIcons.calendar,
|
UiIcons.calendar,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: UiColors.primary,
|
color: UiColors.textPrimary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space2),
|
const SizedBox(width: UiConstants.space2),
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user