Merge branch 'dev' into feature/session-persistence-new
This commit is contained in:
@@ -9,6 +9,7 @@ export 'src/presentation/widgets/web_mobile_frame.dart';
|
||||
export 'src/presentation/mixins/bloc_error_handler.dart';
|
||||
export 'src/presentation/observers/core_bloc_observer.dart';
|
||||
export 'src/config/app_config.dart';
|
||||
export 'src/config/app_environment.dart';
|
||||
export 'src/routing/routing.dart';
|
||||
export 'src/services/api_service/api_service.dart';
|
||||
export 'src/services/api_service/dio_client.dart';
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/// Represents the application environment.
|
||||
enum AppEnvironment {
|
||||
dev,
|
||||
stage,
|
||||
prod;
|
||||
|
||||
/// Resolves the current environment from the compile-time `ENV` dart define.
|
||||
/// Defaults to [AppEnvironment.dev] if not set or unrecognized.
|
||||
static AppEnvironment get current {
|
||||
const String envString = String.fromEnvironment('ENV', defaultValue: 'dev');
|
||||
return AppEnvironment.values.firstWhere(
|
||||
(AppEnvironment e) => e.name == envString,
|
||||
orElse: () => AppEnvironment.dev,
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether the app is running in production.
|
||||
bool get isProduction => this == AppEnvironment.prod;
|
||||
|
||||
/// Whether the app is running in a non-production environment.
|
||||
bool get isNonProduction => !isProduction;
|
||||
|
||||
/// The Firebase project ID for this environment.
|
||||
String get firebaseProjectId {
|
||||
switch (this) {
|
||||
case AppEnvironment.dev:
|
||||
return 'krow-workforce-dev';
|
||||
case AppEnvironment.stage:
|
||||
return 'krow-workforce-staging';
|
||||
case AppEnvironment.prod:
|
||||
return 'krow-workforce-prod';
|
||||
}
|
||||
}
|
||||
|
||||
/// A display label for the environment (empty for prod).
|
||||
String get label {
|
||||
switch (this) {
|
||||
case AppEnvironment.dev:
|
||||
return '[DEV]';
|
||||
case AppEnvironment.stage:
|
||||
return '[STG]';
|
||||
case AppEnvironment.prod:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,35 +13,35 @@ class CoreModule extends Module {
|
||||
@override
|
||||
void exportedBinds(Injector i) {
|
||||
// 1. Register the base HTTP client
|
||||
i.addSingleton<Dio>(() => DioClient());
|
||||
i.addLazySingleton<Dio>(() => DioClient());
|
||||
|
||||
// 2. Register the base API service
|
||||
i.addSingleton<BaseApiService>(() => ApiService(i.get<Dio>()));
|
||||
i.addLazySingleton<BaseApiService>(() => ApiService(i.get<Dio>()));
|
||||
|
||||
// 3. Register Core API Services (Orchestrators)
|
||||
i.addSingleton<FileUploadService>(
|
||||
i.addLazySingleton<FileUploadService>(
|
||||
() => FileUploadService(i.get<BaseApiService>()),
|
||||
);
|
||||
i.addSingleton<SignedUrlService>(
|
||||
i.addLazySingleton<SignedUrlService>(
|
||||
() => SignedUrlService(i.get<BaseApiService>()),
|
||||
);
|
||||
i.addSingleton<VerificationService>(
|
||||
i.addLazySingleton<VerificationService>(
|
||||
() => VerificationService(i.get<BaseApiService>()),
|
||||
);
|
||||
i.addSingleton<LlmService>(() => LlmService(i.get<BaseApiService>()));
|
||||
i.addSingleton<RapidOrderService>(
|
||||
i.addLazySingleton<LlmService>(() => LlmService(i.get<BaseApiService>()));
|
||||
i.addLazySingleton<RapidOrderService>(
|
||||
() => RapidOrderService(i.get<BaseApiService>()),
|
||||
);
|
||||
|
||||
// 4. Register Device dependency
|
||||
i.addSingleton<ImagePicker>(() => ImagePicker());
|
||||
i.addLazySingleton<ImagePicker>(() => ImagePicker());
|
||||
|
||||
// 5. Register Device Services
|
||||
i.addSingleton<CameraService>(() => CameraService(i.get<ImagePicker>()));
|
||||
i.addSingleton<GalleryService>(() => GalleryService(i.get<ImagePicker>()));
|
||||
i.addSingleton<FilePickerService>(FilePickerService.new);
|
||||
i.addSingleton<AudioRecorderService>(AudioRecorderService.new);
|
||||
i.addSingleton<DeviceFileUploadService>(
|
||||
i.addLazySingleton<CameraService>(() => CameraService(i.get<ImagePicker>()));
|
||||
i.addLazySingleton<GalleryService>(() => GalleryService(i.get<ImagePicker>()));
|
||||
i.addLazySingleton<FilePickerService>(FilePickerService.new);
|
||||
i.addLazySingleton<AudioRecorderService>(AudioRecorderService.new);
|
||||
i.addLazySingleton<DeviceFileUploadService>(
|
||||
() => DeviceFileUploadService(
|
||||
cameraService: i.get<CameraService>(),
|
||||
galleryService: i.get<GalleryService>(),
|
||||
|
||||
@@ -210,6 +210,13 @@ extension ClientNavigator on IModularNavigator {
|
||||
safePush(ClientPaths.createOrderPermanent, arguments: arguments);
|
||||
}
|
||||
|
||||
/// Pushes the review order page before submission.
|
||||
///
|
||||
/// Returns `true` if the user confirmed submission, `null` if they went back.
|
||||
Future<bool?> toCreateOrderReview({Object? arguments}) async {
|
||||
return safePush<bool>(ClientPaths.createOrderReview, arguments: arguments);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// VIEW ORDER
|
||||
// ==========================================================================
|
||||
|
||||
@@ -154,4 +154,9 @@ class ClientPaths {
|
||||
///
|
||||
/// Create a long-term or permanent staffing position.
|
||||
static const String createOrderPermanent = '/create-order/permanent';
|
||||
|
||||
/// Review order before submission.
|
||||
///
|
||||
/// Summary page shown before posting any order type.
|
||||
static const String createOrderReview = '/create-order/review';
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:core_localization/src/l10n/strings.g.dart';
|
||||
|
||||
import '../../domain/repositories/locale_repository_interface.dart';
|
||||
@@ -31,12 +32,10 @@ class LocaleRepositoryImpl implements LocaleRepositoryInterface {
|
||||
return getDefaultLocale();
|
||||
}
|
||||
|
||||
/// We can hardcode this to english based on customer requirements,
|
||||
/// but in a more dynamic app this should be the device locale or a fallback to english.
|
||||
@override
|
||||
Locale getDefaultLocale() {
|
||||
final Locale deviceLocale = AppLocaleUtils.findDeviceLocale().flutterLocale;
|
||||
if (getSupportedLocales().contains(deviceLocale)) {
|
||||
return deviceLocale;
|
||||
}
|
||||
return const Locale('en');
|
||||
}
|
||||
|
||||
|
||||
@@ -325,6 +325,8 @@
|
||||
"client_create_order": {
|
||||
"title": "Create Order",
|
||||
"section_title": "ORDER TYPE",
|
||||
"no_vendors_title": "No Vendors Available",
|
||||
"no_vendors_description": "There are no staffing vendors associated with your account.",
|
||||
"types": {
|
||||
"rapid": "RAPID",
|
||||
"rapid_desc": "URGENT same-day Coverage",
|
||||
@@ -397,6 +399,33 @@
|
||||
"title": "Permanent Order",
|
||||
"subtitle": "Long-term staffing placement",
|
||||
"placeholder": "Permanent Order Flow (Work in Progress)"
|
||||
},
|
||||
"review": {
|
||||
"invalid_arguments": "Unable to load order review. Please go back and try again.",
|
||||
"title": "Review & Submit",
|
||||
"subtitle": "Confirm details before posting",
|
||||
"edit": "Edit",
|
||||
"basics": "Basics",
|
||||
"order_name": "Order Name",
|
||||
"hub": "Hub",
|
||||
"shift_contact": "Shift Contact",
|
||||
"schedule": "Schedule",
|
||||
"date": "Date",
|
||||
"time": "Time",
|
||||
"duration": "Duration",
|
||||
"start_date": "Start Date",
|
||||
"end_date": "End Date",
|
||||
"repeat": "Repeat",
|
||||
"positions": "POSITIONS",
|
||||
"total": "Total",
|
||||
"estimated_total": "Estimated Total",
|
||||
"estimated_weekly_total": "Estimated Weekly Total",
|
||||
"post_order": "Post Order",
|
||||
"hours_suffix": "hrs"
|
||||
},
|
||||
"rapid_draft": {
|
||||
"title": "Rapid Order",
|
||||
"subtitle": "Verify the order details"
|
||||
}
|
||||
},
|
||||
"client_main": {
|
||||
@@ -1249,7 +1278,7 @@
|
||||
"clock_in": "CLOCK IN",
|
||||
"decline": "DECLINE",
|
||||
"accept_shift": "ACCEPT SHIFT",
|
||||
"apply_now": "APPLY NOW",
|
||||
"apply_now": "BOOK SHIFT",
|
||||
"book_dialog": {
|
||||
"title": "Book Shift",
|
||||
"message": "Do you want to instantly book this shift?"
|
||||
@@ -1665,9 +1694,44 @@
|
||||
"todays_cost": "Today's Cost",
|
||||
"no_shifts_day": "No shifts scheduled for this day",
|
||||
"no_workers_assigned": "No workers assigned yet",
|
||||
"status_checked_in_at": "Checked In at $time",
|
||||
"status_on_site": "On Site",
|
||||
"status_en_route": "En Route",
|
||||
"status_en_route_expected": "En Route - Expected $time",
|
||||
"status_confirmed": "Confirmed",
|
||||
"status_running_late": "Running Late",
|
||||
"status_late": "Late",
|
||||
"status_checked_out": "Checked Out",
|
||||
"status_done": "Done",
|
||||
"status_no_show": "No Show",
|
||||
"status_completed": "Completed",
|
||||
"worker_row": {
|
||||
"verify": "Verify",
|
||||
"verified_message": "Worker attire verified for $name"
|
||||
},
|
||||
"page": {
|
||||
"daily_coverage": "Daily Coverage",
|
||||
"coverage_status": "Coverage Status",
|
||||
"workers": "Workers",
|
||||
"error_occurred": "An error occurred",
|
||||
"retry": "Retry",
|
||||
"shifts": "Shifts"
|
||||
},
|
||||
"calendar": {
|
||||
"prev_week": "\u2190 Prev Week",
|
||||
"today": "Today",
|
||||
"next_week": "Next Week \u2192"
|
||||
},
|
||||
"stats": {
|
||||
"checked_in": "Checked In",
|
||||
"en_route": "En Route"
|
||||
},
|
||||
"alert": {
|
||||
"workers_running_late(count)": {
|
||||
"one": "$count worker is running late",
|
||||
"other": "$count workers are running late"
|
||||
},
|
||||
"auto_backup_searching": "Auto-backup system is searching for replacements."
|
||||
}
|
||||
},
|
||||
"client_reports_common": {
|
||||
|
||||
@@ -325,6 +325,8 @@
|
||||
"client_create_order": {
|
||||
"title": "Crear Orden",
|
||||
"section_title": "TIPO DE ORDEN",
|
||||
"no_vendors_title": "No Hay Proveedores Disponibles",
|
||||
"no_vendors_description": "No hay proveedores de personal asociados con su cuenta.",
|
||||
"types": {
|
||||
"rapid": "R\u00c1PIDO",
|
||||
"rapid_desc": "Cobertura URGENTE mismo d\u00eda",
|
||||
@@ -397,6 +399,33 @@
|
||||
"title": "Orden Permanente",
|
||||
"subtitle": "Colocaci\u00f3n de personal a largo plazo",
|
||||
"placeholder": "Flujo de Orden Permanente (Trabajo en Progreso)"
|
||||
},
|
||||
"review": {
|
||||
"invalid_arguments": "No se pudo cargar la revisi\u00f3n de la orden. Por favor, regresa e intenta de nuevo.",
|
||||
"title": "Revisar y Enviar",
|
||||
"subtitle": "Confirma los detalles antes de publicar",
|
||||
"edit": "Editar",
|
||||
"basics": "Datos B\u00e1sicos",
|
||||
"order_name": "Nombre de la Orden",
|
||||
"hub": "Hub",
|
||||
"shift_contact": "Contacto del Turno",
|
||||
"schedule": "Horario",
|
||||
"date": "Fecha",
|
||||
"time": "Hora",
|
||||
"duration": "Duraci\u00f3n",
|
||||
"start_date": "Fecha de Inicio",
|
||||
"end_date": "Fecha de Fin",
|
||||
"repeat": "Repetir",
|
||||
"positions": "POSICIONES",
|
||||
"total": "Total",
|
||||
"estimated_total": "Total Estimado",
|
||||
"estimated_weekly_total": "Total Semanal Estimado",
|
||||
"post_order": "Publicar Orden",
|
||||
"hours_suffix": "hrs"
|
||||
},
|
||||
"rapid_draft": {
|
||||
"title": "Orden R\u00e1pida",
|
||||
"subtitle": "Verifica los detalles de la orden"
|
||||
}
|
||||
},
|
||||
"client_main": {
|
||||
@@ -1244,7 +1273,7 @@
|
||||
"clock_in": "ENTRADA",
|
||||
"decline": "RECHAZAR",
|
||||
"accept_shift": "ACEPTAR TURNO",
|
||||
"apply_now": "SOLICITAR AHORA",
|
||||
"apply_now": "RESERVAR TURNO",
|
||||
"book_dialog": {
|
||||
"title": "Reservar turno",
|
||||
"message": "\u00bfDesea reservar este turno al instante?"
|
||||
@@ -1665,9 +1694,44 @@
|
||||
"todays_cost": "Costo de Hoy",
|
||||
"no_shifts_day": "No hay turnos programados para este día",
|
||||
"no_workers_assigned": "Aún no hay trabajadores asignados",
|
||||
"status_checked_in_at": "Registrado a las $time",
|
||||
"status_on_site": "En Sitio",
|
||||
"status_en_route": "En Camino",
|
||||
"status_en_route_expected": "En Camino - Esperado $time",
|
||||
"status_confirmed": "Confirmado",
|
||||
"status_running_late": "Llegando Tarde",
|
||||
"status_late": "Tarde",
|
||||
"status_checked_out": "Salida Registrada",
|
||||
"status_done": "Hecho",
|
||||
"status_no_show": "No Se Presentó",
|
||||
"status_completed": "Completado",
|
||||
"worker_row": {
|
||||
"verify": "Verificar",
|
||||
"verified_message": "Vestimenta del trabajador verificada para $name"
|
||||
},
|
||||
"page": {
|
||||
"daily_coverage": "Cobertura Diaria",
|
||||
"coverage_status": "Estado de Cobertura",
|
||||
"workers": "Trabajadores",
|
||||
"error_occurred": "Ocurri\u00f3 un error",
|
||||
"retry": "Reintentar",
|
||||
"shifts": "Turnos"
|
||||
},
|
||||
"calendar": {
|
||||
"prev_week": "\u2190 Semana Anterior",
|
||||
"today": "Hoy",
|
||||
"next_week": "Semana Siguiente \u2192"
|
||||
},
|
||||
"stats": {
|
||||
"checked_in": "Registrado",
|
||||
"en_route": "En Camino"
|
||||
},
|
||||
"alert": {
|
||||
"workers_running_late(count)": {
|
||||
"one": "$count trabajador est\u00e1 llegando tarde",
|
||||
"other": "$count trabajadores est\u00e1n llegando tarde"
|
||||
},
|
||||
"auto_backup_searching": "El sistema de respaldo autom\u00e1tico est\u00e1 buscando reemplazos."
|
||||
}
|
||||
},
|
||||
"client_reports_common": {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
|
||||
import 'dart:convert';
|
||||
import 'package:firebase_data_connect/src/core/ref.dart';
|
||||
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../domain/repositories/hubs_connector_repository.dart';
|
||||
|
||||
/// Implementation of [HubsConnectorRepository].
|
||||
|
||||
@@ -20,7 +20,6 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
|
||||
|
||||
@override
|
||||
Future<bool> getProfileCompletion() async {
|
||||
return true;
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
|
||||
@@ -14,3 +14,6 @@ export 'src/widgets/ui_loading_page.dart';
|
||||
export 'src/widgets/ui_snackbar.dart';
|
||||
export 'src/widgets/ui_notice_banner.dart';
|
||||
export 'src/widgets/ui_empty_state.dart';
|
||||
export 'src/widgets/shimmer/ui_shimmer.dart';
|
||||
export 'src/widgets/shimmer/ui_shimmer_shapes.dart';
|
||||
export 'src/widgets/shimmer/ui_shimmer_presets.dart';
|
||||
|
||||
@@ -322,7 +322,7 @@ class UiTypography {
|
||||
|
||||
/// Body 1 Medium - Font: Instrument Sans, Size: 16, Height: 1.5 (#121826)
|
||||
static final TextStyle body1m = _primaryBase.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
height: 1.5,
|
||||
letterSpacing: -0.025,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
/// Core shimmer wrapper that applies an animated gradient effect to its child.
|
||||
///
|
||||
/// Wraps the `shimmer` package's [Shimmer.fromColors] using design system
|
||||
/// color tokens. Place shimmer shape primitives as children.
|
||||
class UiShimmer extends StatelessWidget {
|
||||
/// Creates a shimmer effect wrapper around [child].
|
||||
const UiShimmer({
|
||||
super.key,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
/// The widget tree to apply the shimmer gradient over.
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: UiColors.muted,
|
||||
highlightColor: UiColors.background,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// List-row shimmer skeleton with a leading circle, two text lines, and a
|
||||
/// trailing box.
|
||||
///
|
||||
/// Mimics a typical list item layout during loading. Wrap with [UiShimmer]
|
||||
/// to activate the animated gradient.
|
||||
class UiShimmerListItem extends StatelessWidget {
|
||||
/// Creates a list-row shimmer skeleton.
|
||||
const UiShimmerListItem({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: UiConstants.space2,
|
||||
),
|
||||
child: Row(
|
||||
spacing: UiConstants.space3,
|
||||
children: <Widget>[
|
||||
UiShimmerCircle(size: UiConstants.space10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 160),
|
||||
UiShimmerLine(width: 100, height: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
UiShimmerBox(width: 48, height: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stats-card shimmer skeleton with an icon placeholder, a short label line,
|
||||
/// and a taller value line.
|
||||
///
|
||||
/// Wrapped in a bordered container matching the design system card pattern.
|
||||
/// Wrap with [UiShimmer] to activate the animated gradient.
|
||||
class UiShimmerStatsCard extends StatelessWidget {
|
||||
/// Creates a stats-card shimmer skeleton.
|
||||
const UiShimmerStatsCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
color: UiColors.cardViewBackground,
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
UiShimmerCircle(size: UiConstants.space8),
|
||||
SizedBox(height: UiConstants.space3),
|
||||
UiShimmerLine(width: 80, height: 12),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
UiShimmerLine(width: 120, height: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Section-header shimmer skeleton rendering a single wide line placeholder.
|
||||
///
|
||||
/// Wrap with [UiShimmer] to activate the animated gradient.
|
||||
class UiShimmerSectionHeader extends StatelessWidget {
|
||||
/// Creates a section-header shimmer skeleton.
|
||||
const UiShimmerSectionHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: UiConstants.space2),
|
||||
child: UiShimmerLine(width: 200, height: 18),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Repeats a shimmer widget [itemCount] times in a [Column] with spacing.
|
||||
///
|
||||
/// Use [itemBuilder] to produce each item. Wrap the entire list with
|
||||
/// [UiShimmer] to share a single animated gradient across all items.
|
||||
class UiShimmerList extends StatelessWidget {
|
||||
/// Creates a shimmer list with [itemCount] items built by [itemBuilder].
|
||||
const UiShimmerList({
|
||||
super.key,
|
||||
required this.itemBuilder,
|
||||
this.itemCount = 3,
|
||||
this.spacing,
|
||||
});
|
||||
|
||||
/// Builder that produces each shimmer placeholder item by index.
|
||||
final Widget Function(int index) itemBuilder;
|
||||
|
||||
/// Number of shimmer items to render. Defaults to 3.
|
||||
final int itemCount;
|
||||
|
||||
/// Vertical spacing between items. Defaults to [UiConstants.space3].
|
||||
final double? spacing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double gap = spacing ?? UiConstants.space3;
|
||||
return Column(
|
||||
children: List<Widget>.generate(itemCount, (int index) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: index < itemCount - 1 ? gap : 0),
|
||||
child: itemBuilder(index),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Rectangular shimmer placeholder with configurable dimensions and corner radius.
|
||||
///
|
||||
/// Renders as a solid white container; the parent [UiShimmer] applies the
|
||||
/// animated gradient.
|
||||
class UiShimmerBox extends StatelessWidget {
|
||||
/// Creates a rectangular shimmer placeholder.
|
||||
const UiShimmerBox({
|
||||
super.key,
|
||||
required this.width,
|
||||
required this.height,
|
||||
this.borderRadius,
|
||||
});
|
||||
|
||||
/// Width of the placeholder rectangle.
|
||||
final double width;
|
||||
|
||||
/// Height of the placeholder rectangle.
|
||||
final double height;
|
||||
|
||||
/// Corner radius. Defaults to [UiConstants.radiusMd] when null.
|
||||
final BorderRadius? borderRadius;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: borderRadius ?? UiConstants.radiusMd,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Circular shimmer placeholder with a configurable diameter.
|
||||
///
|
||||
/// Renders as a solid white circle; the parent [UiShimmer] applies the
|
||||
/// animated gradient.
|
||||
class UiShimmerCircle extends StatelessWidget {
|
||||
/// Creates a circular shimmer placeholder with the given [size] as diameter.
|
||||
const UiShimmerCircle({
|
||||
super.key,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
/// Diameter of the circle.
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Text-line shimmer placeholder with configurable width and height.
|
||||
///
|
||||
/// Useful for simulating a single line of text. Renders as a solid white
|
||||
/// rounded rectangle; the parent [UiShimmer] applies the animated gradient.
|
||||
class UiShimmerLine extends StatelessWidget {
|
||||
/// Creates a text-line shimmer placeholder.
|
||||
const UiShimmerLine({
|
||||
super.key,
|
||||
this.width = double.infinity,
|
||||
this.height = 14,
|
||||
});
|
||||
|
||||
/// Width of the line. Defaults to [double.infinity].
|
||||
final double width;
|
||||
|
||||
/// Height of the line. Defaults to 14 logical pixels.
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../ui_constants.dart';
|
||||
|
||||
/// A customizable notice banner widget for displaying informational messages.
|
||||
///
|
||||
/// [UiNoticeBanner] displays a message with an optional icon and supports
|
||||
|
||||
@@ -15,6 +15,7 @@ dependencies:
|
||||
google_fonts: ^7.0.2
|
||||
lucide_icons: ^0.257.0
|
||||
font_awesome_flutter: ^10.7.0
|
||||
shimmer: ^3.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -28,6 +28,12 @@ class StaffPayment extends Equatable {
|
||||
required this.amount,
|
||||
required this.status,
|
||||
this.paidAt,
|
||||
this.shiftTitle,
|
||||
this.shiftLocation,
|
||||
this.locationAddress,
|
||||
this.hoursWorked,
|
||||
this.hourlyRate,
|
||||
this.workedTime,
|
||||
});
|
||||
/// Unique identifier.
|
||||
final String id;
|
||||
@@ -47,6 +53,24 @@ class StaffPayment extends Equatable {
|
||||
/// When the payment was successfully processed.
|
||||
final DateTime? paidAt;
|
||||
|
||||
/// Title of the shift worked.
|
||||
final String? shiftTitle;
|
||||
|
||||
/// Location/hub name of the shift.
|
||||
final String? shiftLocation;
|
||||
|
||||
/// Address of the shift location.
|
||||
final String? locationAddress;
|
||||
|
||||
/// Number of hours worked.
|
||||
final double? hoursWorked;
|
||||
|
||||
/// Hourly rate for the shift.
|
||||
final double? hourlyRate;
|
||||
|
||||
/// Work session duration or status.
|
||||
final String? workedTime;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[id, staffId, assignmentId, amount, status, paidAt];
|
||||
List<Object?> get props => <Object?>[id, staffId, assignmentId, amount, status, paidAt, shiftTitle, shiftLocation, locationAddress, hoursWorked, hourlyRate, workedTime];
|
||||
}
|
||||
@@ -24,20 +24,20 @@ class BillingModule extends Module {
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addSingleton<BillingRepository>(BillingRepositoryImpl.new);
|
||||
i.addLazySingleton<BillingRepository>(BillingRepositoryImpl.new);
|
||||
|
||||
// Use Cases
|
||||
i.addSingleton(GetBankAccountsUseCase.new);
|
||||
i.addSingleton(GetCurrentBillAmountUseCase.new);
|
||||
i.addSingleton(GetSavingsAmountUseCase.new);
|
||||
i.addSingleton(GetPendingInvoicesUseCase.new);
|
||||
i.addSingleton(GetInvoiceHistoryUseCase.new);
|
||||
i.addSingleton(GetSpendingBreakdownUseCase.new);
|
||||
i.addSingleton(ApproveInvoiceUseCase.new);
|
||||
i.addSingleton(DisputeInvoiceUseCase.new);
|
||||
i.addLazySingleton(GetBankAccountsUseCase.new);
|
||||
i.addLazySingleton(GetCurrentBillAmountUseCase.new);
|
||||
i.addLazySingleton(GetSavingsAmountUseCase.new);
|
||||
i.addLazySingleton(GetPendingInvoicesUseCase.new);
|
||||
i.addLazySingleton(GetInvoiceHistoryUseCase.new);
|
||||
i.addLazySingleton(GetSpendingBreakdownUseCase.new);
|
||||
i.addLazySingleton(ApproveInvoiceUseCase.new);
|
||||
i.addLazySingleton(DisputeInvoiceUseCase.new);
|
||||
|
||||
// BLoCs
|
||||
i.addSingleton<BillingBloc>(
|
||||
i.addLazySingleton<BillingBloc>(
|
||||
() => BillingBloc(
|
||||
getBankAccounts: i.get<GetBankAccountsUseCase>(),
|
||||
getCurrentBillAmount: i.get<GetCurrentBillAmountUseCase>(),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:krow_core/core.dart';
|
||||
import '../blocs/billing_bloc.dart';
|
||||
import '../blocs/billing_event.dart';
|
||||
import '../blocs/billing_state.dart';
|
||||
import '../widgets/billing_page_skeleton.dart';
|
||||
import '../widgets/invoice_history_section.dart';
|
||||
import '../widgets/pending_invoices_section.dart';
|
||||
import '../widgets/spending_breakdown_card.dart';
|
||||
@@ -179,10 +180,7 @@ class _BillingViewState extends State<BillingView> {
|
||||
|
||||
Widget _buildContent(BuildContext context, BillingState state) {
|
||||
if (state.status == BillingStatus.loading) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(UiConstants.space10),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
return const BillingPageSkeleton();
|
||||
}
|
||||
|
||||
if (state.status == BillingStatus.failure) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../blocs/billing_bloc.dart';
|
||||
import '../blocs/billing_event.dart';
|
||||
import '../blocs/billing_state.dart';
|
||||
import '../models/billing_invoice_model.dart';
|
||||
import '../widgets/invoices_list_skeleton.dart';
|
||||
|
||||
class InvoiceReadyPage extends StatelessWidget {
|
||||
const InvoiceReadyPage({super.key});
|
||||
@@ -30,7 +31,7 @@ class InvoiceReadyView extends StatelessWidget {
|
||||
body: BlocBuilder<BillingBloc, BillingState>(
|
||||
builder: (BuildContext context, BillingState state) {
|
||||
if (state.status == BillingStatus.loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return const InvoicesListSkeleton();
|
||||
}
|
||||
|
||||
if (state.invoiceHistory.isEmpty) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:krow_core/core.dart';
|
||||
|
||||
import '../blocs/billing_bloc.dart';
|
||||
import '../blocs/billing_state.dart';
|
||||
import '../widgets/invoices_list_skeleton.dart';
|
||||
import '../widgets/pending_invoices_section.dart';
|
||||
|
||||
class PendingInvoicesPage extends StatelessWidget {
|
||||
@@ -31,7 +32,7 @@ class PendingInvoicesPage extends StatelessWidget {
|
||||
|
||||
Widget _buildBody(BuildContext context, BillingState state) {
|
||||
if (state.status == BillingStatus.loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return const InvoicesListSkeleton();
|
||||
}
|
||||
|
||||
if (state.pendingInvoices.isEmpty) {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export 'billing_page_skeleton/index.dart';
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'breakdown_row_skeleton.dart';
|
||||
import 'invoice_card_skeleton.dart';
|
||||
|
||||
/// Shimmer loading skeleton for the billing page content area.
|
||||
///
|
||||
/// Mimics the loaded layout with a pending invoices section,
|
||||
/// a spending breakdown card, and an invoice history list.
|
||||
class BillingPageSkeleton extends StatelessWidget {
|
||||
/// Creates a [BillingPageSkeleton].
|
||||
const BillingPageSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UiShimmer(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Pending invoices section header
|
||||
const UiShimmerSectionHeader(),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
|
||||
// Pending invoice cards
|
||||
const InvoiceCardSkeleton(),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
const InvoiceCardSkeleton(),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Spending breakdown card
|
||||
Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
UiShimmerLine(width: 160, height: 16),
|
||||
SizedBox(height: UiConstants.space4),
|
||||
// Breakdown rows
|
||||
BreakdownRowSkeleton(),
|
||||
SizedBox(height: UiConstants.space3),
|
||||
BreakdownRowSkeleton(),
|
||||
SizedBox(height: UiConstants.space3),
|
||||
BreakdownRowSkeleton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Invoice history section header
|
||||
const UiShimmerSectionHeader(),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
const UiShimmerListItem(),
|
||||
const UiShimmerListItem(),
|
||||
const UiShimmerListItem(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shimmer placeholder for a spending breakdown row.
|
||||
class BreakdownRowSkeleton extends StatelessWidget {
|
||||
/// Creates a [BreakdownRowSkeleton].
|
||||
const BreakdownRowSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
UiShimmerLine(width: 100, height: 14),
|
||||
UiShimmerLine(width: 60, height: 14),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export 'billing_page_skeleton.dart';
|
||||
export 'breakdown_row_skeleton.dart';
|
||||
export 'invoice_card_skeleton.dart';
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shimmer placeholder for a single pending invoice card.
|
||||
class InvoiceCardSkeleton extends StatelessWidget {
|
||||
/// Creates an [InvoiceCardSkeleton].
|
||||
const InvoiceCardSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
UiShimmerBox(
|
||||
width: 72,
|
||||
height: 24,
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
),
|
||||
const UiShimmerLine(width: 80, height: 12),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
const UiShimmerLine(width: 200, height: 16),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
const UiShimmerLine(width: 160, height: 12),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
UiShimmerLine(width: 80, height: 10),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 100, height: 18),
|
||||
],
|
||||
),
|
||||
UiShimmerBox(
|
||||
width: 100,
|
||||
height: 36,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shimmer loading skeleton for invoice list pages.
|
||||
///
|
||||
/// Used by both [PendingInvoicesPage] and [InvoiceReadyPage] to show
|
||||
/// placeholder cards while data loads.
|
||||
class InvoicesListSkeleton extends StatelessWidget {
|
||||
/// Creates an [InvoicesListSkeleton].
|
||||
const InvoicesListSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UiShimmer(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
children: List.generate(4, (int index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
UiShimmerBox(
|
||||
width: 64,
|
||||
height: 22,
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
),
|
||||
const UiShimmerLine(width: 80, height: 12),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
const UiShimmerLine(width: 180, height: 16),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
const UiShimmerLine(width: 140, height: 12),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
const Divider(color: UiColors.border),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
UiShimmerLine(width: 80, height: 10),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 100, height: 20),
|
||||
],
|
||||
),
|
||||
UiShimmerBox(
|
||||
width: 100,
|
||||
height: 36,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,14 +16,14 @@ class CoverageModule extends Module {
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addSingleton<CoverageRepository>(CoverageRepositoryImpl.new);
|
||||
i.addLazySingleton<CoverageRepository>(CoverageRepositoryImpl.new);
|
||||
|
||||
// Use Cases
|
||||
i.addSingleton(GetShiftsForDateUseCase.new);
|
||||
i.addSingleton(GetCoverageStatsUseCase.new);
|
||||
i.addLazySingleton(GetShiftsForDateUseCase.new);
|
||||
i.addLazySingleton(GetCoverageStatsUseCase.new);
|
||||
|
||||
// BLoCs
|
||||
i.addSingleton<CoverageBloc>(CoverageBloc.new);
|
||||
i.addLazySingleton<CoverageBloc>(CoverageBloc.new);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
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:core_localization/core_localization.dart';
|
||||
|
||||
import '../blocs/coverage_bloc.dart';
|
||||
import '../blocs/coverage_event.dart';
|
||||
import '../blocs/coverage_state.dart';
|
||||
|
||||
import '../widgets/coverage_calendar_selector.dart';
|
||||
import '../widgets/coverage_page_skeleton.dart';
|
||||
import '../widgets/coverage_quick_stats.dart';
|
||||
import '../widgets/coverage_shift_list.dart';
|
||||
import '../widgets/coverage_stats_header.dart';
|
||||
import '../widgets/late_workers_alert.dart';
|
||||
|
||||
/// Page for displaying daily coverage information.
|
||||
@@ -60,7 +61,8 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
child: Scaffold(
|
||||
body: BlocConsumer<CoverageBloc, CoverageState>(
|
||||
listener: (BuildContext context, CoverageState state) {
|
||||
if (state.status == CoverageStatus.failure && state.errorMessage != null) {
|
||||
if (state.status == CoverageStatus.failure &&
|
||||
state.errorMessage != null) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: translateErrorKey(state.errorMessage!),
|
||||
@@ -78,27 +80,12 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
pinned: true,
|
||||
expandedHeight: 300.0,
|
||||
backgroundColor: UiColors.primary,
|
||||
leading: IconButton(
|
||||
onPressed: () => Modular.to.toClientHome(),
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space2),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primaryForeground.withOpacity(0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.arrowLeft,
|
||||
color: UiColors.primaryForeground,
|
||||
size: UiConstants.space4,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Text(
|
||||
_isScrolled
|
||||
? DateFormat('MMMM d').format(selectedDate)
|
||||
: 'Daily Coverage',
|
||||
: context.t.client_coverage.page.daily_coverage,
|
||||
key: ValueKey<bool>(_isScrolled),
|
||||
style: UiTypography.title2m.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
@@ -115,7 +102,7 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space2),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primaryForeground.withOpacity(0.2),
|
||||
color: UiColors.primaryForeground.withValues(alpha: 0.2),
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: const Icon(
|
||||
@@ -158,57 +145,13 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
// Coverage Stats Container
|
||||
Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
UiColors.primaryForeground.withOpacity(0.1),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Coverage Status',
|
||||
style: UiTypography.body2r.copyWith(
|
||||
color: UiColors.primaryForeground
|
||||
.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${state.stats?.coveragePercent ?? 0}%',
|
||||
style: UiTypography.display1b.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Workers',
|
||||
style: UiTypography.body2r.copyWith(
|
||||
color: UiColors.primaryForeground
|
||||
.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${state.stats?.totalConfirmed ?? 0}/${state.stats?.totalNeeded ?? 0}',
|
||||
style: UiTypography.title2m.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
CoverageStatsHeader(
|
||||
coveragePercent:
|
||||
(state.stats?.coveragePercent ?? 0)
|
||||
.toDouble(),
|
||||
totalConfirmed:
|
||||
state.stats?.totalConfirmed ?? 0,
|
||||
totalNeeded: state.stats?.totalNeeded ?? 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -238,9 +181,7 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
}) {
|
||||
if (state.shifts.isEmpty) {
|
||||
if (state.status == CoverageStatus.loading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
return const CoveragePageSkeleton();
|
||||
}
|
||||
|
||||
if (state.status == CoverageStatus.failure) {
|
||||
@@ -259,16 +200,16 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
Text(
|
||||
state.errorMessage != null
|
||||
? translateErrorKey(state.errorMessage!)
|
||||
: 'An error occurred',
|
||||
: context.t.client_coverage.page.error_occurred,
|
||||
style: UiTypography.body1m.textError,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
UiButton.secondary(
|
||||
text: 'Retry',
|
||||
text: context.t.client_coverage.page.retry,
|
||||
onPressed: () => BlocProvider.of<CoverageBloc>(context).add(
|
||||
const CoverageRefreshRequested(),
|
||||
),
|
||||
const CoverageRefreshRequested(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -281,22 +222,25 @@ class _CoveragePageState extends State<CoveragePage> {
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space6,
|
||||
children: <Widget>[
|
||||
if (state.stats != null) ...<Widget>[
|
||||
CoverageQuickStats(stats: state.stats!),
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
],
|
||||
if (state.stats != null && state.stats!.late > 0) ...<Widget>[
|
||||
LateWorkersAlert(lateCount: state.stats!.late),
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
],
|
||||
Column(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
if (state.stats != null && state.stats!.late > 0) ...<Widget>[
|
||||
LateWorkersAlert(lateCount: state.stats!.late),
|
||||
],
|
||||
if (state.stats != null) ...<Widget>[
|
||||
CoverageQuickStats(stats: state.stats!),
|
||||
],
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'Shifts (${state.shifts.length})',
|
||||
'${context.t.client_coverage.page.shifts} (${state.shifts.length})',
|
||||
style: UiTypography.title2b.copyWith(
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
CoverageShiftList(shifts: state.shifts),
|
||||
const SizedBox(
|
||||
height: UiConstants.space24,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Navigation button used in the calendar selector for week navigation.
|
||||
class CalendarNavButton extends StatelessWidget {
|
||||
/// Creates a [CalendarNavButton].
|
||||
const CalendarNavButton({
|
||||
required this.text,
|
||||
required this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The button label text.
|
||||
final String text;
|
||||
|
||||
/// Callback when the button is tapped.
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space3,
|
||||
vertical: UiConstants.space1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primaryForeground.withValues(alpha: 0.2),
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Badge showing worker count ratio with color-coded coverage status.
|
||||
///
|
||||
/// Green for 100%+, yellow for 80%+, red below 80%.
|
||||
class CoverageBadge extends StatelessWidget {
|
||||
/// Creates a [CoverageBadge].
|
||||
const CoverageBadge({
|
||||
required this.current,
|
||||
required this.total,
|
||||
required this.coveragePercent,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Current number of assigned workers.
|
||||
final int current;
|
||||
|
||||
/// Total workers needed.
|
||||
final int total;
|
||||
|
||||
/// Coverage percentage used to determine badge color.
|
||||
final int coveragePercent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color bg;
|
||||
Color text;
|
||||
|
||||
if (coveragePercent >= 100) {
|
||||
bg = UiColors.textSuccess.withAlpha(40);
|
||||
text = UiColors.textSuccess;
|
||||
} else if (coveragePercent >= 80) {
|
||||
bg = UiColors.textWarning.withAlpha(40);
|
||||
text = UiColors.textWarning;
|
||||
} else {
|
||||
bg = UiColors.destructive.withAlpha(40);
|
||||
text = UiColors.destructive;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2 + UiConstants.space1,
|
||||
vertical: UiConstants.space1 / 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
border: Border.all(color: text, width: 0.75),
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: Text(
|
||||
'$current/$total',
|
||||
style: UiTypography.body3b.copyWith(
|
||||
color: text,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
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 'calendar_nav_button.dart';
|
||||
|
||||
/// Calendar selector widget for choosing dates.
|
||||
///
|
||||
/// Displays a week view with navigation buttons and date selection.
|
||||
@@ -74,16 +77,16 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
_NavButton(
|
||||
text: '← Prev Week',
|
||||
CalendarNavButton(
|
||||
text: context.t.client_coverage.calendar.prev_week,
|
||||
onTap: _navigatePrevWeek,
|
||||
),
|
||||
_NavButton(
|
||||
text: 'Today',
|
||||
CalendarNavButton(
|
||||
text: context.t.client_coverage.calendar.today,
|
||||
onTap: _navigateToday,
|
||||
),
|
||||
_NavButton(
|
||||
text: 'Next Week →',
|
||||
CalendarNavButton(
|
||||
text: context.t.client_coverage.calendar.next_week,
|
||||
onTap: _navigateNextWeek,
|
||||
),
|
||||
],
|
||||
@@ -145,41 +148,3 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigation button for calendar navigation.
|
||||
class _NavButton extends StatelessWidget {
|
||||
/// Creates a [_NavButton].
|
||||
const _NavButton({
|
||||
required this.text,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
/// The button text.
|
||||
final String text;
|
||||
|
||||
/// Callback when tapped.
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space3,
|
||||
vertical: UiConstants.space1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primaryForeground.withOpacity(0.2),
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'coverage_calendar_selector.dart';
|
||||
|
||||
/// Header widget for the coverage page.
|
||||
///
|
||||
/// Displays:
|
||||
/// - Back button and title
|
||||
/// - Refresh button
|
||||
/// - Calendar date selector
|
||||
/// - Coverage summary statistics
|
||||
class CoverageHeader extends StatelessWidget {
|
||||
/// Creates a [CoverageHeader].
|
||||
const CoverageHeader({
|
||||
required this.selectedDate,
|
||||
required this.coveragePercent,
|
||||
required this.totalConfirmed,
|
||||
required this.totalNeeded,
|
||||
required this.onDateSelected,
|
||||
required this.onRefresh,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The currently selected date.
|
||||
final DateTime selectedDate;
|
||||
|
||||
/// The coverage percentage.
|
||||
final int coveragePercent;
|
||||
|
||||
/// The total number of confirmed workers.
|
||||
final int totalConfirmed;
|
||||
|
||||
/// The total number of workers needed.
|
||||
final int totalNeeded;
|
||||
|
||||
/// Callback when a date is selected.
|
||||
final ValueChanged<DateTime> onDateSelected;
|
||||
|
||||
/// Callback when refresh is requested.
|
||||
final VoidCallback onRefresh;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(
|
||||
top: UiConstants.space14,
|
||||
left: UiConstants.space5,
|
||||
right: UiConstants.space5,
|
||||
bottom: UiConstants.space6,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: <Color>[
|
||||
UiColors.primary,
|
||||
UiColors.primary,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
GestureDetector(
|
||||
onTap: () => Modular.to.toClientHome(),
|
||||
child: Container(
|
||||
width: UiConstants.space10,
|
||||
height: UiConstants.space10,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primaryForeground.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.arrowLeft,
|
||||
color: UiColors.primaryForeground,
|
||||
size: UiConstants.space5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Text(
|
||||
'Daily Coverage',
|
||||
style: UiTypography.title1m.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
width: UiConstants.space8,
|
||||
height: UiConstants.space8,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.transparent,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: onRefresh,
|
||||
icon: const Icon(
|
||||
UiIcons.rotateCcw,
|
||||
color: UiColors.primaryForeground,
|
||||
size: UiConstants.space4,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
style: IconButton.styleFrom(
|
||||
hoverColor: UiColors.primaryForeground.withValues(alpha: 0.2),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
CoverageCalendarSelector(
|
||||
selectedDate: selectedDate,
|
||||
onDateSelected: onDateSelected,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primaryForeground.withValues(alpha: 0.1),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Coverage Status',
|
||||
style: UiTypography.body2r.copyWith(
|
||||
color: UiColors.primaryForeground.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$coveragePercent%',
|
||||
style: UiTypography.display1b.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Workers',
|
||||
style: UiTypography.body2r.copyWith(
|
||||
color: UiColors.primaryForeground.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$totalConfirmed/$totalNeeded',
|
||||
style: UiTypography.title2m.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'coverage_page_skeleton/index.dart';
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'shift_card_skeleton.dart';
|
||||
|
||||
/// Shimmer loading skeleton that mimics the coverage page loaded layout.
|
||||
///
|
||||
/// Shows placeholder shapes for the quick stats row, shift section header,
|
||||
/// and a list of shift cards with worker rows.
|
||||
class CoveragePageSkeleton extends StatelessWidget {
|
||||
/// Creates a [CoveragePageSkeleton].
|
||||
const CoveragePageSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UiShimmer(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Quick stats row (2 stat cards)
|
||||
const Row(
|
||||
children: [
|
||||
Expanded(child: UiShimmerStatsCard()),
|
||||
SizedBox(width: UiConstants.space2),
|
||||
Expanded(child: UiShimmerStatsCard()),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Shifts section header
|
||||
const UiShimmerLine(width: 140, height: 18),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Shift cards with worker rows
|
||||
const ShiftCardSkeleton(),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
const ShiftCardSkeleton(),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
const ShiftCardSkeleton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'coverage_page_skeleton.dart';
|
||||
export 'shift_card_skeleton.dart';
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shimmer placeholder for a single shift card with header and worker rows.
|
||||
class ShiftCardSkeleton extends StatelessWidget {
|
||||
/// Creates a [ShiftCardSkeleton].
|
||||
const ShiftCardSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
// Shift header
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const UiShimmerLine(width: 180, height: 16),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
const UiShimmerLine(width: 120, height: 12),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Row(
|
||||
children: [
|
||||
const UiShimmerLine(width: 80, height: 12),
|
||||
const Spacer(),
|
||||
UiShimmerBox(
|
||||
width: 60,
|
||||
height: 24,
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Worker rows
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space3,
|
||||
).copyWith(bottom: UiConstants.space3),
|
||||
child: const Column(
|
||||
children: [
|
||||
UiShimmerListItem(),
|
||||
UiShimmerListItem(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
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';
|
||||
|
||||
import 'coverage_stat_card.dart';
|
||||
|
||||
/// Quick statistics cards showing coverage metrics.
|
||||
///
|
||||
/// Displays checked-in, en-route, and late worker counts.
|
||||
/// Displays checked-in and en-route worker counts.
|
||||
class CoverageQuickStats extends StatelessWidget {
|
||||
/// Creates a [CoverageQuickStats].
|
||||
const CoverageQuickStats({
|
||||
@@ -18,96 +21,25 @@ class CoverageQuickStats extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: _StatCard(
|
||||
child: CoverageStatCard(
|
||||
icon: UiIcons.success,
|
||||
label: 'Checked In',
|
||||
label: context.t.client_coverage.stats.checked_in,
|
||||
value: stats.checkedIn.toString(),
|
||||
color: UiColors.iconSuccess,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: _StatCard(
|
||||
child: CoverageStatCard(
|
||||
icon: UiIcons.clock,
|
||||
label: 'En Route',
|
||||
label: context.t.client_coverage.stats.en_route,
|
||||
value: stats.enRoute.toString(),
|
||||
color: UiColors.textWarning,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: _StatCard(
|
||||
icon: UiIcons.warning,
|
||||
label: 'Late',
|
||||
value: stats.late.toString(),
|
||||
color: UiColors.destructive,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual stat card widget.
|
||||
class _StatCard extends StatelessWidget {
|
||||
/// Creates a [_StatCard].
|
||||
const _StatCard({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
/// The icon to display.
|
||||
final IconData icon;
|
||||
|
||||
/// The label text.
|
||||
final String label;
|
||||
|
||||
/// The value to display.
|
||||
final String value;
|
||||
|
||||
/// The accent color for the card.
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withAlpha(10),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(
|
||||
color: color,
|
||||
width: 0.75,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: UiConstants.space6,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
value,
|
||||
style: UiTypography.title1m.copyWith(
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
label,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'shift_header.dart';
|
||||
import 'worker_row.dart';
|
||||
|
||||
/// List of shifts with their workers.
|
||||
///
|
||||
/// Displays all shifts for the selected date, or an empty state if none exist.
|
||||
@@ -33,6 +36,8 @@ class CoverageShiftList extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientCoverageEn l10n = context.t.client_coverage;
|
||||
|
||||
if (shifts.isEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space8),
|
||||
@@ -51,7 +56,7 @@ class CoverageShiftList extends StatelessWidget {
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
Text(
|
||||
'No shifts scheduled for this day',
|
||||
l10n.no_shifts_day,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
],
|
||||
@@ -71,7 +76,7 @@ class CoverageShiftList extends StatelessWidget {
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
_ShiftHeader(
|
||||
ShiftHeader(
|
||||
title: shift.title,
|
||||
location: shift.location,
|
||||
startTime: _formatTime(shift.startTime),
|
||||
@@ -91,7 +96,7 @@ class CoverageShiftList extends StatelessWidget {
|
||||
padding: EdgeInsets.only(
|
||||
bottom: isLast ? 0 : UiConstants.space2,
|
||||
),
|
||||
child: _WorkerRow(
|
||||
child: WorkerRow(
|
||||
worker: worker,
|
||||
shiftStartTime: _formatTime(shift.startTime),
|
||||
formatTime: _formatTime,
|
||||
@@ -104,7 +109,7 @@ class CoverageShiftList extends StatelessWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Text(
|
||||
'No workers assigned yet',
|
||||
l10n.no_workers_assigned,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
@@ -117,414 +122,3 @@ class CoverageShiftList extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Header for a shift card.
|
||||
class _ShiftHeader extends StatelessWidget {
|
||||
/// Creates a [_ShiftHeader].
|
||||
const _ShiftHeader({
|
||||
required this.title,
|
||||
required this.location,
|
||||
required this.startTime,
|
||||
required this.current,
|
||||
required this.total,
|
||||
required this.coveragePercent,
|
||||
required this.shiftId,
|
||||
});
|
||||
|
||||
/// The shift title.
|
||||
final String title;
|
||||
|
||||
/// The shift location.
|
||||
final String location;
|
||||
|
||||
/// The shift start time.
|
||||
final String startTime;
|
||||
|
||||
/// Current number of workers.
|
||||
final int current;
|
||||
|
||||
/// Total workers needed.
|
||||
final int total;
|
||||
|
||||
/// Coverage percentage.
|
||||
final int coveragePercent;
|
||||
|
||||
/// The shift ID.
|
||||
final String shiftId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.muted,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: UiColors.border,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
spacing: UiConstants.space4,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: UiConstants.space2,
|
||||
height: UiConstants.space2,
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.body1b.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: UiConstants.space3,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
location,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.clock,
|
||||
size: UiConstants.space3,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
Text(
|
||||
startTime,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_CoverageBadge(
|
||||
current: current,
|
||||
total: total,
|
||||
coveragePercent: coveragePercent,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Coverage badge showing worker count and status.
|
||||
class _CoverageBadge extends StatelessWidget {
|
||||
/// Creates a [_CoverageBadge].
|
||||
const _CoverageBadge({
|
||||
required this.current,
|
||||
required this.total,
|
||||
required this.coveragePercent,
|
||||
});
|
||||
|
||||
/// Current number of workers.
|
||||
final int current;
|
||||
|
||||
/// Total workers needed.
|
||||
final int total;
|
||||
|
||||
/// Coverage percentage.
|
||||
final int coveragePercent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color bg;
|
||||
Color text;
|
||||
|
||||
if (coveragePercent >= 100) {
|
||||
bg = UiColors.textSuccess.withAlpha(40);
|
||||
text = UiColors.textSuccess;
|
||||
} else if (coveragePercent >= 80) {
|
||||
bg = UiColors.textWarning.withAlpha(40);
|
||||
text = UiColors.textWarning;
|
||||
} else {
|
||||
bg = UiColors.destructive.withAlpha(40);
|
||||
text = UiColors.destructive;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2 + UiConstants.space1,
|
||||
vertical: UiConstants.space1 / 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
border: Border.all(color: text, width: 0.75),
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: Text(
|
||||
'$current/$total',
|
||||
style: UiTypography.body3b.copyWith(
|
||||
color: text,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Row displaying a single worker's status.
|
||||
class _WorkerRow extends StatelessWidget {
|
||||
/// Creates a [_WorkerRow].
|
||||
const _WorkerRow({
|
||||
required this.worker,
|
||||
required this.shiftStartTime,
|
||||
required this.formatTime,
|
||||
});
|
||||
|
||||
/// The worker to display.
|
||||
final CoverageWorker worker;
|
||||
|
||||
/// The shift start time.
|
||||
final String shiftStartTime;
|
||||
|
||||
/// Function to format time strings.
|
||||
final String Function(String?) formatTime;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color bg;
|
||||
Color border;
|
||||
Color textBg;
|
||||
Color textColor;
|
||||
IconData icon;
|
||||
String statusText;
|
||||
Color badgeBg;
|
||||
Color badgeText;
|
||||
Color badgeBorder;
|
||||
String badgeLabel;
|
||||
|
||||
switch (worker.status) {
|
||||
case CoverageWorkerStatus.checkedIn:
|
||||
bg = UiColors.textSuccess.withAlpha(26);
|
||||
border = UiColors.textSuccess;
|
||||
textBg = UiColors.textSuccess.withAlpha(51);
|
||||
textColor = UiColors.textSuccess;
|
||||
icon = UiIcons.success;
|
||||
statusText = '✓ Checked In at ${formatTime(worker.checkInTime)}';
|
||||
badgeBg = UiColors.textSuccess.withAlpha(40);
|
||||
badgeText = UiColors.textSuccess;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = 'On Site';
|
||||
case CoverageWorkerStatus.confirmed:
|
||||
if (worker.checkInTime == null) {
|
||||
bg = UiColors.textWarning.withAlpha(26);
|
||||
border = UiColors.textWarning;
|
||||
textBg = UiColors.textWarning.withAlpha(51);
|
||||
textColor = UiColors.textWarning;
|
||||
icon = UiIcons.clock;
|
||||
statusText = 'En Route - Expected $shiftStartTime';
|
||||
badgeBg = UiColors.textWarning.withAlpha(40);
|
||||
badgeText = UiColors.textWarning;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = 'En Route';
|
||||
} else {
|
||||
bg = UiColors.muted.withAlpha(26);
|
||||
border = UiColors.border;
|
||||
textBg = UiColors.muted.withAlpha(51);
|
||||
textColor = UiColors.textSecondary;
|
||||
icon = UiIcons.success;
|
||||
statusText = 'Confirmed';
|
||||
badgeBg = UiColors.textSecondary.withAlpha(40);
|
||||
badgeText = UiColors.textSecondary;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = 'Confirmed';
|
||||
}
|
||||
case CoverageWorkerStatus.late:
|
||||
bg = UiColors.destructive.withAlpha(26);
|
||||
border = UiColors.destructive;
|
||||
textBg = UiColors.destructive.withAlpha(51);
|
||||
textColor = UiColors.destructive;
|
||||
icon = UiIcons.warning;
|
||||
statusText = '⚠ Running Late';
|
||||
badgeBg = UiColors.destructive.withAlpha(40);
|
||||
badgeText = UiColors.destructive;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = 'Late';
|
||||
case CoverageWorkerStatus.checkedOut:
|
||||
bg = UiColors.muted.withAlpha(26);
|
||||
border = UiColors.border;
|
||||
textBg = UiColors.muted.withAlpha(51);
|
||||
textColor = UiColors.textSecondary;
|
||||
icon = UiIcons.success;
|
||||
statusText = 'Checked Out';
|
||||
badgeBg = UiColors.textSecondary.withAlpha(40);
|
||||
badgeText = UiColors.textSecondary;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = 'Done';
|
||||
case CoverageWorkerStatus.noShow:
|
||||
bg = UiColors.destructive.withAlpha(26);
|
||||
border = UiColors.destructive;
|
||||
textBg = UiColors.destructive.withAlpha(51);
|
||||
textColor = UiColors.destructive;
|
||||
icon = UiIcons.warning;
|
||||
statusText = 'No Show';
|
||||
badgeBg = UiColors.destructive.withAlpha(40);
|
||||
badgeText = UiColors.destructive;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = 'No Show';
|
||||
case CoverageWorkerStatus.completed:
|
||||
bg = UiColors.iconSuccess.withAlpha(26);
|
||||
border = UiColors.iconSuccess;
|
||||
textBg = UiColors.iconSuccess.withAlpha(51);
|
||||
textColor = UiColors.textSuccess;
|
||||
icon = UiIcons.success;
|
||||
statusText = 'Completed';
|
||||
badgeBg = UiColors.textSuccess.withAlpha(40);
|
||||
badgeText = UiColors.textSuccess;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = 'Completed';
|
||||
case CoverageWorkerStatus.pending:
|
||||
case CoverageWorkerStatus.accepted:
|
||||
case CoverageWorkerStatus.rejected:
|
||||
bg = UiColors.muted.withAlpha(26);
|
||||
border = UiColors.border;
|
||||
textBg = UiColors.muted.withAlpha(51);
|
||||
textColor = UiColors.textSecondary;
|
||||
icon = UiIcons.clock;
|
||||
statusText = worker.status.name.toUpperCase();
|
||||
badgeBg = UiColors.textSecondary.withAlpha(40);
|
||||
badgeText = UiColors.textSecondary;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = worker.status.name[0].toUpperCase() +
|
||||
worker.status.name.substring(1);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space2),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: UiConstants.space10,
|
||||
height: UiConstants.space10,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: border, width: 2),
|
||||
),
|
||||
child: CircleAvatar(
|
||||
backgroundColor: textBg,
|
||||
child: Text(
|
||||
worker.name.isNotEmpty ? worker.name[0] : 'W',
|
||||
style: UiTypography.body1b.copyWith(
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
child: Container(
|
||||
width: UiConstants.space4,
|
||||
height: UiConstants.space4,
|
||||
decoration: BoxDecoration(
|
||||
color: border,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: UiConstants.space2 + UiConstants.space1,
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
worker.name,
|
||||
style: UiTypography.body2b.copyWith(
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
statusText,
|
||||
style: UiTypography.body3m.copyWith(
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2,
|
||||
vertical: UiConstants.space1 / 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeBg,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: badgeBorder, width: 0.5),
|
||||
),
|
||||
child: Text(
|
||||
badgeLabel,
|
||||
style: UiTypography.footnote2b.copyWith(
|
||||
color: badgeText,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (worker.status == CoverageWorkerStatus.checkedIn)
|
||||
UiButton.primary(
|
||||
text: context.t.client_coverage.worker_row.verify,
|
||||
size: UiButtonSize.small,
|
||||
onPressed: () {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message:
|
||||
context.t.client_coverage.worker_row.verified_message(
|
||||
name: worker.name,
|
||||
),
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Stat card displaying an icon, value, and label with an accent color.
|
||||
class CoverageStatCard extends StatelessWidget {
|
||||
/// Creates a [CoverageStatCard].
|
||||
const CoverageStatCard({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.color,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The icon to display.
|
||||
final IconData icon;
|
||||
|
||||
/// The label text describing the stat.
|
||||
final String label;
|
||||
|
||||
/// The numeric value to display.
|
||||
final String value;
|
||||
|
||||
/// The accent color for the card border, icon, and text.
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withAlpha(10),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(
|
||||
color: color,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
spacing: UiConstants.space2,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: UiConstants.space6,
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: UiTypography.title1b.copyWith(
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Displays coverage percentage and worker ratio in the app bar header.
|
||||
class CoverageStatsHeader extends StatelessWidget {
|
||||
/// Creates a [CoverageStatsHeader].
|
||||
const CoverageStatsHeader({
|
||||
required this.coveragePercent,
|
||||
required this.totalConfirmed,
|
||||
required this.totalNeeded,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The current coverage percentage.
|
||||
final double coveragePercent;
|
||||
|
||||
/// The number of confirmed workers.
|
||||
final int totalConfirmed;
|
||||
|
||||
/// The total number of workers needed.
|
||||
final int totalNeeded;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primaryForeground.withOpacity(0.1),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
context.t.client_coverage.page.coverage_status,
|
||||
style: UiTypography.body2r.copyWith(
|
||||
color: UiColors.primaryForeground.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${coveragePercent.toStringAsFixed(0)}%',
|
||||
style: UiTypography.display1b.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
context.t.client_coverage.page.workers,
|
||||
style: UiTypography.body2r.copyWith(
|
||||
color: UiColors.primaryForeground.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$totalConfirmed/$totalNeeded',
|
||||
style: UiTypography.title2m.copyWith(
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Alert widget for displaying late workers warning.
|
||||
///
|
||||
/// Shows a warning banner when there are late workers.
|
||||
/// Shows a warning banner when workers are running late.
|
||||
class LateWorkersAlert extends StatelessWidget {
|
||||
/// Creates a [LateWorkersAlert].
|
||||
const LateWorkersAlert({
|
||||
@@ -22,32 +23,30 @@ class LateWorkersAlert extends StatelessWidget {
|
||||
color: UiColors.destructive.withValues(alpha: 0.1),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(
|
||||
color: UiColors.destructive.withValues(alpha: 0.3),
|
||||
color: UiColors.destructive,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
spacing: UiConstants.space4,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.warning,
|
||||
color: UiColors.destructive,
|
||||
size: UiConstants.space5,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Late Workers Alert',
|
||||
style: UiTypography.body1b.copyWith(
|
||||
color: UiColors.destructive,
|
||||
),
|
||||
context.t.client_coverage.alert
|
||||
.workers_running_late(n: lateCount, count: lateCount),
|
||||
style: UiTypography.body1b.textError,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
'$lateCount ${lateCount == 1 ? 'worker is' : 'workers are'} running late',
|
||||
context.t.client_coverage.alert.auto_backup_searching,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: UiColors.destructiveForeground,
|
||||
color: UiColors.textError.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'coverage_badge.dart';
|
||||
|
||||
/// Header section for a shift card showing title, location, time, and coverage.
|
||||
class ShiftHeader extends StatelessWidget {
|
||||
/// Creates a [ShiftHeader].
|
||||
const ShiftHeader({
|
||||
required this.title,
|
||||
required this.location,
|
||||
required this.startTime,
|
||||
required this.current,
|
||||
required this.total,
|
||||
required this.coveragePercent,
|
||||
required this.shiftId,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The shift title.
|
||||
final String title;
|
||||
|
||||
/// The shift location.
|
||||
final String location;
|
||||
|
||||
/// The formatted shift start time.
|
||||
final String startTime;
|
||||
|
||||
/// Current number of assigned workers.
|
||||
final int current;
|
||||
|
||||
/// Total workers needed for the shift.
|
||||
final int total;
|
||||
|
||||
/// Coverage percentage (0-100+).
|
||||
final int coveragePercent;
|
||||
|
||||
/// The shift identifier.
|
||||
final String shiftId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.muted,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: UiColors.border,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
spacing: UiConstants.space4,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: UiConstants.space2,
|
||||
height: UiConstants.space2,
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.body1b.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: UiConstants.space3,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
location,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.clock,
|
||||
size: UiConstants.space3,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
Text(
|
||||
startTime,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
CoverageBadge(
|
||||
current: current,
|
||||
total: total,
|
||||
coveragePercent: coveragePercent,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
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';
|
||||
|
||||
/// Row displaying a single worker's avatar, name, status, and badge.
|
||||
class WorkerRow extends StatelessWidget {
|
||||
/// Creates a [WorkerRow].
|
||||
const WorkerRow({
|
||||
required this.worker,
|
||||
required this.shiftStartTime,
|
||||
required this.formatTime,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The worker data to display.
|
||||
final CoverageWorker worker;
|
||||
|
||||
/// The formatted shift start time.
|
||||
final String shiftStartTime;
|
||||
|
||||
/// Callback to format a raw time string into a readable format.
|
||||
final String Function(String?) formatTime;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientCoverageEn l10n = context.t.client_coverage;
|
||||
|
||||
Color bg;
|
||||
Color border;
|
||||
Color textBg;
|
||||
Color textColor;
|
||||
IconData icon;
|
||||
String statusText;
|
||||
Color badgeBg;
|
||||
Color badgeText;
|
||||
Color badgeBorder;
|
||||
String badgeLabel;
|
||||
|
||||
switch (worker.status) {
|
||||
case CoverageWorkerStatus.checkedIn:
|
||||
bg = UiColors.textSuccess.withAlpha(26);
|
||||
border = UiColors.textSuccess;
|
||||
textBg = UiColors.textSuccess.withAlpha(51);
|
||||
textColor = UiColors.textSuccess;
|
||||
icon = UiIcons.success;
|
||||
statusText = l10n.status_checked_in_at(
|
||||
time: formatTime(worker.checkInTime),
|
||||
);
|
||||
badgeBg = UiColors.textSuccess.withAlpha(40);
|
||||
badgeText = UiColors.textSuccess;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_on_site;
|
||||
case CoverageWorkerStatus.confirmed:
|
||||
if (worker.checkInTime == null) {
|
||||
bg = UiColors.textWarning.withAlpha(26);
|
||||
border = UiColors.textWarning;
|
||||
textBg = UiColors.textWarning.withAlpha(51);
|
||||
textColor = UiColors.textWarning;
|
||||
icon = UiIcons.clock;
|
||||
statusText = l10n.status_en_route_expected(time: shiftStartTime);
|
||||
badgeBg = UiColors.textWarning.withAlpha(40);
|
||||
badgeText = UiColors.textWarning;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_en_route;
|
||||
} else {
|
||||
bg = UiColors.muted.withAlpha(26);
|
||||
border = UiColors.border;
|
||||
textBg = UiColors.muted.withAlpha(51);
|
||||
textColor = UiColors.textSecondary;
|
||||
icon = UiIcons.success;
|
||||
statusText = l10n.status_confirmed;
|
||||
badgeBg = UiColors.textSecondary.withAlpha(40);
|
||||
badgeText = UiColors.textSecondary;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_confirmed;
|
||||
}
|
||||
case CoverageWorkerStatus.late:
|
||||
bg = UiColors.destructive.withAlpha(26);
|
||||
border = UiColors.destructive;
|
||||
textBg = UiColors.destructive.withAlpha(51);
|
||||
textColor = UiColors.destructive;
|
||||
icon = UiIcons.warning;
|
||||
statusText = l10n.status_running_late;
|
||||
badgeBg = UiColors.destructive.withAlpha(40);
|
||||
badgeText = UiColors.destructive;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_late;
|
||||
case CoverageWorkerStatus.checkedOut:
|
||||
bg = UiColors.muted.withAlpha(26);
|
||||
border = UiColors.border;
|
||||
textBg = UiColors.muted.withAlpha(51);
|
||||
textColor = UiColors.textSecondary;
|
||||
icon = UiIcons.success;
|
||||
statusText = l10n.status_checked_out;
|
||||
badgeBg = UiColors.textSecondary.withAlpha(40);
|
||||
badgeText = UiColors.textSecondary;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_done;
|
||||
case CoverageWorkerStatus.noShow:
|
||||
bg = UiColors.destructive.withAlpha(26);
|
||||
border = UiColors.destructive;
|
||||
textBg = UiColors.destructive.withAlpha(51);
|
||||
textColor = UiColors.destructive;
|
||||
icon = UiIcons.warning;
|
||||
statusText = l10n.status_no_show;
|
||||
badgeBg = UiColors.destructive.withAlpha(40);
|
||||
badgeText = UiColors.destructive;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_no_show;
|
||||
case CoverageWorkerStatus.completed:
|
||||
bg = UiColors.iconSuccess.withAlpha(26);
|
||||
border = UiColors.iconSuccess;
|
||||
textBg = UiColors.iconSuccess.withAlpha(51);
|
||||
textColor = UiColors.textSuccess;
|
||||
icon = UiIcons.success;
|
||||
statusText = l10n.status_completed;
|
||||
badgeBg = UiColors.textSuccess.withAlpha(40);
|
||||
badgeText = UiColors.textSuccess;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = l10n.status_completed;
|
||||
case CoverageWorkerStatus.pending:
|
||||
case CoverageWorkerStatus.accepted:
|
||||
case CoverageWorkerStatus.rejected:
|
||||
bg = UiColors.muted.withAlpha(26);
|
||||
border = UiColors.border;
|
||||
textBg = UiColors.muted.withAlpha(51);
|
||||
textColor = UiColors.textSecondary;
|
||||
icon = UiIcons.clock;
|
||||
statusText = worker.status.name.toUpperCase();
|
||||
badgeBg = UiColors.textSecondary.withAlpha(40);
|
||||
badgeText = UiColors.textSecondary;
|
||||
badgeBorder = badgeText;
|
||||
badgeLabel = worker.status.name[0].toUpperCase() +
|
||||
worker.status.name.substring(1);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space2),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: UiConstants.space10,
|
||||
height: UiConstants.space10,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: border, width: 2),
|
||||
),
|
||||
child: CircleAvatar(
|
||||
backgroundColor: textBg,
|
||||
child: Text(
|
||||
worker.name.isNotEmpty ? worker.name[0] : 'W',
|
||||
style: UiTypography.body1b.copyWith(
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
child: Container(
|
||||
width: UiConstants.space4,
|
||||
height: UiConstants.space4,
|
||||
decoration: BoxDecoration(
|
||||
color: border,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: UiConstants.space2 + UiConstants.space1,
|
||||
color: UiColors.primaryForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
worker.name,
|
||||
style: UiTypography.body2b.copyWith(
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
statusText,
|
||||
style: UiTypography.body3m.copyWith(
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2,
|
||||
vertical: UiConstants.space1 / 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeBg,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: badgeBorder, width: 0.5),
|
||||
),
|
||||
child: Text(
|
||||
badgeLabel,
|
||||
style: UiTypography.footnote2b.copyWith(
|
||||
color: badgeText,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import 'presentation/pages/client_main_page.dart';
|
||||
class ClientMainModule extends Module {
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
i.addSingleton(ClientMainCubit.new);
|
||||
i.addLazySingleton(ClientMainCubit.new);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -8,10 +8,12 @@ import '../blocs/client_home_state.dart';
|
||||
import 'client_home_edit_mode_body.dart';
|
||||
import 'client_home_error_state.dart';
|
||||
import 'client_home_normal_mode_body.dart';
|
||||
import 'client_home_page_skeleton.dart';
|
||||
|
||||
/// Main body widget for the client home page.
|
||||
///
|
||||
/// Manages the state transitions between error, edit mode, and normal mode views.
|
||||
/// Manages the state transitions between loading, error, edit mode,
|
||||
/// and normal mode views.
|
||||
class ClientHomeBody extends StatelessWidget {
|
||||
/// Creates a [ClientHomeBody].
|
||||
const ClientHomeBody({super.key});
|
||||
@@ -31,6 +33,10 @@ class ClientHomeBody extends StatelessWidget {
|
||||
}
|
||||
},
|
||||
builder: (BuildContext context, ClientHomeState state) {
|
||||
if (state.status == ClientHomeStatus.initial ||
|
||||
state.status == ClientHomeStatus.loading) {
|
||||
return const ClientHomePageSkeleton();
|
||||
}
|
||||
if (state.status == ClientHomeStatus.error) {
|
||||
return ClientHomeErrorState(state: state);
|
||||
}
|
||||
|
||||
@@ -22,8 +22,15 @@ class ClientHomeEditBanner extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ClientHomeBloc, ClientHomeState>(
|
||||
buildWhen: (ClientHomeState prev, ClientHomeState curr) => prev.isEditMode != curr.isEditMode,
|
||||
buildWhen: (ClientHomeState prev, ClientHomeState curr) =>
|
||||
prev.isEditMode != curr.isEditMode ||
|
||||
prev.status != curr.status,
|
||||
builder: (BuildContext context, ClientHomeState state) {
|
||||
if (state.status == ClientHomeStatus.initial ||
|
||||
state.status == ClientHomeStatus.loading) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
height: state.isEditMode ? 80 : 0,
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../blocs/client_home_bloc.dart';
|
||||
import '../blocs/client_home_event.dart';
|
||||
import '../blocs/client_home_state.dart';
|
||||
import 'header_icon_button.dart';
|
||||
import 'client_home_header_skeleton.dart';
|
||||
|
||||
/// The header section of the client home page.
|
||||
///
|
||||
@@ -26,6 +27,11 @@ class ClientHomeHeader extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ClientHomeBloc, ClientHomeState>(
|
||||
builder: (BuildContext context, ClientHomeState state) {
|
||||
if (state.status == ClientHomeStatus.initial ||
|
||||
state.status == ClientHomeStatus.loading) {
|
||||
return const ClientHomeHeaderSkeleton();
|
||||
}
|
||||
|
||||
final String businessName = state.businessName;
|
||||
final String? photoUrl = state.photoUrl;
|
||||
final String avatarLetter = businessName.trim().isNotEmpty
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shimmer placeholder for the client home header during loading.
|
||||
///
|
||||
/// Mimics the avatar, welcome text, business name, and action buttons.
|
||||
class ClientHomeHeaderSkeleton extends StatelessWidget {
|
||||
/// Creates a [ClientHomeHeaderSkeleton].
|
||||
const ClientHomeHeaderSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UiShimmer(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
UiConstants.space4,
|
||||
UiConstants.space4,
|
||||
UiConstants.space4,
|
||||
UiConstants.space3,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const UiShimmerCircle(size: UiConstants.space10),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const <Widget>[
|
||||
UiShimmerLine(width: 80, height: 12),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 120, height: 16),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
spacing: UiConstants.space2,
|
||||
children: const <Widget>[
|
||||
UiShimmerBox(width: 36, height: 36),
|
||||
UiShimmerBox(width: 36, height: 36),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export 'client_home_page_skeleton/action_card_skeleton.dart';
|
||||
export 'client_home_page_skeleton/actions_section_skeleton.dart';
|
||||
export 'client_home_page_skeleton/client_home_page_skeleton.dart';
|
||||
export 'client_home_page_skeleton/coverage_section_skeleton.dart';
|
||||
export 'client_home_page_skeleton/live_activity_section_skeleton.dart';
|
||||
export 'client_home_page_skeleton/metric_card_skeleton.dart';
|
||||
export 'client_home_page_skeleton/reorder_card_skeleton.dart';
|
||||
export 'client_home_page_skeleton/reorder_section_skeleton.dart';
|
||||
export 'client_home_page_skeleton/spending_card_skeleton.dart';
|
||||
export 'client_home_page_skeleton/spending_section_skeleton.dart';
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Skeleton for a single action card with icon, title, and subtitle.
|
||||
class ActionCardSkeleton extends StatelessWidget {
|
||||
/// Creates an [ActionCardSkeleton].
|
||||
const ActionCardSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border, width: 0.5),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: const Column(
|
||||
children: <Widget>[
|
||||
UiShimmerBox(width: 36, height: 36),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
UiShimmerLine(width: 60, height: 14),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 100, height: 10),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'action_card_skeleton.dart';
|
||||
|
||||
/// Skeleton for the two side-by-side action cards.
|
||||
class ActionsSectionSkeleton extends StatelessWidget {
|
||||
/// Creates an [ActionsSectionSkeleton].
|
||||
const ActionsSectionSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
UiShimmerSectionHeader(),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(child: ActionCardSkeleton()),
|
||||
SizedBox(width: UiConstants.space4),
|
||||
Expanded(child: ActionCardSkeleton()),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'actions_section_skeleton.dart';
|
||||
import 'coverage_section_skeleton.dart';
|
||||
import 'live_activity_section_skeleton.dart';
|
||||
import 'reorder_section_skeleton.dart';
|
||||
import 'spending_section_skeleton.dart';
|
||||
|
||||
/// Shimmer loading skeleton for the client home page.
|
||||
///
|
||||
/// Mimics the loaded dashboard layout with action cards, reorder cards,
|
||||
/// coverage metrics, spending card, and live activity sections.
|
||||
class ClientHomePageSkeleton extends StatelessWidget {
|
||||
/// Creates a [ClientHomePageSkeleton].
|
||||
const ClientHomePageSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UiShimmer(
|
||||
child: ListView(
|
||||
children: const <Widget>[
|
||||
// Actions section
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: UiConstants.space4),
|
||||
child: ActionsSectionSkeleton(),
|
||||
),
|
||||
SizedBox(height: UiConstants.space8),
|
||||
Divider(color: UiColors.border, height: 0.1),
|
||||
SizedBox(height: UiConstants.space8),
|
||||
|
||||
// Reorder section
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: UiConstants.space4),
|
||||
child: ReorderSectionSkeleton(),
|
||||
),
|
||||
SizedBox(height: UiConstants.space8),
|
||||
Divider(color: UiColors.border, height: 0.1),
|
||||
SizedBox(height: UiConstants.space8),
|
||||
|
||||
// Coverage section
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: UiConstants.space4),
|
||||
child: CoverageSectionSkeleton(),
|
||||
),
|
||||
SizedBox(height: UiConstants.space8),
|
||||
Divider(color: UiColors.border, height: 0.1),
|
||||
SizedBox(height: UiConstants.space8),
|
||||
|
||||
// Spending section
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: UiConstants.space4),
|
||||
child: SpendingSectionSkeleton(),
|
||||
),
|
||||
SizedBox(height: UiConstants.space8),
|
||||
Divider(color: UiColors.border, height: 0.1),
|
||||
SizedBox(height: UiConstants.space8),
|
||||
|
||||
// Live activity section
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: UiConstants.space4),
|
||||
child: LiveActivitySectionSkeleton(),
|
||||
),
|
||||
SizedBox(height: UiConstants.space8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'metric_card_skeleton.dart';
|
||||
|
||||
/// Skeleton for the coverage metric cards row.
|
||||
class CoverageSectionSkeleton extends StatelessWidget {
|
||||
/// Creates a [CoverageSectionSkeleton].
|
||||
const CoverageSectionSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
UiShimmerSectionHeader(),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(child: MetricCardSkeleton()),
|
||||
SizedBox(width: UiConstants.space2),
|
||||
Expanded(child: MetricCardSkeleton()),
|
||||
SizedBox(width: UiConstants.space2),
|
||||
Expanded(child: MetricCardSkeleton()),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Skeleton for the live activity section.
|
||||
class LiveActivitySectionSkeleton extends StatelessWidget {
|
||||
/// Creates a [LiveActivitySectionSkeleton].
|
||||
const LiveActivitySectionSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
UiShimmerSectionHeader(),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
UiShimmerStatsCard(),
|
||||
SizedBox(height: UiConstants.space3),
|
||||
UiShimmerListItem(),
|
||||
UiShimmerListItem(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Skeleton for a single coverage metric card.
|
||||
class MetricCardSkeleton extends StatelessWidget {
|
||||
/// Creates a [MetricCardSkeleton].
|
||||
const MetricCardSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space2),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border, width: 0.5),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
UiShimmerCircle(size: 14),
|
||||
SizedBox(width: UiConstants.space1),
|
||||
UiShimmerLine(width: 40, height: 10),
|
||||
],
|
||||
),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
UiShimmerLine(width: 32, height: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Skeleton for a single reorder card.
|
||||
class ReorderCardSkeleton extends StatelessWidget {
|
||||
/// Creates a [ReorderCardSkeleton].
|
||||
const ReorderCardSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border, width: 0.6),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Row(
|
||||
children: <Widget>[
|
||||
UiShimmerBox(width: 36, height: 36),
|
||||
SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 100, height: 14),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 80, height: 10),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 40, height: 14),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 60, height: 10),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
const Row(
|
||||
children: <Widget>[
|
||||
UiShimmerBox(width: 60, height: 22),
|
||||
SizedBox(width: UiConstants.space2),
|
||||
UiShimmerBox(width: 36, height: 22),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
UiShimmerBox(
|
||||
width: double.infinity,
|
||||
height: 32,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'reorder_card_skeleton.dart';
|
||||
|
||||
/// Skeleton for the horizontal reorder cards list.
|
||||
class ReorderSectionSkeleton extends StatelessWidget {
|
||||
/// Creates a [ReorderSectionSkeleton].
|
||||
const ReorderSectionSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const <Widget>[
|
||||
UiShimmerSectionHeader(),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
SizedBox(
|
||||
height: 164,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Expanded(child: ReorderCardSkeleton()),
|
||||
SizedBox(width: UiConstants.space3),
|
||||
Expanded(child: ReorderCardSkeleton()),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Skeleton mimicking the spending card layout.
|
||||
class SpendingCardSkeleton extends StatelessWidget {
|
||||
/// Creates a [SpendingCardSkeleton].
|
||||
const SpendingCardSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: const Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 60, height: 10),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 80, height: 22),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 50, height: 10),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 60, height: 10),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 70, height: 18),
|
||||
SizedBox(height: UiConstants.space1),
|
||||
UiShimmerLine(width: 50, height: 10),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'spending_card_skeleton.dart';
|
||||
|
||||
/// Skeleton for the spending gradient card.
|
||||
class SpendingSectionSkeleton extends StatelessWidget {
|
||||
/// Creates a [SpendingSectionSkeleton].
|
||||
const SpendingSectionSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
UiShimmerSectionHeader(),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
SpendingCardSkeleton(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import '../blocs/client_hubs_state.dart';
|
||||
import '../widgets/hub_card.dart';
|
||||
import '../widgets/hub_empty_state.dart';
|
||||
import '../widgets/hub_info_card.dart';
|
||||
import '../widgets/hubs_page_skeleton.dart';
|
||||
|
||||
/// The main page for the client hubs feature.
|
||||
///
|
||||
@@ -94,7 +95,7 @@ class ClientHubsPage extends StatelessWidget {
|
||||
),
|
||||
|
||||
if (state.status == ClientHubsStatus.loading)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
const HubsPageSkeleton()
|
||||
else if (state.hubs.isEmpty)
|
||||
HubEmptyState(
|
||||
onAddPressed: () async {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shimmer loading skeleton for the hubs list page.
|
||||
///
|
||||
/// Shows placeholder hub cards matching the [HubCard] layout with a
|
||||
/// leading icon box, title line, and address line.
|
||||
class HubsPageSkeleton extends StatelessWidget {
|
||||
/// Creates a [HubsPageSkeleton].
|
||||
const HubsPageSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UiShimmer(
|
||||
child: Column(
|
||||
children: List.generate(5, (int index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Row(
|
||||
children: [
|
||||
// Leading icon placeholder
|
||||
UiShimmerBox(
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space4),
|
||||
// Title and address lines
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
UiShimmerLine(width: 160, height: 16),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
UiShimmerLine(width: 200, height: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
// Chevron placeholder
|
||||
const UiShimmerBox(width: 16, height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -4,7 +4,9 @@ import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'data/repositories_impl/client_create_order_repository_impl.dart';
|
||||
import 'data/repositories_impl/client_order_query_repository_impl.dart';
|
||||
import 'domain/repositories/client_create_order_repository_interface.dart';
|
||||
import 'domain/repositories/client_order_query_repository_interface.dart';
|
||||
import 'domain/usecases/create_one_time_order_usecase.dart';
|
||||
import 'domain/usecases/create_permanent_order_usecase.dart';
|
||||
import 'domain/usecases/create_recurring_order_usecase.dart';
|
||||
@@ -18,6 +20,7 @@ import 'presentation/pages/one_time_order_page.dart';
|
||||
import 'presentation/pages/permanent_order_page.dart';
|
||||
import 'presentation/pages/rapid_order_page.dart';
|
||||
import 'presentation/pages/recurring_order_page.dart';
|
||||
import 'presentation/pages/review_order_page.dart';
|
||||
|
||||
/// Module for the Client Create Order feature.
|
||||
///
|
||||
@@ -39,6 +42,12 @@ class ClientCreateOrderModule extends Module {
|
||||
),
|
||||
);
|
||||
|
||||
i.addLazySingleton<ClientOrderQueryRepositoryInterface>(
|
||||
() => ClientOrderQueryRepositoryImpl(
|
||||
service: i.get<dc.DataConnectService>(),
|
||||
),
|
||||
);
|
||||
|
||||
// UseCases
|
||||
i.addLazySingleton(CreateOneTimeOrderUseCase.new);
|
||||
i.addLazySingleton(CreatePermanentOrderUseCase.new);
|
||||
@@ -57,14 +66,20 @@ class ClientCreateOrderModule extends Module {
|
||||
),
|
||||
);
|
||||
i.add<OneTimeOrderBloc>(OneTimeOrderBloc.new);
|
||||
i.add<PermanentOrderBloc>(PermanentOrderBloc.new);
|
||||
i.add<PermanentOrderBloc>(
|
||||
() => PermanentOrderBloc(
|
||||
i.get<CreatePermanentOrderUseCase>(),
|
||||
i.get<GetOrderDetailsForReorderUseCase>(),
|
||||
i.get<ClientOrderQueryRepositoryInterface>(),
|
||||
),
|
||||
);
|
||||
i.add<RecurringOrderBloc>(RecurringOrderBloc.new);
|
||||
}
|
||||
|
||||
@override
|
||||
void routes(RouteManager r) {
|
||||
r.child(
|
||||
'/',
|
||||
ClientPaths.childRoute(ClientPaths.createOrder, ClientPaths.createOrder),
|
||||
child: (BuildContext context) => const ClientCreateOrderPage(),
|
||||
);
|
||||
r.child(
|
||||
@@ -95,5 +110,12 @@ class ClientCreateOrderModule extends Module {
|
||||
),
|
||||
child: (BuildContext context) => const PermanentOrderPage(),
|
||||
);
|
||||
r.child(
|
||||
ClientPaths.childRoute(
|
||||
ClientPaths.createOrder,
|
||||
ClientPaths.createOrderReview,
|
||||
),
|
||||
child: (BuildContext context) => const ReviewOrderPage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../domain/models/order_hub.dart';
|
||||
import '../../domain/models/order_manager.dart';
|
||||
import '../../domain/models/order_role.dart';
|
||||
import '../../domain/repositories/client_order_query_repository_interface.dart';
|
||||
|
||||
/// Data layer implementation of [ClientOrderQueryRepositoryInterface].
|
||||
///
|
||||
/// Delegates all backend calls to [dc.DataConnectService] using the
|
||||
/// `_service.run()` pattern for automatic auth validation, token refresh,
|
||||
/// and retry logic. Each method maps Data Connect response types to the
|
||||
/// corresponding clean domain models.
|
||||
class ClientOrderQueryRepositoryImpl
|
||||
implements ClientOrderQueryRepositoryInterface {
|
||||
/// Creates an instance backed by the given [service].
|
||||
ClientOrderQueryRepositoryImpl({required dc.DataConnectService service})
|
||||
: _service = service;
|
||||
|
||||
final dc.DataConnectService _service;
|
||||
|
||||
@override
|
||||
Future<List<Vendor>> getVendors() async {
|
||||
return _service.run(() async {
|
||||
final result = await _service.connector.listVendors().execute();
|
||||
return result.data.vendors
|
||||
.map(
|
||||
(dc.ListVendorsVendors vendor) => Vendor(
|
||||
id: vendor.id,
|
||||
name: vendor.companyName,
|
||||
rates: const <String, double>{},
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<OrderRole>> getRolesByVendor(String vendorId) async {
|
||||
return _service.run(() async {
|
||||
final result = await _service.connector
|
||||
.listRolesByVendorId(vendorId: vendorId)
|
||||
.execute();
|
||||
return result.data.roles
|
||||
.map(
|
||||
(dc.ListRolesByVendorIdRoles role) => OrderRole(
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
costPerHour: role.costPerHour,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<OrderHub>> getHubsByOwner(String ownerId) async {
|
||||
return _service.run(() async {
|
||||
final result = await _service.connector
|
||||
.listTeamHubsByOwnerId(ownerId: ownerId)
|
||||
.execute();
|
||||
return result.data.teamHubs
|
||||
.map(
|
||||
(dc.ListTeamHubsByOwnerIdTeamHubs hub) => OrderHub(
|
||||
id: hub.id,
|
||||
name: hub.hubName,
|
||||
address: hub.address,
|
||||
placeId: hub.placeId,
|
||||
latitude: hub.latitude,
|
||||
longitude: hub.longitude,
|
||||
city: hub.city,
|
||||
state: hub.state,
|
||||
street: hub.street,
|
||||
country: hub.country,
|
||||
zipCode: hub.zipCode,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<OrderManager>> getManagersByHub(String hubId) async {
|
||||
return _service.run(() async {
|
||||
final result = await _service.connector.listTeamMembers().execute();
|
||||
return result.data.teamMembers
|
||||
.where(
|
||||
(dc.ListTeamMembersTeamMembers member) =>
|
||||
member.teamHubId == hubId &&
|
||||
member.role is dc.Known<dc.TeamMemberRole> &&
|
||||
(member.role as dc.Known<dc.TeamMemberRole>).value ==
|
||||
dc.TeamMemberRole.MANAGER,
|
||||
)
|
||||
.map(
|
||||
(dc.ListTeamMembersTeamMembers member) => OrderManager(
|
||||
id: member.id,
|
||||
name: member.user.fullName ?? 'Unknown',
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getBusinessId() => _service.getBusinessId();
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// A team hub (location) available for order assignment.
|
||||
///
|
||||
/// This domain model represents a physical hub location owned by the business.
|
||||
/// It is used to populate hub selection dropdowns and to attach location
|
||||
/// details when creating shifts for an order.
|
||||
class OrderHub extends Equatable {
|
||||
/// Creates an [OrderHub] with the required [id], [name], and [address],
|
||||
/// plus optional geo-location and address component fields.
|
||||
const OrderHub({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.address,
|
||||
this.placeId,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.city,
|
||||
this.state,
|
||||
this.street,
|
||||
this.country,
|
||||
this.zipCode,
|
||||
});
|
||||
|
||||
/// Unique identifier of the hub.
|
||||
final String id;
|
||||
|
||||
/// Human-readable display name of the hub.
|
||||
final String name;
|
||||
|
||||
/// Full street address of the hub.
|
||||
final String address;
|
||||
|
||||
/// Google Places ID, if available.
|
||||
final String? placeId;
|
||||
|
||||
/// Geographic latitude of the hub.
|
||||
final double? latitude;
|
||||
|
||||
/// Geographic longitude of the hub.
|
||||
final double? longitude;
|
||||
|
||||
/// City where the hub is located.
|
||||
final String? city;
|
||||
|
||||
/// State or province where the hub is located.
|
||||
final String? state;
|
||||
|
||||
/// Street name portion of the address.
|
||||
final String? street;
|
||||
|
||||
/// Country where the hub is located.
|
||||
final String? country;
|
||||
|
||||
/// Postal / ZIP code of the hub.
|
||||
final String? zipCode;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
id,
|
||||
name,
|
||||
address,
|
||||
placeId,
|
||||
latitude,
|
||||
longitude,
|
||||
city,
|
||||
state,
|
||||
street,
|
||||
country,
|
||||
zipCode,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// A hub manager available for assignment to an order.
|
||||
///
|
||||
/// This domain model represents a team member with a MANAGER role at a
|
||||
/// specific hub. It is used to populate the manager selection dropdown
|
||||
/// when creating or editing an order.
|
||||
class OrderManager extends Equatable {
|
||||
/// Creates an [OrderManager] with the given [id] and [name].
|
||||
const OrderManager({required this.id, required this.name});
|
||||
|
||||
/// Unique identifier of the manager (team member ID).
|
||||
final String id;
|
||||
|
||||
/// Full display name of the manager.
|
||||
final String name;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[id, name];
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// A role available for staffing positions within an order.
|
||||
///
|
||||
/// This domain model represents a staffing role fetched from the backend,
|
||||
/// decoupled from any data layer dependencies. It carries the role identity
|
||||
/// and its hourly cost so the presentation layer can populate dropdowns
|
||||
/// and calculate estimates.
|
||||
class OrderRole extends Equatable {
|
||||
/// Creates an [OrderRole] with the given [id], [name], and [costPerHour].
|
||||
const OrderRole({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.costPerHour,
|
||||
});
|
||||
|
||||
/// Unique identifier of the role.
|
||||
final String id;
|
||||
|
||||
/// Human-readable display name of the role.
|
||||
final String name;
|
||||
|
||||
/// Hourly cost rate for this role.
|
||||
final double costPerHour;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[id, name, costPerHour];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../models/order_hub.dart';
|
||||
import '../models/order_manager.dart';
|
||||
import '../models/order_role.dart';
|
||||
|
||||
/// Interface for querying order-related reference data.
|
||||
///
|
||||
/// This repository centralises the read-only queries that the order creation
|
||||
/// BLoCs need (vendors, roles, hubs, managers) so that they no longer depend
|
||||
/// directly on [DataConnectService] or the `krow_data_connect` package.
|
||||
///
|
||||
/// Implementations live in the data layer and translate backend responses
|
||||
/// into clean domain models.
|
||||
abstract interface class ClientOrderQueryRepositoryInterface {
|
||||
/// Returns the list of available vendors.
|
||||
///
|
||||
/// The returned [Vendor] objects come from the shared `krow_domain` package
|
||||
/// because `Vendor` is already a clean domain entity.
|
||||
Future<List<Vendor>> getVendors();
|
||||
|
||||
/// Returns the roles offered by the vendor identified by [vendorId].
|
||||
Future<List<OrderRole>> getRolesByVendor(String vendorId);
|
||||
|
||||
/// Returns the team hubs owned by the business identified by [ownerId].
|
||||
Future<List<OrderHub>> getHubsByOwner(String ownerId);
|
||||
|
||||
/// Returns the managers assigned to the hub identified by [hubId].
|
||||
///
|
||||
/// Only team members with the MANAGER role at the given hub are included.
|
||||
Future<List<OrderManager>> getManagersByHub(String hubId);
|
||||
|
||||
/// Returns the current business ID from the active client session.
|
||||
///
|
||||
/// This allows BLoCs to resolve the business ID without depending on
|
||||
/// the data layer's session store directly, keeping the presentation
|
||||
/// layer free from `krow_data_connect` imports.
|
||||
Future<String> getBusinessId();
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:client_create_order/src/domain/arguments/one_time_order_arguments.dart';
|
||||
import 'package:client_create_order/src/domain/models/order_hub.dart';
|
||||
import 'package:client_create_order/src/domain/models/order_manager.dart';
|
||||
import 'package:client_create_order/src/domain/models/order_role.dart';
|
||||
import 'package:client_create_order/src/domain/repositories/client_order_query_repository_interface.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/create_one_time_order_usecase.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart';
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'one_time_order_event.dart';
|
||||
@@ -18,7 +20,7 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||
OneTimeOrderBloc(
|
||||
this._createOneTimeOrderUseCase,
|
||||
this._getOrderDetailsForReorderUseCase,
|
||||
this._service,
|
||||
this._queryRepository,
|
||||
) : super(OneTimeOrderState.initial()) {
|
||||
on<OneTimeOrderVendorsLoaded>(_onVendorsLoaded);
|
||||
on<OneTimeOrderVendorChanged>(_onVendorChanged);
|
||||
@@ -39,25 +41,11 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||
}
|
||||
final CreateOneTimeOrderUseCase _createOneTimeOrderUseCase;
|
||||
final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase;
|
||||
final dc.DataConnectService _service;
|
||||
final ClientOrderQueryRepositoryInterface _queryRepository;
|
||||
|
||||
Future<void> _loadVendors() async {
|
||||
final List<Vendor>? vendors = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final fdc.QueryResult<dc.ListVendorsData, void> result = await _service
|
||||
.connector
|
||||
.listVendors()
|
||||
.execute();
|
||||
return result.data.vendors
|
||||
.map(
|
||||
(dc.ListVendorsVendors vendor) => Vendor(
|
||||
id: vendor.id,
|
||||
name: vendor.companyName,
|
||||
rates: const <String, double>{},
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
},
|
||||
action: () => _queryRepository.getVendors(),
|
||||
onError: (_) => add(const OneTimeOrderVendorsLoaded(<Vendor>[])),
|
||||
);
|
||||
|
||||
@@ -72,19 +60,14 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||
) async {
|
||||
final List<OneTimeOrderRoleOption>? roles = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final fdc.QueryResult<
|
||||
dc.ListRolesByVendorIdData,
|
||||
dc.ListRolesByVendorIdVariables
|
||||
>
|
||||
result = await _service.connector
|
||||
.listRolesByVendorId(vendorId: vendorId)
|
||||
.execute();
|
||||
return result.data.roles
|
||||
final List<OrderRole> result =
|
||||
await _queryRepository.getRolesByVendor(vendorId);
|
||||
return result
|
||||
.map(
|
||||
(dc.ListRolesByVendorIdRoles role) => OneTimeOrderRoleOption(
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
costPerHour: role.costPerHour,
|
||||
(OrderRole r) => OneTimeOrderRoleOption(
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
costPerHour: r.costPerHour,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
@@ -101,28 +84,23 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||
Future<void> _loadHubs() async {
|
||||
final List<OneTimeOrderHubOption>? hubs = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
final fdc.QueryResult<
|
||||
dc.ListTeamHubsByOwnerIdData,
|
||||
dc.ListTeamHubsByOwnerIdVariables
|
||||
>
|
||||
result = await _service.connector
|
||||
.listTeamHubsByOwnerId(ownerId: businessId)
|
||||
.execute();
|
||||
return result.data.teamHubs
|
||||
final String businessId = await _queryRepository.getBusinessId();
|
||||
final List<OrderHub> result =
|
||||
await _queryRepository.getHubsByOwner(businessId);
|
||||
return result
|
||||
.map(
|
||||
(dc.ListTeamHubsByOwnerIdTeamHubs hub) => OneTimeOrderHubOption(
|
||||
id: hub.id,
|
||||
name: hub.hubName,
|
||||
address: hub.address,
|
||||
placeId: hub.placeId,
|
||||
latitude: hub.latitude,
|
||||
longitude: hub.longitude,
|
||||
city: hub.city,
|
||||
state: hub.state,
|
||||
street: hub.street,
|
||||
country: hub.country,
|
||||
zipCode: hub.zipCode,
|
||||
(OrderHub h) => OneTimeOrderHubOption(
|
||||
id: h.id,
|
||||
name: h.name,
|
||||
address: h.address,
|
||||
placeId: h.placeId,
|
||||
latitude: h.latitude,
|
||||
longitude: h.longitude,
|
||||
city: h.city,
|
||||
state: h.state,
|
||||
street: h.street,
|
||||
country: h.country,
|
||||
zipCode: h.zipCode,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
@@ -140,23 +118,14 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||
final List<OneTimeOrderManagerOption>? managers =
|
||||
await handleErrorWithResult(
|
||||
action: () async {
|
||||
final fdc.QueryResult<dc.ListTeamMembersData, void> result =
|
||||
await _service.connector.listTeamMembers().execute();
|
||||
|
||||
return result.data.teamMembers
|
||||
.where(
|
||||
(dc.ListTeamMembersTeamMembers member) =>
|
||||
member.teamHubId == hubId &&
|
||||
member.role is dc.Known<dc.TeamMemberRole> &&
|
||||
(member.role as dc.Known<dc.TeamMemberRole>).value ==
|
||||
dc.TeamMemberRole.MANAGER,
|
||||
)
|
||||
final List<OrderManager> result =
|
||||
await _queryRepository.getManagersByHub(hubId);
|
||||
return result
|
||||
.map(
|
||||
(dc.ListTeamMembersTeamMembers member) =>
|
||||
OneTimeOrderManagerOption(
|
||||
id: member.id,
|
||||
name: member.user.fullName ?? 'Unknown',
|
||||
),
|
||||
(OrderManager m) => OneTimeOrderManagerOption(
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
},
|
||||
@@ -180,7 +149,11 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||
? event.vendors.first
|
||||
: null;
|
||||
emit(
|
||||
state.copyWith(vendors: event.vendors, selectedVendor: selectedVendor),
|
||||
state.copyWith(
|
||||
vendors: event.vendors,
|
||||
selectedVendor: selectedVendor,
|
||||
isDataLoaded: true,
|
||||
),
|
||||
);
|
||||
if (selectedVendor != null) {
|
||||
await _loadRolesForVendor(selectedVendor.id, emit);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../utils/time_parsing_utils.dart';
|
||||
|
||||
enum OneTimeOrderStatus { initial, loading, success, failure }
|
||||
|
||||
class OneTimeOrderState extends Equatable {
|
||||
@@ -19,6 +21,7 @@ class OneTimeOrderState extends Equatable {
|
||||
this.managers = const <OneTimeOrderManagerOption>[],
|
||||
this.selectedManager,
|
||||
this.isRapidDraft = false,
|
||||
this.isDataLoaded = false,
|
||||
});
|
||||
|
||||
factory OneTimeOrderState.initial() {
|
||||
@@ -50,6 +53,9 @@ class OneTimeOrderState extends Equatable {
|
||||
final OneTimeOrderManagerOption? selectedManager;
|
||||
final bool isRapidDraft;
|
||||
|
||||
/// Whether initial data (vendors, hubs) has been fetched from the backend.
|
||||
final bool isDataLoaded;
|
||||
|
||||
OneTimeOrderState copyWith({
|
||||
DateTime? date,
|
||||
String? location,
|
||||
@@ -65,6 +71,7 @@ class OneTimeOrderState extends Equatable {
|
||||
List<OneTimeOrderManagerOption>? managers,
|
||||
OneTimeOrderManagerOption? selectedManager,
|
||||
bool? isRapidDraft,
|
||||
bool? isDataLoaded,
|
||||
}) {
|
||||
return OneTimeOrderState(
|
||||
date: date ?? this.date,
|
||||
@@ -81,6 +88,7 @@ class OneTimeOrderState extends Equatable {
|
||||
managers: managers ?? this.managers,
|
||||
selectedManager: selectedManager ?? this.selectedManager,
|
||||
isRapidDraft: isRapidDraft ?? this.isRapidDraft,
|
||||
isDataLoaded: isDataLoaded ?? this.isDataLoaded,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,6 +106,77 @@ class OneTimeOrderState extends Equatable {
|
||||
);
|
||||
}
|
||||
|
||||
/// Looks up a role name by its ID, returns `null` if not found.
|
||||
String? roleNameById(String id) {
|
||||
for (final OneTimeOrderRoleOption r in roles) {
|
||||
if (r.id == id) return r.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Looks up a role cost-per-hour by its ID, returns `0` if not found.
|
||||
double roleCostById(String id) {
|
||||
for (final OneTimeOrderRoleOption r in roles) {
|
||||
if (r.id == id) return r.costPerHour;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Total number of workers across all positions.
|
||||
int get totalWorkers => positions.fold(
|
||||
0,
|
||||
(int sum, OneTimeOrderPosition p) => sum + p.count,
|
||||
);
|
||||
|
||||
/// Sum of (count * costPerHour) across all positions.
|
||||
double get totalCostPerHour => positions.fold(
|
||||
0,
|
||||
(double sum, OneTimeOrderPosition p) =>
|
||||
sum + (p.count * roleCostById(p.role)),
|
||||
);
|
||||
|
||||
/// Estimated total cost: sum of (count * costPerHour * hours) per position.
|
||||
double get estimatedTotal {
|
||||
double total = 0;
|
||||
for (final OneTimeOrderPosition p in positions) {
|
||||
final double hours = parseHoursFromTimes(p.startTime, p.endTime);
|
||||
total += p.count * roleCostById(p.role) * hours;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Time range string from the first position (e.g. "6:00 AM \u2013 2:00 PM").
|
||||
String get shiftTimeRange {
|
||||
if (positions.isEmpty) return '';
|
||||
final OneTimeOrderPosition first = positions.first;
|
||||
return '${first.startTime} \u2013 ${first.endTime}';
|
||||
}
|
||||
|
||||
/// Formatted shift duration from the first position (e.g. "8 hrs (30 min break)").
|
||||
String get shiftDuration {
|
||||
if (positions.isEmpty) return '';
|
||||
final OneTimeOrderPosition first = positions.first;
|
||||
final double hours = parseHoursFromTimes(first.startTime, first.endTime);
|
||||
if (hours <= 0) return '';
|
||||
|
||||
final int wholeHours = hours.floor();
|
||||
final int minutes = ((hours - wholeHours) * 60).round();
|
||||
final StringBuffer buffer = StringBuffer();
|
||||
|
||||
if (wholeHours > 0) buffer.write('$wholeHours hrs');
|
||||
if (minutes > 0) {
|
||||
if (wholeHours > 0) buffer.write(' ');
|
||||
buffer.write('$minutes min');
|
||||
}
|
||||
|
||||
if (first.lunchBreak != 'NO_BREAK' &&
|
||||
first.lunchBreak.isNotEmpty) {
|
||||
buffer.write(' (${first.lunchBreak} break)');
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
date,
|
||||
@@ -114,6 +193,7 @@ class OneTimeOrderState extends Equatable {
|
||||
managers,
|
||||
selectedManager,
|
||||
isRapidDraft,
|
||||
isDataLoaded,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:client_create_order/src/domain/models/order_hub.dart';
|
||||
import 'package:client_create_order/src/domain/models/order_manager.dart';
|
||||
import 'package:client_create_order/src/domain/models/order_role.dart';
|
||||
import 'package:client_create_order/src/domain/repositories/client_order_query_repository_interface.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/create_permanent_order_usecase.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart';
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
|
||||
import 'permanent_order_event.dart';
|
||||
@@ -17,7 +19,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
PermanentOrderBloc(
|
||||
this._createPermanentOrderUseCase,
|
||||
this._getOrderDetailsForReorderUseCase,
|
||||
this._service,
|
||||
this._queryRepository,
|
||||
) : super(PermanentOrderState.initial()) {
|
||||
on<PermanentOrderVendorsLoaded>(_onVendorsLoaded);
|
||||
on<PermanentOrderVendorChanged>(_onVendorChanged);
|
||||
@@ -40,7 +42,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
|
||||
final CreatePermanentOrderUseCase _createPermanentOrderUseCase;
|
||||
final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase;
|
||||
final dc.DataConnectService _service;
|
||||
final ClientOrderQueryRepositoryInterface _queryRepository;
|
||||
|
||||
static const List<String> _dayLabels = <String>[
|
||||
'SUN',
|
||||
@@ -54,21 +56,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
|
||||
Future<void> _loadVendors() async {
|
||||
final List<domain.Vendor>? vendors = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final fdc.QueryResult<dc.ListVendorsData, void> result = await _service
|
||||
.connector
|
||||
.listVendors()
|
||||
.execute();
|
||||
return result.data.vendors
|
||||
.map(
|
||||
(dc.ListVendorsVendors vendor) => domain.Vendor(
|
||||
id: vendor.id,
|
||||
name: vendor.companyName,
|
||||
rates: const <String, double>{},
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
},
|
||||
action: () => _queryRepository.getVendors(),
|
||||
onError: (_) => add(const PermanentOrderVendorsLoaded(<domain.Vendor>[])),
|
||||
);
|
||||
|
||||
@@ -83,19 +71,14 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
) async {
|
||||
final List<PermanentOrderRoleOption>? roles = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final fdc.QueryResult<
|
||||
dc.ListRolesByVendorIdData,
|
||||
dc.ListRolesByVendorIdVariables
|
||||
>
|
||||
result = await _service.connector
|
||||
.listRolesByVendorId(vendorId: vendorId)
|
||||
.execute();
|
||||
return result.data.roles
|
||||
final List<OrderRole> orderRoles =
|
||||
await _queryRepository.getRolesByVendor(vendorId);
|
||||
return orderRoles
|
||||
.map(
|
||||
(dc.ListRolesByVendorIdRoles role) => PermanentOrderRoleOption(
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
costPerHour: role.costPerHour,
|
||||
(OrderRole r) => PermanentOrderRoleOption(
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
costPerHour: r.costPerHour,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
@@ -112,19 +95,17 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
Future<void> _loadHubs() async {
|
||||
final List<PermanentOrderHubOption>? hubs = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
final fdc.QueryResult<
|
||||
dc.ListTeamHubsByOwnerIdData,
|
||||
dc.ListTeamHubsByOwnerIdVariables
|
||||
>
|
||||
result = await _service.connector
|
||||
.listTeamHubsByOwnerId(ownerId: businessId)
|
||||
.execute();
|
||||
return result.data.teamHubs
|
||||
final String? businessId = await _queryRepository.getBusinessId();
|
||||
if (businessId == null || businessId.isEmpty) {
|
||||
return <PermanentOrderHubOption>[];
|
||||
}
|
||||
final List<OrderHub> orderHubs =
|
||||
await _queryRepository.getHubsByOwner(businessId);
|
||||
return orderHubs
|
||||
.map(
|
||||
(dc.ListTeamHubsByOwnerIdTeamHubs hub) => PermanentOrderHubOption(
|
||||
(OrderHub hub) => PermanentOrderHubOption(
|
||||
id: hub.id,
|
||||
name: hub.hubName,
|
||||
name: hub.name,
|
||||
address: hub.address,
|
||||
placeId: hub.placeId,
|
||||
latitude: hub.latitude,
|
||||
@@ -155,7 +136,11 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
? event.vendors.first
|
||||
: null;
|
||||
emit(
|
||||
state.copyWith(vendors: event.vendors, selectedVendor: selectedVendor),
|
||||
state.copyWith(
|
||||
vendors: event.vendors,
|
||||
selectedVendor: selectedVendor,
|
||||
isDataLoaded: true,
|
||||
),
|
||||
);
|
||||
if (selectedVendor != null) {
|
||||
await _loadRolesForVendor(selectedVendor.id, emit);
|
||||
@@ -170,10 +155,10 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
await _loadRolesForVendor(event.vendor.id, emit);
|
||||
}
|
||||
|
||||
void _onHubsLoaded(
|
||||
Future<void> _onHubsLoaded(
|
||||
PermanentOrderHubsLoaded event,
|
||||
Emitter<PermanentOrderState> emit,
|
||||
) {
|
||||
) async {
|
||||
final PermanentOrderHubOption? selectedHub = event.hubs.isNotEmpty
|
||||
? event.hubs.first
|
||||
: null;
|
||||
@@ -186,16 +171,16 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
);
|
||||
|
||||
if (selectedHub != null) {
|
||||
_loadManagersForHub(selectedHub.id, emit);
|
||||
await _loadManagersForHub(selectedHub.id, emit);
|
||||
}
|
||||
}
|
||||
|
||||
void _onHubChanged(
|
||||
Future<void> _onHubChanged(
|
||||
PermanentOrderHubChanged event,
|
||||
Emitter<PermanentOrderState> emit,
|
||||
) {
|
||||
) async {
|
||||
emit(state.copyWith(selectedHub: event.hub, location: event.hub.name));
|
||||
_loadManagersForHub(event.hub.id, emit);
|
||||
await _loadManagersForHub(event.hub.id, emit);
|
||||
}
|
||||
|
||||
void _onHubManagerChanged(
|
||||
@@ -219,22 +204,13 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
final List<PermanentOrderManagerOption>? managers =
|
||||
await handleErrorWithResult(
|
||||
action: () async {
|
||||
final fdc.QueryResult<dc.ListTeamMembersData, void> result =
|
||||
await _service.connector.listTeamMembers().execute();
|
||||
|
||||
return result.data.teamMembers
|
||||
.where(
|
||||
(dc.ListTeamMembersTeamMembers member) =>
|
||||
member.teamHubId == hubId &&
|
||||
member.role is dc.Known<dc.TeamMemberRole> &&
|
||||
(member.role as dc.Known<dc.TeamMemberRole>).value ==
|
||||
dc.TeamMemberRole.MANAGER,
|
||||
)
|
||||
final List<OrderManager> orderManagers =
|
||||
await _queryRepository.getManagersByHub(hubId);
|
||||
return orderManagers
|
||||
.map(
|
||||
(dc.ListTeamMembersTeamMembers member) =>
|
||||
PermanentOrderManagerOption(
|
||||
id: member.id,
|
||||
name: member.user.fullName ?? 'Unknown',
|
||||
(OrderManager m) => PermanentOrderManagerOption(
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../utils/time_parsing_utils.dart';
|
||||
|
||||
enum PermanentOrderStatus { initial, loading, success, failure }
|
||||
|
||||
@@ -20,6 +21,7 @@ class PermanentOrderState extends Equatable {
|
||||
this.roles = const <PermanentOrderRoleOption>[],
|
||||
this.managers = const <PermanentOrderManagerOption>[],
|
||||
this.selectedManager,
|
||||
this.isDataLoaded = false,
|
||||
});
|
||||
|
||||
factory PermanentOrderState.initial() {
|
||||
@@ -67,6 +69,9 @@ class PermanentOrderState extends Equatable {
|
||||
final List<PermanentOrderManagerOption> managers;
|
||||
final PermanentOrderManagerOption? selectedManager;
|
||||
|
||||
/// Whether initial data (vendors, hubs) has been fetched from the backend.
|
||||
final bool isDataLoaded;
|
||||
|
||||
PermanentOrderState copyWith({
|
||||
DateTime? startDate,
|
||||
List<String>? permanentDays,
|
||||
@@ -83,6 +88,7 @@ class PermanentOrderState extends Equatable {
|
||||
List<PermanentOrderRoleOption>? roles,
|
||||
List<PermanentOrderManagerOption>? managers,
|
||||
PermanentOrderManagerOption? selectedManager,
|
||||
bool? isDataLoaded,
|
||||
}) {
|
||||
return PermanentOrderState(
|
||||
startDate: startDate ?? this.startDate,
|
||||
@@ -100,6 +106,7 @@ class PermanentOrderState extends Equatable {
|
||||
roles: roles ?? this.roles,
|
||||
managers: managers ?? this.managers,
|
||||
selectedManager: selectedManager ?? this.selectedManager,
|
||||
isDataLoaded: isDataLoaded ?? this.isDataLoaded,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,6 +125,56 @@ class PermanentOrderState extends Equatable {
|
||||
);
|
||||
}
|
||||
|
||||
/// Looks up a role name by its ID, returns `null` if not found.
|
||||
String? roleNameById(String id) {
|
||||
for (final PermanentOrderRoleOption r in roles) {
|
||||
if (r.id == id) return r.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Looks up a role cost-per-hour by its ID, returns `0` if not found.
|
||||
double roleCostById(String id) {
|
||||
for (final PermanentOrderRoleOption r in roles) {
|
||||
if (r.id == id) return r.costPerHour;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Total number of workers across all positions.
|
||||
int get totalWorkers => positions.fold(
|
||||
0,
|
||||
(int sum, PermanentOrderPosition p) => sum + p.count,
|
||||
);
|
||||
|
||||
/// Sum of (count * costPerHour) across all positions.
|
||||
double get totalCostPerHour => positions.fold(
|
||||
0,
|
||||
(double sum, PermanentOrderPosition p) =>
|
||||
sum + (p.count * roleCostById(p.role)),
|
||||
);
|
||||
|
||||
/// Daily cost: sum of (count * costPerHour * hours) per position.
|
||||
double get dailyCost {
|
||||
double total = 0;
|
||||
for (final PermanentOrderPosition p in positions) {
|
||||
final double hours = parseHoursFromTimes(p.startTime, p.endTime);
|
||||
total += p.count * roleCostById(p.role) * hours;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Estimated weekly total cost for the permanent order.
|
||||
///
|
||||
/// Calculated as [dailyCost] multiplied by the number of selected
|
||||
/// [permanentDays] per week.
|
||||
double get estimatedTotal => dailyCost * permanentDays.length;
|
||||
|
||||
/// Formatted repeat days (e.g. "Mon, Tue, Wed").
|
||||
String get formattedRepeatDays => permanentDays.map(
|
||||
(String day) => day[0] + day.substring(1).toLowerCase(),
|
||||
).join(', ');
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
startDate,
|
||||
@@ -135,6 +192,7 @@ class PermanentOrderState extends Equatable {
|
||||
roles,
|
||||
managers,
|
||||
selectedManager,
|
||||
isDataLoaded,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
import 'package:client_create_order/src/domain/models/order_hub.dart';
|
||||
import 'package:client_create_order/src/domain/models/order_manager.dart';
|
||||
import 'package:client_create_order/src/domain/models/order_role.dart';
|
||||
import 'package:client_create_order/src/domain/repositories/client_order_query_repository_interface.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/create_recurring_order_usecase.dart';
|
||||
import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart';
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
|
||||
import 'recurring_order_event.dart';
|
||||
import 'recurring_order_state.dart';
|
||||
|
||||
/// BLoC for managing the recurring order creation form.
|
||||
///
|
||||
/// This BLoC delegates all backend queries to
|
||||
/// [ClientOrderQueryRepositoryInterface] and order submission to
|
||||
/// [CreateRecurringOrderUseCase], keeping the presentation layer free
|
||||
/// from direct `krow_data_connect` imports.
|
||||
class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
with
|
||||
BlocErrorHandler<RecurringOrderState>,
|
||||
SafeBloc<RecurringOrderEvent, RecurringOrderState> {
|
||||
/// Creates a [RecurringOrderBloc] with the required use cases and
|
||||
/// query repository.
|
||||
RecurringOrderBloc(
|
||||
this._createRecurringOrderUseCase,
|
||||
this._getOrderDetailsForReorderUseCase,
|
||||
this._service,
|
||||
this._queryRepository,
|
||||
) : super(RecurringOrderState.initial()) {
|
||||
on<RecurringOrderVendorsLoaded>(_onVendorsLoaded);
|
||||
on<RecurringOrderVendorChanged>(_onVendorChanged);
|
||||
@@ -41,7 +50,7 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
|
||||
final CreateRecurringOrderUseCase _createRecurringOrderUseCase;
|
||||
final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase;
|
||||
final dc.DataConnectService _service;
|
||||
final ClientOrderQueryRepositoryInterface _queryRepository;
|
||||
|
||||
static const List<String> _dayLabels = <String>[
|
||||
'SUN',
|
||||
@@ -53,24 +62,14 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
'SAT',
|
||||
];
|
||||
|
||||
/// Loads the list of available vendors from the query repository.
|
||||
Future<void> _loadVendors() async {
|
||||
final List<domain.Vendor>? vendors = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final fdc.QueryResult<dc.ListVendorsData, void> result = await _service
|
||||
.connector
|
||||
.listVendors()
|
||||
.execute();
|
||||
return result.data.vendors
|
||||
.map(
|
||||
(dc.ListVendorsVendors vendor) => domain.Vendor(
|
||||
id: vendor.id,
|
||||
name: vendor.companyName,
|
||||
rates: const <String, double>{},
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
return _queryRepository.getVendors();
|
||||
},
|
||||
onError: (_) => add(const RecurringOrderVendorsLoaded(<domain.Vendor>[])),
|
||||
onError: (_) =>
|
||||
add(const RecurringOrderVendorsLoaded(<domain.Vendor>[])),
|
||||
);
|
||||
|
||||
if (vendors != null) {
|
||||
@@ -78,25 +77,22 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads roles for the given [vendorId] and maps them to presentation
|
||||
/// option models.
|
||||
Future<void> _loadRolesForVendor(
|
||||
String vendorId,
|
||||
Emitter<RecurringOrderState> emit,
|
||||
) async {
|
||||
final List<RecurringOrderRoleOption>? roles = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final fdc.QueryResult<
|
||||
dc.ListRolesByVendorIdData,
|
||||
dc.ListRolesByVendorIdVariables
|
||||
>
|
||||
result = await _service.connector
|
||||
.listRolesByVendorId(vendorId: vendorId)
|
||||
.execute();
|
||||
return result.data.roles
|
||||
final List<OrderRole> orderRoles =
|
||||
await _queryRepository.getRolesByVendor(vendorId);
|
||||
return orderRoles
|
||||
.map(
|
||||
(dc.ListRolesByVendorIdRoles role) => RecurringOrderRoleOption(
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
costPerHour: role.costPerHour,
|
||||
(OrderRole r) => RecurringOrderRoleOption(
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
costPerHour: r.costPerHour,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
@@ -110,22 +106,19 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads team hubs for the current business owner and maps them to
|
||||
/// presentation option models.
|
||||
Future<void> _loadHubs() async {
|
||||
final List<RecurringOrderHubOption>? hubs = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
final fdc.QueryResult<
|
||||
dc.ListTeamHubsByOwnerIdData,
|
||||
dc.ListTeamHubsByOwnerIdVariables
|
||||
>
|
||||
result = await _service.connector
|
||||
.listTeamHubsByOwnerId(ownerId: businessId)
|
||||
.execute();
|
||||
return result.data.teamHubs
|
||||
final String businessId = await _queryRepository.getBusinessId();
|
||||
final List<OrderHub> orderHubs =
|
||||
await _queryRepository.getHubsByOwner(businessId);
|
||||
return orderHubs
|
||||
.map(
|
||||
(dc.ListTeamHubsByOwnerIdTeamHubs hub) => RecurringOrderHubOption(
|
||||
(OrderHub hub) => RecurringOrderHubOption(
|
||||
id: hub.id,
|
||||
name: hub.hubName,
|
||||
name: hub.name,
|
||||
address: hub.address,
|
||||
placeId: hub.placeId,
|
||||
latitude: hub.latitude,
|
||||
@@ -156,7 +149,11 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
? event.vendors.first
|
||||
: null;
|
||||
emit(
|
||||
state.copyWith(vendors: event.vendors, selectedVendor: selectedVendor),
|
||||
state.copyWith(
|
||||
vendors: event.vendors,
|
||||
selectedVendor: selectedVendor,
|
||||
isDataLoaded: true,
|
||||
),
|
||||
);
|
||||
if (selectedVendor != null) {
|
||||
await _loadRolesForVendor(selectedVendor.id, emit);
|
||||
@@ -213,6 +210,8 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
emit(state.copyWith(managers: event.managers));
|
||||
}
|
||||
|
||||
/// Loads managers for the given [hubId] and maps them to presentation
|
||||
/// option models.
|
||||
Future<void> _loadManagersForHub(
|
||||
String hubId,
|
||||
Emitter<RecurringOrderState> emit,
|
||||
@@ -220,22 +219,13 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
final List<RecurringOrderManagerOption>? managers =
|
||||
await handleErrorWithResult(
|
||||
action: () async {
|
||||
final fdc.QueryResult<dc.ListTeamMembersData, void> result =
|
||||
await _service.connector.listTeamMembers().execute();
|
||||
|
||||
return result.data.teamMembers
|
||||
.where(
|
||||
(dc.ListTeamMembersTeamMembers member) =>
|
||||
member.teamHubId == hubId &&
|
||||
member.role is dc.Known<dc.TeamMemberRole> &&
|
||||
(member.role as dc.Known<dc.TeamMemberRole>).value ==
|
||||
dc.TeamMemberRole.MANAGER,
|
||||
)
|
||||
final List<OrderManager> orderManagers =
|
||||
await _queryRepository.getManagersByHub(hubId);
|
||||
return orderManagers
|
||||
.map(
|
||||
(dc.ListTeamMembersTeamMembers member) =>
|
||||
RecurringOrderManagerOption(
|
||||
id: member.id,
|
||||
name: member.user.fullName ?? 'Unknown',
|
||||
(OrderManager m) => RecurringOrderManagerOption(
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../utils/schedule_utils.dart';
|
||||
import '../../utils/time_parsing_utils.dart';
|
||||
|
||||
enum RecurringOrderStatus { initial, loading, success, failure }
|
||||
|
||||
@@ -21,6 +23,7 @@ class RecurringOrderState extends Equatable {
|
||||
this.roles = const <RecurringOrderRoleOption>[],
|
||||
this.managers = const <RecurringOrderManagerOption>[],
|
||||
this.selectedManager,
|
||||
this.isDataLoaded = false,
|
||||
});
|
||||
|
||||
factory RecurringOrderState.initial() {
|
||||
@@ -70,6 +73,9 @@ class RecurringOrderState extends Equatable {
|
||||
final List<RecurringOrderManagerOption> managers;
|
||||
final RecurringOrderManagerOption? selectedManager;
|
||||
|
||||
/// Whether initial data (vendors, hubs) has been fetched from the backend.
|
||||
final bool isDataLoaded;
|
||||
|
||||
RecurringOrderState copyWith({
|
||||
DateTime? startDate,
|
||||
DateTime? endDate,
|
||||
@@ -87,6 +93,7 @@ class RecurringOrderState extends Equatable {
|
||||
List<RecurringOrderRoleOption>? roles,
|
||||
List<RecurringOrderManagerOption>? managers,
|
||||
RecurringOrderManagerOption? selectedManager,
|
||||
bool? isDataLoaded,
|
||||
}) {
|
||||
return RecurringOrderState(
|
||||
startDate: startDate ?? this.startDate,
|
||||
@@ -105,6 +112,7 @@ class RecurringOrderState extends Equatable {
|
||||
roles: roles ?? this.roles,
|
||||
managers: managers ?? this.managers,
|
||||
selectedManager: selectedManager ?? this.selectedManager,
|
||||
isDataLoaded: isDataLoaded ?? this.isDataLoaded,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,6 +133,75 @@ class RecurringOrderState extends Equatable {
|
||||
);
|
||||
}
|
||||
|
||||
/// Looks up a role name by its ID, returns `null` if not found.
|
||||
String? roleNameById(String id) {
|
||||
for (final RecurringOrderRoleOption r in roles) {
|
||||
if (r.id == id) return r.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Looks up a role cost-per-hour by its ID, returns `0` if not found.
|
||||
double roleCostById(String id) {
|
||||
for (final RecurringOrderRoleOption r in roles) {
|
||||
if (r.id == id) return r.costPerHour;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Total number of workers across all positions.
|
||||
int get totalWorkers => positions.fold(
|
||||
0,
|
||||
(int sum, RecurringOrderPosition p) => sum + p.count,
|
||||
);
|
||||
|
||||
/// Sum of (count * costPerHour) across all positions.
|
||||
double get totalCostPerHour => positions.fold(
|
||||
0,
|
||||
(double sum, RecurringOrderPosition p) =>
|
||||
sum + (p.count * roleCostById(p.role)),
|
||||
);
|
||||
|
||||
/// Daily cost: sum of (count * costPerHour * hours) per position.
|
||||
double get dailyCost {
|
||||
double total = 0;
|
||||
for (final RecurringOrderPosition p in positions) {
|
||||
final double hours = parseHoursFromTimes(p.startTime, p.endTime);
|
||||
total += p.count * roleCostById(p.role) * hours;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Total number of working days between [startDate] and [endDate]
|
||||
/// (inclusive) that match the selected [recurringDays].
|
||||
///
|
||||
/// Iterates day-by-day and counts each date whose weekday label
|
||||
/// (e.g. "MON", "TUE") appears in [recurringDays].
|
||||
int get totalWorkingDays {
|
||||
final Set<String> selectedSet = recurringDays.toSet();
|
||||
int count = 0;
|
||||
for (
|
||||
DateTime day = startDate;
|
||||
!day.isAfter(endDate);
|
||||
day = day.add(const Duration(days: 1))
|
||||
) {
|
||||
if (selectedSet.contains(weekdayLabel(day))) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Estimated total cost for the entire recurring order period.
|
||||
///
|
||||
/// Calculated as [dailyCost] multiplied by [totalWorkingDays].
|
||||
double get estimatedTotal => dailyCost * totalWorkingDays;
|
||||
|
||||
/// Formatted repeat days (e.g. "Mon, Tue, Wed").
|
||||
String get formattedRepeatDays => recurringDays.map(
|
||||
(String day) => day[0] + day.substring(1).toLowerCase(),
|
||||
).join(', ');
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
startDate,
|
||||
@@ -143,6 +220,7 @@ class RecurringOrderState extends Equatable {
|
||||
roles,
|
||||
managers,
|
||||
selectedManager,
|
||||
isDataLoaded,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import '../widgets/review_order/review_order_positions_card.dart';
|
||||
|
||||
/// Identifies the order type for rendering the correct schedule layout
|
||||
/// on the review page.
|
||||
enum ReviewOrderType { oneTime, recurring, permanent }
|
||||
|
||||
/// Data transfer object passed as route arguments to the [ReviewOrderPage].
|
||||
///
|
||||
/// Contains pre-formatted display strings for every section of the review
|
||||
/// summary. The form page is responsible for converting BLoC state into
|
||||
/// these human-readable values before navigating.
|
||||
class ReviewOrderArguments {
|
||||
const ReviewOrderArguments({
|
||||
required this.orderType,
|
||||
required this.orderName,
|
||||
required this.hubName,
|
||||
required this.shiftContactName,
|
||||
required this.positions,
|
||||
required this.totalWorkers,
|
||||
required this.totalCostPerHour,
|
||||
required this.estimatedTotal,
|
||||
this.scheduleDate,
|
||||
this.scheduleTime,
|
||||
this.scheduleDuration,
|
||||
this.scheduleStartDate,
|
||||
this.scheduleEndDate,
|
||||
this.scheduleRepeatDays,
|
||||
this.totalLabel,
|
||||
});
|
||||
|
||||
final ReviewOrderType orderType;
|
||||
final String orderName;
|
||||
final String hubName;
|
||||
final String shiftContactName;
|
||||
final List<ReviewPositionItem> positions;
|
||||
final int totalWorkers;
|
||||
final double totalCostPerHour;
|
||||
final double estimatedTotal;
|
||||
|
||||
/// One-time order schedule fields.
|
||||
final String? scheduleDate;
|
||||
final String? scheduleTime;
|
||||
final String? scheduleDuration;
|
||||
|
||||
/// Recurring / permanent order schedule fields.
|
||||
final String? scheduleStartDate;
|
||||
final String? scheduleEndDate;
|
||||
final String? scheduleRepeatDays;
|
||||
|
||||
/// Optional label override for the total banner (e.g. "Estimated Weekly Total").
|
||||
final String? totalLabel;
|
||||
}
|
||||
@@ -1,19 +1,27 @@
|
||||
import 'package:client_orders_common/client_orders_common.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:client_orders_common/client_orders_common.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../blocs/one_time_order/one_time_order_bloc.dart';
|
||||
import '../blocs/one_time_order/one_time_order_event.dart';
|
||||
import '../blocs/one_time_order/one_time_order_state.dart';
|
||||
import '../models/review_order_arguments.dart';
|
||||
import '../utils/time_parsing_utils.dart';
|
||||
import '../widgets/review_order/review_order_positions_card.dart';
|
||||
|
||||
/// Page for creating a one-time staffing order.
|
||||
/// Users can specify the date, location, and multiple staff positions required.
|
||||
///
|
||||
/// This page initializes the [OneTimeOrderBloc] and displays the [OneTimeOrderView]
|
||||
/// from the common orders package. It follows the KROW Clean Architecture by being
|
||||
/// a [StatelessWidget] and mapping local BLoC state to generic UI models.
|
||||
/// ## Submission Flow
|
||||
///
|
||||
/// When the user taps "Create Order", this page does NOT submit directly.
|
||||
/// Instead it navigates to [ReviewOrderPage] with a snapshot of the current
|
||||
/// BLoC state formatted as [ReviewOrderArguments]. If the user confirms on
|
||||
/// the review page (pops with `true`), this page then fires
|
||||
/// [OneTimeOrderSubmitted] on the BLoC to perform the actual API call.
|
||||
class OneTimeOrderPage extends StatelessWidget {
|
||||
/// Creates a [OneTimeOrderPage].
|
||||
const OneTimeOrderPage({super.key});
|
||||
@@ -36,6 +44,7 @@ class OneTimeOrderPage extends StatelessWidget {
|
||||
);
|
||||
|
||||
return OneTimeOrderView(
|
||||
isDataLoaded: state.isDataLoaded,
|
||||
status: _mapStatus(state.status),
|
||||
errorMessage: state.errorMessage,
|
||||
eventName: state.eventName,
|
||||
@@ -53,8 +62,8 @@ class OneTimeOrderPage extends StatelessWidget {
|
||||
: null,
|
||||
hubManagers: state.managers.map(_mapManager).toList(),
|
||||
isValid: state.isValid,
|
||||
title: state.isRapidDraft ? 'Rapid Order' : null,
|
||||
subtitle: state.isRapidDraft ? 'Verify the order details' : null,
|
||||
title: state.isRapidDraft ? t.client_create_order.rapid_draft.title : null,
|
||||
subtitle: state.isRapidDraft ? t.client_create_order.rapid_draft.subtitle : null,
|
||||
onEventNameChanged: (String val) =>
|
||||
bloc.add(OneTimeOrderEventNameChanged(val)),
|
||||
onVendorChanged: (Vendor val) =>
|
||||
@@ -90,15 +99,53 @@ class OneTimeOrderPage extends StatelessWidget {
|
||||
},
|
||||
onPositionRemoved: (int index) =>
|
||||
bloc.add(OneTimeOrderPositionRemoved(index)),
|
||||
onSubmit: () => bloc.add(const OneTimeOrderSubmitted()),
|
||||
onSubmit: () => _navigateToReview(state, bloc),
|
||||
onDone: () => Modular.to.toOrdersSpecificDate(state.date),
|
||||
onBack: () => Modular.to.pop(),
|
||||
onBack: () => Modular.to.popSafe(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds [ReviewOrderArguments] from the current BLoC state and navigates
|
||||
/// to the review page. Submits the order only if the user confirms.
|
||||
Future<void> _navigateToReview(
|
||||
OneTimeOrderState state,
|
||||
OneTimeOrderBloc bloc,
|
||||
) async {
|
||||
final List<ReviewPositionItem> reviewPositions = state.positions.map(
|
||||
(OneTimeOrderPosition p) => ReviewPositionItem(
|
||||
roleName: state.roleNameById(p.role) ?? p.role,
|
||||
workerCount: p.count,
|
||||
costPerHour: state.roleCostById(p.role),
|
||||
hours: parseHoursFromTimes(p.startTime, p.endTime),
|
||||
startTime: p.startTime,
|
||||
endTime: p.endTime,
|
||||
),
|
||||
).toList();
|
||||
|
||||
final bool? confirmed = await Modular.to.toCreateOrderReview(
|
||||
arguments: ReviewOrderArguments(
|
||||
orderType: ReviewOrderType.oneTime,
|
||||
orderName: state.eventName,
|
||||
hubName: state.selectedHub?.name ?? '',
|
||||
shiftContactName: state.selectedManager?.name ?? '',
|
||||
positions: reviewPositions,
|
||||
totalWorkers: state.totalWorkers,
|
||||
totalCostPerHour: state.totalCostPerHour,
|
||||
estimatedTotal: state.estimatedTotal,
|
||||
scheduleDate: DateFormat.yMMMEd().format(state.date),
|
||||
scheduleTime: state.shiftTimeRange,
|
||||
scheduleDuration: state.shiftDuration,
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
bloc.add(const OneTimeOrderSubmitted());
|
||||
}
|
||||
}
|
||||
|
||||
OrderFormStatus _mapStatus(OneTimeOrderStatus status) {
|
||||
switch (status) {
|
||||
case OneTimeOrderStatus.initial:
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:client_orders_common/client_orders_common.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' hide PermanentOrderPosition;
|
||||
import '../blocs/permanent_order/permanent_order_bloc.dart';
|
||||
import '../blocs/permanent_order/permanent_order_event.dart';
|
||||
import '../blocs/permanent_order/permanent_order_state.dart';
|
||||
import '../models/review_order_arguments.dart';
|
||||
import '../utils/schedule_utils.dart';
|
||||
import '../utils/time_parsing_utils.dart';
|
||||
import '../widgets/review_order/review_order_positions_card.dart';
|
||||
|
||||
/// Page for creating a permanent staffing order.
|
||||
///
|
||||
/// ## Submission Flow
|
||||
///
|
||||
/// When the user taps "Create Order", this page navigates to
|
||||
/// [ReviewOrderPage] with a snapshot of the current BLoC state formatted
|
||||
/// as [ReviewOrderArguments]. If the user confirms (pops with `true`),
|
||||
/// this page fires [PermanentOrderSubmitted] on the BLoC.
|
||||
class PermanentOrderPage extends StatelessWidget {
|
||||
/// Creates a [PermanentOrderPage].
|
||||
const PermanentOrderPage({super.key});
|
||||
@@ -31,6 +44,7 @@ class PermanentOrderPage extends StatelessWidget {
|
||||
);
|
||||
|
||||
return PermanentOrderView(
|
||||
isDataLoaded: state.isDataLoaded,
|
||||
status: _mapStatus(state.status),
|
||||
errorMessage: state.errorMessage,
|
||||
eventName: state.eventName,
|
||||
@@ -89,64 +103,58 @@ class PermanentOrderPage extends StatelessWidget {
|
||||
},
|
||||
onPositionRemoved: (int index) =>
|
||||
bloc.add(PermanentOrderPositionRemoved(index)),
|
||||
onSubmit: () => bloc.add(const PermanentOrderSubmitted()),
|
||||
onSubmit: () => _navigateToReview(state, bloc),
|
||||
onDone: () {
|
||||
final DateTime initialDate = _firstPermanentShiftDate(
|
||||
final DateTime initialDate = firstScheduledShiftDate(
|
||||
state.startDate,
|
||||
state.startDate.add(const Duration(days: 29)),
|
||||
state.permanentDays,
|
||||
);
|
||||
|
||||
// Navigate to orders page with the initial date set to the first recurring shift date
|
||||
Modular.to.toOrdersSpecificDate(initialDate);
|
||||
},
|
||||
onBack: () => Modular.to.pop(),
|
||||
onBack: () => Modular.to.popSafe(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
DateTime _firstPermanentShiftDate(
|
||||
DateTime startDate,
|
||||
List<String> permanentDays,
|
||||
) {
|
||||
final DateTime start = DateTime(
|
||||
startDate.year,
|
||||
startDate.month,
|
||||
startDate.day,
|
||||
);
|
||||
final DateTime end = start.add(const Duration(days: 29));
|
||||
final Set<String> selected = permanentDays.toSet();
|
||||
for (
|
||||
DateTime day = start;
|
||||
!day.isAfter(end);
|
||||
day = day.add(const Duration(days: 1))
|
||||
) {
|
||||
if (selected.contains(_weekdayLabel(day))) {
|
||||
return day;
|
||||
}
|
||||
}
|
||||
return start;
|
||||
}
|
||||
/// Builds [ReviewOrderArguments] from the current BLoC state and navigates
|
||||
/// to the review page. Submits the order only if the user confirms.
|
||||
Future<void> _navigateToReview(
|
||||
PermanentOrderState state,
|
||||
PermanentOrderBloc bloc,
|
||||
) async {
|
||||
final List<ReviewPositionItem> reviewPositions = state.positions.map(
|
||||
(PermanentOrderPosition p) => ReviewPositionItem(
|
||||
roleName: state.roleNameById(p.role) ?? p.role,
|
||||
workerCount: p.count,
|
||||
costPerHour: state.roleCostById(p.role),
|
||||
hours: parseHoursFromTimes(p.startTime, p.endTime),
|
||||
startTime: p.startTime,
|
||||
endTime: p.endTime,
|
||||
),
|
||||
).toList();
|
||||
|
||||
String _weekdayLabel(DateTime date) {
|
||||
switch (date.weekday) {
|
||||
case DateTime.monday:
|
||||
return 'MON';
|
||||
case DateTime.tuesday:
|
||||
return 'TUE';
|
||||
case DateTime.wednesday:
|
||||
return 'WED';
|
||||
case DateTime.thursday:
|
||||
return 'THU';
|
||||
case DateTime.friday:
|
||||
return 'FRI';
|
||||
case DateTime.saturday:
|
||||
return 'SAT';
|
||||
case DateTime.sunday:
|
||||
return 'SUN';
|
||||
default:
|
||||
return 'SUN';
|
||||
final bool? confirmed = await Modular.to.toCreateOrderReview(
|
||||
arguments: ReviewOrderArguments(
|
||||
orderType: ReviewOrderType.permanent,
|
||||
orderName: state.eventName,
|
||||
hubName: state.selectedHub?.name ?? '',
|
||||
shiftContactName: state.selectedManager?.name ?? '',
|
||||
positions: reviewPositions,
|
||||
totalWorkers: state.totalWorkers,
|
||||
totalCostPerHour: state.totalCostPerHour,
|
||||
estimatedTotal: state.estimatedTotal,
|
||||
scheduleStartDate: DateFormat.yMMMd().format(state.startDate),
|
||||
scheduleRepeatDays: state.formattedRepeatDays,
|
||||
totalLabel: t.client_create_order.review.estimated_weekly_total,
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
bloc.add(const PermanentOrderSubmitted());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,25 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:client_orders_common/client_orders_common.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' hide RecurringOrderPosition;
|
||||
import '../blocs/recurring_order/recurring_order_bloc.dart';
|
||||
import '../blocs/recurring_order/recurring_order_event.dart';
|
||||
import '../blocs/recurring_order/recurring_order_state.dart';
|
||||
import '../models/review_order_arguments.dart';
|
||||
import '../utils/schedule_utils.dart';
|
||||
import '../utils/time_parsing_utils.dart';
|
||||
import '../widgets/review_order/review_order_positions_card.dart';
|
||||
|
||||
/// Page for creating a recurring staffing order.
|
||||
///
|
||||
/// ## Submission Flow
|
||||
///
|
||||
/// When the user taps "Create Order", this page navigates to
|
||||
/// [ReviewOrderPage] with a snapshot of the current BLoC state formatted
|
||||
/// as [ReviewOrderArguments]. If the user confirms (pops with `true`),
|
||||
/// this page fires [RecurringOrderSubmitted] on the BLoC.
|
||||
class RecurringOrderPage extends StatelessWidget {
|
||||
/// Creates a [RecurringOrderPage].
|
||||
const RecurringOrderPage({super.key});
|
||||
@@ -31,6 +43,7 @@ class RecurringOrderPage extends StatelessWidget {
|
||||
);
|
||||
|
||||
return RecurringOrderView(
|
||||
isDataLoaded: state.isDataLoaded,
|
||||
status: _mapStatus(state.status),
|
||||
errorMessage: state.errorMessage,
|
||||
eventName: state.eventName,
|
||||
@@ -92,7 +105,7 @@ class RecurringOrderPage extends StatelessWidget {
|
||||
},
|
||||
onPositionRemoved: (int index) =>
|
||||
bloc.add(RecurringOrderPositionRemoved(index)),
|
||||
onSubmit: () => bloc.add(const RecurringOrderSubmitted()),
|
||||
onSubmit: () => _navigateToReview(state, bloc),
|
||||
onDone: () {
|
||||
final DateTime maxEndDate = state.startDate.add(
|
||||
const Duration(days: 29),
|
||||
@@ -101,64 +114,56 @@ class RecurringOrderPage extends StatelessWidget {
|
||||
state.endDate.isAfter(maxEndDate)
|
||||
? maxEndDate
|
||||
: state.endDate;
|
||||
final DateTime initialDate = _firstRecurringShiftDate(
|
||||
final DateTime initialDate = firstScheduledShiftDate(
|
||||
state.startDate,
|
||||
effectiveEndDate,
|
||||
state.recurringDays,
|
||||
);
|
||||
|
||||
// Navigate to orders page with the initial date set to the first recurring shift date
|
||||
Modular.to.toOrdersSpecificDate(initialDate);
|
||||
},
|
||||
onBack: () => Modular.to.pop(),
|
||||
onBack: () => Modular.to.popSafe(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
DateTime _firstRecurringShiftDate(
|
||||
DateTime startDate,
|
||||
DateTime endDate,
|
||||
List<String> recurringDays,
|
||||
) {
|
||||
final DateTime start = DateTime(
|
||||
startDate.year,
|
||||
startDate.month,
|
||||
startDate.day,
|
||||
);
|
||||
final DateTime end = DateTime(endDate.year, endDate.month, endDate.day);
|
||||
final Set<String> selected = recurringDays.toSet();
|
||||
for (
|
||||
DateTime day = start;
|
||||
!day.isAfter(end);
|
||||
day = day.add(const Duration(days: 1))
|
||||
) {
|
||||
if (selected.contains(_weekdayLabel(day))) {
|
||||
return day;
|
||||
}
|
||||
}
|
||||
return start;
|
||||
}
|
||||
/// Builds [ReviewOrderArguments] from the current BLoC state and navigates
|
||||
/// to the review page. Submits the order only if the user confirms.
|
||||
Future<void> _navigateToReview(
|
||||
RecurringOrderState state,
|
||||
RecurringOrderBloc bloc,
|
||||
) async {
|
||||
final List<ReviewPositionItem> reviewPositions = state.positions.map(
|
||||
(RecurringOrderPosition p) => ReviewPositionItem(
|
||||
roleName: state.roleNameById(p.role) ?? p.role,
|
||||
workerCount: p.count,
|
||||
costPerHour: state.roleCostById(p.role),
|
||||
hours: parseHoursFromTimes(p.startTime, p.endTime),
|
||||
startTime: p.startTime,
|
||||
endTime: p.endTime,
|
||||
),
|
||||
).toList();
|
||||
|
||||
String _weekdayLabel(DateTime date) {
|
||||
switch (date.weekday) {
|
||||
case DateTime.monday:
|
||||
return 'MON';
|
||||
case DateTime.tuesday:
|
||||
return 'TUE';
|
||||
case DateTime.wednesday:
|
||||
return 'WED';
|
||||
case DateTime.thursday:
|
||||
return 'THU';
|
||||
case DateTime.friday:
|
||||
return 'FRI';
|
||||
case DateTime.saturday:
|
||||
return 'SAT';
|
||||
case DateTime.sunday:
|
||||
return 'SUN';
|
||||
default:
|
||||
return 'SUN';
|
||||
final bool? confirmed = await Modular.to.toCreateOrderReview(
|
||||
arguments: ReviewOrderArguments(
|
||||
orderType: ReviewOrderType.recurring,
|
||||
orderName: state.eventName,
|
||||
hubName: state.selectedHub?.name ?? '',
|
||||
shiftContactName: state.selectedManager?.name ?? '',
|
||||
positions: reviewPositions,
|
||||
totalWorkers: state.totalWorkers,
|
||||
totalCostPerHour: state.totalCostPerHour,
|
||||
estimatedTotal: state.estimatedTotal,
|
||||
scheduleStartDate: DateFormat.yMMMd().format(state.startDate),
|
||||
scheduleEndDate: DateFormat.yMMMd().format(state.endDate),
|
||||
scheduleRepeatDays: state.formattedRepeatDays,
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
bloc.add(const RecurringOrderSubmitted());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../models/review_order_arguments.dart';
|
||||
import '../widgets/review_order/one_time_schedule_section.dart';
|
||||
import '../widgets/review_order/permanent_schedule_section.dart';
|
||||
import '../widgets/review_order/recurring_schedule_section.dart';
|
||||
import '../widgets/review_order/review_order_view.dart';
|
||||
|
||||
/// Review step in the order creation flow.
|
||||
///
|
||||
/// ## Navigation Flow
|
||||
///
|
||||
/// ```
|
||||
/// Form Page (one-time / recurring / permanent)
|
||||
/// -> user taps "Create Order"
|
||||
/// -> navigates here with [ReviewOrderArguments]
|
||||
/// -> user reviews summary
|
||||
/// -> "Post Order" => pops with `true` => form page submits via BLoC
|
||||
/// -> back / "Edit" => pops without result => form page resumes editing
|
||||
/// ```
|
||||
///
|
||||
/// This page is purely presentational. It receives all display data via
|
||||
/// [ReviewOrderArguments] and does not hold any BLoC. The calling form
|
||||
/// page owns the BLoC and only fires the submit event after this page
|
||||
/// confirms.
|
||||
class ReviewOrderPage extends StatelessWidget {
|
||||
/// Creates a [ReviewOrderPage].
|
||||
const ReviewOrderPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Object? rawArgs = Modular.args.data;
|
||||
if (rawArgs is! ReviewOrderArguments) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Text(t.client_create_order.review.invalid_arguments),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final ReviewOrderArguments args = rawArgs;
|
||||
final bool showEdit = args.orderType != ReviewOrderType.oneTime;
|
||||
|
||||
return ReviewOrderView(
|
||||
orderName: args.orderName,
|
||||
hubName: args.hubName,
|
||||
shiftContactName: args.shiftContactName,
|
||||
scheduleSection: _buildScheduleSection(args, showEdit),
|
||||
positions: args.positions,
|
||||
totalWorkers: args.totalWorkers,
|
||||
totalCostPerHour: args.totalCostPerHour,
|
||||
estimatedTotal: args.estimatedTotal,
|
||||
totalLabel: args.totalLabel,
|
||||
showEditButtons: showEdit,
|
||||
onEditBasics: showEdit ? () => Modular.to.popSafe() : null,
|
||||
onEditSchedule: showEdit ? () => Modular.to.popSafe() : null,
|
||||
onEditPositions: showEdit ? () => Modular.to.popSafe() : null,
|
||||
onBack: () => Modular.to.popSafe(),
|
||||
onSubmit: () => Modular.to.popSafe<bool>(true),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the schedule section widget matching the order type.
|
||||
Widget _buildScheduleSection(ReviewOrderArguments args, bool showEdit) {
|
||||
switch (args.orderType) {
|
||||
case ReviewOrderType.oneTime:
|
||||
return OneTimeScheduleSection(
|
||||
date: args.scheduleDate ?? '',
|
||||
time: args.scheduleTime ?? '',
|
||||
duration: args.scheduleDuration ?? '',
|
||||
);
|
||||
case ReviewOrderType.recurring:
|
||||
return RecurringScheduleSection(
|
||||
startDate: args.scheduleStartDate ?? '',
|
||||
endDate: args.scheduleEndDate ?? '',
|
||||
repeatDays: args.scheduleRepeatDays ?? '',
|
||||
onEdit: showEdit ? () => Modular.to.popSafe() : null,
|
||||
);
|
||||
case ReviewOrderType.permanent:
|
||||
return PermanentScheduleSection(
|
||||
startDate: args.scheduleStartDate ?? '',
|
||||
repeatDays: args.scheduleRepeatDays ?? '',
|
||||
onEdit: showEdit ? () => Modular.to.popSafe() : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/// Returns the uppercase three-letter weekday label for [date].
|
||||
///
|
||||
/// Maps `DateTime.weekday` (1=Monday..7=Sunday) to labels like "MON", "TUE".
|
||||
String weekdayLabel(DateTime date) {
|
||||
switch (date.weekday) {
|
||||
case DateTime.monday:
|
||||
return 'MON';
|
||||
case DateTime.tuesday:
|
||||
return 'TUE';
|
||||
case DateTime.wednesday:
|
||||
return 'WED';
|
||||
case DateTime.thursday:
|
||||
return 'THU';
|
||||
case DateTime.friday:
|
||||
return 'FRI';
|
||||
case DateTime.saturday:
|
||||
return 'SAT';
|
||||
case DateTime.sunday:
|
||||
return 'SUN';
|
||||
default:
|
||||
return 'SUN';
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the first date within [startDate]..[endDate] whose weekday matches
|
||||
/// one of the [selectedDays] labels (e.g. "MON", "TUE").
|
||||
///
|
||||
/// Returns [startDate] if no match is found.
|
||||
DateTime firstScheduledShiftDate(
|
||||
DateTime startDate,
|
||||
DateTime endDate,
|
||||
List<String> selectedDays,
|
||||
) {
|
||||
final DateTime start = DateTime(startDate.year, startDate.month, startDate.day);
|
||||
final DateTime end = DateTime(endDate.year, endDate.month, endDate.day);
|
||||
final Set<String> selected = selectedDays.toSet();
|
||||
for (
|
||||
DateTime day = start;
|
||||
!day.isAfter(end);
|
||||
day = day.add(const Duration(days: 1))
|
||||
) {
|
||||
if (selected.contains(weekdayLabel(day))) {
|
||||
return day;
|
||||
}
|
||||
}
|
||||
return start;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Parses a time string in common formats ("6:00 PM", "18:00", "6:00PM").
|
||||
///
|
||||
/// Returns `null` if no format matches.
|
||||
DateTime? parseTime(String time) {
|
||||
for (final String format in <String>['h:mm a', 'HH:mm', 'h:mma']) {
|
||||
try {
|
||||
return DateFormat(format).parse(time.trim());
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Calculates the number of hours between [startTime] and [endTime].
|
||||
///
|
||||
/// Handles overnight shifts (negative difference wraps to 24h).
|
||||
/// Returns `0` if either time string cannot be parsed.
|
||||
double parseHoursFromTimes(String startTime, String endTime) {
|
||||
final DateTime? start = parseTime(startTime);
|
||||
final DateTime? end = parseTime(endTime);
|
||||
if (start == null || end == null) return 0;
|
||||
Duration diff = end.difference(start);
|
||||
if (diff.isNegative) diff += const Duration(hours: 24);
|
||||
return diff.inMinutes / 60;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'review_order_info_row.dart';
|
||||
import 'review_order_section_card.dart';
|
||||
|
||||
/// Schedule section for one-time orders.
|
||||
///
|
||||
/// Displays: Date, Time (start-end), Duration (with break info).
|
||||
class OneTimeScheduleSection extends StatelessWidget {
|
||||
const OneTimeScheduleSection({
|
||||
required this.date,
|
||||
required this.time,
|
||||
required this.duration,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String date;
|
||||
final String time;
|
||||
final String duration;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ReviewOrderSectionCard(
|
||||
title: t.client_create_order.review.schedule,
|
||||
children: <Widget>[
|
||||
ReviewOrderInfoRow(label: t.client_create_order.review.date, value: date),
|
||||
ReviewOrderInfoRow(label: t.client_create_order.review.time, value: time),
|
||||
ReviewOrderInfoRow(label: t.client_create_order.review.duration, value: duration),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'review_order_info_row.dart';
|
||||
import 'review_order_section_card.dart';
|
||||
|
||||
/// Schedule section for permanent orders.
|
||||
///
|
||||
/// Displays: Start Date, Repeat days (no end date).
|
||||
class PermanentScheduleSection extends StatelessWidget {
|
||||
const PermanentScheduleSection({
|
||||
required this.startDate,
|
||||
required this.repeatDays,
|
||||
this.onEdit,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String startDate;
|
||||
final String repeatDays;
|
||||
final VoidCallback? onEdit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ReviewOrderSectionCard(
|
||||
title: t.client_create_order.review.schedule,
|
||||
onEdit: onEdit,
|
||||
children: <Widget>[
|
||||
ReviewOrderInfoRow(label: t.client_create_order.review.start_date, value: startDate),
|
||||
ReviewOrderInfoRow(label: t.client_create_order.review.repeat, value: repeatDays),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'review_order_info_row.dart';
|
||||
import 'review_order_section_card.dart';
|
||||
|
||||
/// Schedule section for recurring orders.
|
||||
///
|
||||
/// Displays: Start Date, End Date, Repeat days.
|
||||
class RecurringScheduleSection extends StatelessWidget {
|
||||
const RecurringScheduleSection({
|
||||
required this.startDate,
|
||||
required this.endDate,
|
||||
required this.repeatDays,
|
||||
this.onEdit,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String startDate;
|
||||
final String endDate;
|
||||
final String repeatDays;
|
||||
final VoidCallback? onEdit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ReviewOrderSectionCard(
|
||||
title: t.client_create_order.review.schedule,
|
||||
onEdit: onEdit,
|
||||
children: <Widget>[
|
||||
ReviewOrderInfoRow(label: t.client_create_order.review.start_date, value: startDate),
|
||||
ReviewOrderInfoRow(label: t.client_create_order.review.end_date, value: endDate),
|
||||
ReviewOrderInfoRow(label: t.client_create_order.review.repeat, value: repeatDays),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Bottom action bar with a back button and primary submit button.
|
||||
///
|
||||
/// The back button is a compact outlined button with a chevron icon.
|
||||
/// The submit button fills the remaining space.
|
||||
class ReviewOrderActionBar extends StatelessWidget {
|
||||
const ReviewOrderActionBar({
|
||||
required this.onBack,
|
||||
required this.onSubmit,
|
||||
this.submitLabel,
|
||||
this.isLoading = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final VoidCallback onBack;
|
||||
final VoidCallback? onSubmit;
|
||||
final String? submitLabel;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: UiConstants.space6,
|
||||
right: UiConstants.space6,
|
||||
top: UiConstants.space3,
|
||||
bottom: UiConstants.space10,
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
UiButton.secondary(
|
||||
leadingIcon: UiIcons.chevronLeft,
|
||||
onPressed: onBack,
|
||||
size: UiButtonSize.large,
|
||||
text: '',
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: UiButton.primary(
|
||||
text: submitLabel ?? t.client_create_order.review.post_order,
|
||||
onPressed: onSubmit,
|
||||
isLoading: isLoading,
|
||||
size: UiButtonSize.large,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'review_order_info_row.dart';
|
||||
import 'review_order_section_card.dart';
|
||||
|
||||
/// Displays the "Basics" section card showing order name, hub, and
|
||||
/// shift contact information.
|
||||
class ReviewOrderBasicsCard extends StatelessWidget {
|
||||
const ReviewOrderBasicsCard({
|
||||
required this.orderName,
|
||||
required this.hubName,
|
||||
required this.shiftContactName,
|
||||
this.onEdit,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String orderName;
|
||||
final String hubName;
|
||||
final String shiftContactName;
|
||||
final VoidCallback? onEdit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ReviewOrderSectionCard(
|
||||
title: t.client_create_order.review.basics,
|
||||
onEdit: onEdit,
|
||||
children: <Widget>[
|
||||
ReviewOrderInfoRow(label: t.client_create_order.review.order_name, value: orderName),
|
||||
ReviewOrderInfoRow(label: t.client_create_order.review.hub, value: hubName),
|
||||
ReviewOrderInfoRow(label: t.client_create_order.review.shift_contact, value: shiftContactName),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A single key-value row used inside review section cards.
|
||||
///
|
||||
/// Displays a label on the left and a value on the right in a
|
||||
/// space-between layout.
|
||||
class ReviewOrderInfoRow extends StatelessWidget {
|
||||
const ReviewOrderInfoRow({
|
||||
required this.label,
|
||||
required this.value,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
value,
|
||||
style: UiTypography.body2m,
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Displays a summary of all positions with a divider and total row.
|
||||
///
|
||||
/// Each position is rendered as a two-line layout:
|
||||
/// - Line 1: role name (left) and worker count with cost/hr (right).
|
||||
/// - Line 2: time range and shift hours (right-aligned, muted style).
|
||||
///
|
||||
/// A divider separates the individual positions from the total.
|
||||
class ReviewOrderPositionsCard extends StatelessWidget {
|
||||
/// Creates a [ReviewOrderPositionsCard].
|
||||
const ReviewOrderPositionsCard({
|
||||
required this.positions,
|
||||
required this.totalWorkers,
|
||||
required this.totalCostPerHour,
|
||||
this.onEdit,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The list of position items to display.
|
||||
final List<ReviewPositionItem> positions;
|
||||
|
||||
/// The total number of workers across all positions.
|
||||
final int totalWorkers;
|
||||
|
||||
/// The combined cost per hour across all positions.
|
||||
final double totalCostPerHour;
|
||||
|
||||
/// Optional callback invoked when the user taps "Edit".
|
||||
final VoidCallback? onEdit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusXl,
|
||||
border: Border.all(color: UiColors.border, width: 0.5),
|
||||
),
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
t.client_create_order.review.positions,
|
||||
style: UiTypography.titleUppercase4b.textSecondary,
|
||||
),
|
||||
if (onEdit != null)
|
||||
GestureDetector(
|
||||
onTap: onEdit,
|
||||
child: Text(
|
||||
t.client_create_order.review.edit,
|
||||
style: UiTypography.body3m.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
...positions.map(_buildPositionItem),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: UiConstants.space3),
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: UiColors.bgSecondary,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: UiConstants.space3),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
t.client_create_order.review.total,
|
||||
style: UiTypography.body2m,
|
||||
),
|
||||
Text(
|
||||
'$totalWorkers workers \u00B7 '
|
||||
'\$${totalCostPerHour.toStringAsFixed(0)}/hr',
|
||||
style: UiTypography.body2b.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a two-line widget for a single position.
|
||||
///
|
||||
/// Line 1 shows the role name on the left and worker count with cost on
|
||||
/// the right. Line 2 shows the time range and shift hours, right-aligned
|
||||
/// in a secondary/muted style.
|
||||
Widget _buildPositionItem(ReviewPositionItem position) {
|
||||
final String formattedHours = position.hours % 1 == 0
|
||||
? position.hours.toInt().toString()
|
||||
: position.hours.toStringAsFixed(1);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: UiConstants.space3),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Flexible(
|
||||
child: Text(
|
||||
position.roleName,
|
||||
style: UiTypography.body2m.textSecondary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${position.workerCount} workers \u00B7 '
|
||||
'\$${position.costPerHour.toStringAsFixed(0)}/hr',
|
||||
style: UiTypography.body2m,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
'${position.startTime} - ${position.endTime} \u00B7 '
|
||||
'$formattedHours '
|
||||
'${t.client_create_order.review.hours_suffix}',
|
||||
style: UiTypography.body3r.textTertiary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single position item for the positions card.
|
||||
///
|
||||
/// Contains the role name, worker count, shift hours, hourly cost,
|
||||
/// and the start/end times for one position in the review summary.
|
||||
class ReviewPositionItem {
|
||||
/// Creates a [ReviewPositionItem].
|
||||
const ReviewPositionItem({
|
||||
required this.roleName,
|
||||
required this.workerCount,
|
||||
required this.costPerHour,
|
||||
required this.hours,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
});
|
||||
|
||||
/// The display name of the role for this position.
|
||||
final String roleName;
|
||||
|
||||
/// The number of workers requested for this position.
|
||||
final int workerCount;
|
||||
|
||||
/// The cost per hour for this role.
|
||||
final double costPerHour;
|
||||
|
||||
/// The number of shift hours (derived from start/end time).
|
||||
final double hours;
|
||||
|
||||
/// The formatted start time of the shift (e.g. "08:00 AM").
|
||||
final String startTime;
|
||||
|
||||
/// The formatted end time of the shift (e.g. "04:00 PM").
|
||||
final String endTime;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A card that groups related review information with a section header.
|
||||
///
|
||||
/// Displays an uppercase section title with an optional "Edit" action
|
||||
/// and a list of child rows.
|
||||
class ReviewOrderSectionCard extends StatelessWidget {
|
||||
const ReviewOrderSectionCard({
|
||||
required this.title,
|
||||
required this.children,
|
||||
this.onEdit,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
final VoidCallback? onEdit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusXl,
|
||||
border: Border.all(color: UiColors.border, width: 0.5),
|
||||
),
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
title.toUpperCase(),
|
||||
style: UiTypography.titleUppercase4b.textSecondary,
|
||||
),
|
||||
if (onEdit != null)
|
||||
GestureDetector(
|
||||
onTap: onEdit,
|
||||
child: Text(t.client_create_order.review.edit, style: UiTypography.body3m.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
...children.map(
|
||||
(Widget child) => Padding(
|
||||
padding: const EdgeInsets.only(top: UiConstants.space3),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A highlighted banner displaying the estimated total cost.
|
||||
///
|
||||
/// Uses the primary inverse background color with a bold price display.
|
||||
/// An optional [label] can override the default "Estimated Total" text.
|
||||
class ReviewOrderTotalBanner extends StatelessWidget {
|
||||
const ReviewOrderTotalBanner({
|
||||
required this.totalAmount,
|
||||
this.label,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The total monetary amount to display.
|
||||
final double totalAmount;
|
||||
|
||||
/// Optional label override. Defaults to the localized "Estimated Total".
|
||||
final String? label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
vertical: UiConstants.space4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primaryInverse,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
label ?? t.client_create_order.review.estimated_total,
|
||||
style: UiTypography.body2m,
|
||||
),
|
||||
Text(
|
||||
'\$${totalAmount.toStringAsFixed(2)}',
|
||||
style: UiTypography.headline3b.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'review_order_action_bar.dart';
|
||||
import 'review_order_basics_card.dart';
|
||||
import 'review_order_positions_card.dart';
|
||||
import 'review_order_total_banner.dart';
|
||||
|
||||
/// The main review order view that displays a summary of the order
|
||||
/// before submission.
|
||||
///
|
||||
/// This is a "dumb" widget that receives all data via constructor parameters
|
||||
/// and exposes callbacks for user interactions. It does NOT interact with
|
||||
/// any BLoC directly.
|
||||
///
|
||||
/// The [scheduleSection] widget is injected to allow different schedule
|
||||
/// layouts per order type (one-time, recurring, permanent).
|
||||
class ReviewOrderView extends StatelessWidget {
|
||||
const ReviewOrderView({
|
||||
required this.orderName,
|
||||
required this.hubName,
|
||||
required this.shiftContactName,
|
||||
required this.scheduleSection,
|
||||
required this.positions,
|
||||
required this.totalWorkers,
|
||||
required this.totalCostPerHour,
|
||||
required this.estimatedTotal,
|
||||
required this.onBack,
|
||||
required this.onSubmit,
|
||||
this.showEditButtons = false,
|
||||
this.onEditBasics,
|
||||
this.onEditSchedule,
|
||||
this.onEditPositions,
|
||||
this.submitLabel,
|
||||
this.totalLabel,
|
||||
this.isLoading = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String orderName;
|
||||
final String hubName;
|
||||
final String shiftContactName;
|
||||
final Widget scheduleSection;
|
||||
final List<ReviewPositionItem> positions;
|
||||
final int totalWorkers;
|
||||
final double totalCostPerHour;
|
||||
final double estimatedTotal;
|
||||
final VoidCallback onBack;
|
||||
final VoidCallback? onSubmit;
|
||||
final bool showEditButtons;
|
||||
final VoidCallback? onEditBasics;
|
||||
final VoidCallback? onEditSchedule;
|
||||
final VoidCallback? onEditPositions;
|
||||
final String? submitLabel;
|
||||
|
||||
/// Optional label override for the total banner. When `null`, the default
|
||||
/// localized "Estimated Total" text is used.
|
||||
final String? totalLabel;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
showBackButton: true,
|
||||
onLeadingPressed: onBack,
|
||||
title: t.client_create_order.review.title,
|
||||
subtitle: t.client_create_order.review.subtitle,
|
||||
),
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space6,
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
ReviewOrderBasicsCard(
|
||||
orderName: orderName,
|
||||
hubName: hubName,
|
||||
shiftContactName: shiftContactName,
|
||||
onEdit: showEditButtons ? onEditBasics : null,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
scheduleSection,
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
ReviewOrderPositionsCard(
|
||||
positions: positions,
|
||||
totalWorkers: totalWorkers,
|
||||
totalCostPerHour: totalCostPerHour,
|
||||
onEdit: showEditButtons ? onEditPositions : null,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
ReviewOrderTotalBanner(
|
||||
totalAmount: estimatedTotal,
|
||||
label: totalLabel,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
ReviewOrderActionBar(
|
||||
onBack: onBack,
|
||||
onSubmit: onSubmit,
|
||||
submitLabel: submitLabel ?? t.client_create_order.review.post_order,
|
||||
isLoading: isLoading,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
// UI Models
|
||||
export 'src/presentation/widgets/order_ui_models.dart';
|
||||
|
||||
// Shared Widgets
|
||||
export 'src/presentation/widgets/order_bottom_action_button.dart';
|
||||
export 'src/presentation/widgets/order_form_skeleton.dart';
|
||||
|
||||
// One Time Order Widgets
|
||||
export 'src/presentation/widgets/one_time_order/one_time_order_date_picker.dart';
|
||||
export 'src/presentation/widgets/one_time_order/one_time_order_event_name_input.dart';
|
||||
export 'src/presentation/widgets/one_time_order/one_time_order_header.dart';
|
||||
export 'src/presentation/widgets/one_time_order/one_time_order_form.dart';
|
||||
export 'src/presentation/widgets/one_time_order/one_time_order_location_input.dart';
|
||||
export 'src/presentation/widgets/one_time_order/one_time_order_position_card.dart';
|
||||
export 'src/presentation/widgets/one_time_order/one_time_order_section_header.dart';
|
||||
@@ -13,8 +17,9 @@ export 'src/presentation/widgets/one_time_order/one_time_order_view.dart';
|
||||
|
||||
// Permanent Order Widgets
|
||||
export 'src/presentation/widgets/permanent_order/permanent_order_date_picker.dart';
|
||||
export 'src/presentation/widgets/permanent_order/permanent_order_days_selector.dart';
|
||||
export 'src/presentation/widgets/permanent_order/permanent_order_event_name_input.dart';
|
||||
export 'src/presentation/widgets/permanent_order/permanent_order_header.dart';
|
||||
export 'src/presentation/widgets/permanent_order/permanent_order_form.dart';
|
||||
export 'src/presentation/widgets/permanent_order/permanent_order_position_card.dart';
|
||||
export 'src/presentation/widgets/permanent_order/permanent_order_section_header.dart';
|
||||
export 'src/presentation/widgets/permanent_order/permanent_order_success_view.dart';
|
||||
@@ -22,8 +27,9 @@ export 'src/presentation/widgets/permanent_order/permanent_order_view.dart';
|
||||
|
||||
// Recurring Order Widgets
|
||||
export 'src/presentation/widgets/recurring_order/recurring_order_date_picker.dart';
|
||||
export 'src/presentation/widgets/recurring_order/recurring_order_days_selector.dart';
|
||||
export 'src/presentation/widgets/recurring_order/recurring_order_event_name_input.dart';
|
||||
export 'src/presentation/widgets/recurring_order/recurring_order_header.dart';
|
||||
export 'src/presentation/widgets/recurring_order/recurring_order_form.dart';
|
||||
export 'src/presentation/widgets/recurring_order/recurring_order_position_card.dart';
|
||||
export 'src/presentation/widgets/recurring_order/recurring_order_section_header.dart';
|
||||
export 'src/presentation/widgets/recurring_order/recurring_order_success_view.dart';
|
||||
|
||||
@@ -32,13 +32,12 @@ class HubManagerSelector extends StatelessWidget {
|
||||
children: <Widget>[
|
||||
Text(
|
||||
label,
|
||||
style: UiTypography.body1m.textPrimary,
|
||||
style: UiTypography.body1r,
|
||||
),
|
||||
if (description != null) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(description!, style: UiTypography.body2r.textSecondary),
|
||||
],
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
InkWell(
|
||||
onTap: () => _showSelector(context),
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
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';
|
||||
|
||||
import '../hub_manager_selector.dart';
|
||||
import '../order_ui_models.dart';
|
||||
import 'one_time_order_date_picker.dart';
|
||||
import 'one_time_order_event_name_input.dart';
|
||||
import 'one_time_order_position_card.dart';
|
||||
import 'one_time_order_section_header.dart';
|
||||
|
||||
/// The scrollable form body for the one-time order creation flow.
|
||||
///
|
||||
/// Displays fields for event name, vendor selection, date, hub, hub manager,
|
||||
/// and a dynamic list of position cards.
|
||||
class OneTimeOrderForm extends StatelessWidget {
|
||||
/// Creates a [OneTimeOrderForm].
|
||||
const OneTimeOrderForm({
|
||||
required this.eventName,
|
||||
required this.selectedVendor,
|
||||
required this.vendors,
|
||||
required this.date,
|
||||
required this.selectedHub,
|
||||
required this.hubs,
|
||||
required this.selectedHubManager,
|
||||
required this.hubManagers,
|
||||
required this.positions,
|
||||
required this.roles,
|
||||
required this.onEventNameChanged,
|
||||
required this.onVendorChanged,
|
||||
required this.onDateChanged,
|
||||
required this.onHubChanged,
|
||||
required this.onHubManagerChanged,
|
||||
required this.onPositionAdded,
|
||||
required this.onPositionUpdated,
|
||||
required this.onPositionRemoved,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The current event name value.
|
||||
final String eventName;
|
||||
|
||||
/// The currently selected vendor, if any.
|
||||
final Vendor? selectedVendor;
|
||||
|
||||
/// The list of available vendors to choose from.
|
||||
final List<Vendor> vendors;
|
||||
|
||||
/// The selected date for the one-time order.
|
||||
final DateTime date;
|
||||
|
||||
/// The currently selected hub, if any.
|
||||
final OrderHubUiModel? selectedHub;
|
||||
|
||||
/// The list of available hubs to choose from.
|
||||
final List<OrderHubUiModel> hubs;
|
||||
|
||||
/// The currently selected hub manager, if any.
|
||||
final OrderManagerUiModel? selectedHubManager;
|
||||
|
||||
/// The list of available hub managers for the selected hub.
|
||||
final List<OrderManagerUiModel> hubManagers;
|
||||
|
||||
/// The list of position entries in the order.
|
||||
final List<OrderPositionUiModel> positions;
|
||||
|
||||
/// The list of available roles for position assignment.
|
||||
final List<OrderRoleUiModel> roles;
|
||||
|
||||
/// Called when the event name text changes.
|
||||
final ValueChanged<String> onEventNameChanged;
|
||||
|
||||
/// Called when a vendor is selected.
|
||||
final ValueChanged<Vendor> onVendorChanged;
|
||||
|
||||
/// Called when the date is changed.
|
||||
final ValueChanged<DateTime> onDateChanged;
|
||||
|
||||
/// Called when a hub is selected.
|
||||
final ValueChanged<OrderHubUiModel> onHubChanged;
|
||||
|
||||
/// Called when a hub manager is selected or cleared.
|
||||
final ValueChanged<OrderManagerUiModel?> onHubManagerChanged;
|
||||
|
||||
/// Called when the user requests adding a new position.
|
||||
final VoidCallback onPositionAdded;
|
||||
|
||||
/// Called when a position at [index] is updated with new values.
|
||||
final void Function(int index, OrderPositionUiModel position)
|
||||
onPositionUpdated;
|
||||
|
||||
/// Called when a position at [index] is removed.
|
||||
final void Function(int index) onPositionRemoved;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientCreateOrderOneTimeEn labels =
|
||||
t.client_create_order.one_time;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
children: <Widget>[
|
||||
OneTimeOrderEventNameInput(
|
||||
label: 'ORDER NAME',
|
||||
value: eventName,
|
||||
onChanged: onEventNameChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Vendor Selection
|
||||
Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<Vendor>(
|
||||
isExpanded: true,
|
||||
value: selectedVendor,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
onChanged: (Vendor? vendor) {
|
||||
if (vendor != null) {
|
||||
onVendorChanged(vendor);
|
||||
}
|
||||
},
|
||||
items: vendors.map((Vendor vendor) {
|
||||
return DropdownMenuItem<Vendor>(
|
||||
value: vendor,
|
||||
child: Text(
|
||||
vendor.name,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
OneTimeOrderDatePicker(
|
||||
label: labels.date_label,
|
||||
value: date,
|
||||
onChanged: onDateChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
Text('HUB', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<OrderHubUiModel>(
|
||||
isExpanded: true,
|
||||
value: selectedHub,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
onChanged: (OrderHubUiModel? hub) {
|
||||
if (hub != null) {
|
||||
onHubChanged(hub);
|
||||
}
|
||||
},
|
||||
items: hubs.map((OrderHubUiModel hub) {
|
||||
return DropdownMenuItem<OrderHubUiModel>(
|
||||
value: hub,
|
||||
child: Text(hub.name, style: UiTypography.body2m.textPrimary),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
HubManagerSelector(
|
||||
label: labels.hub_manager_label,
|
||||
description: labels.hub_manager_desc,
|
||||
hintText: labels.hub_manager_hint,
|
||||
noManagersText: labels.hub_manager_empty,
|
||||
noneText: labels.hub_manager_none,
|
||||
managers: hubManagers,
|
||||
selectedManager: selectedHubManager,
|
||||
onChanged: onHubManagerChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
OneTimeOrderSectionHeader(
|
||||
title: labels.positions_title,
|
||||
actionLabel: labels.add_position,
|
||||
onAction: onPositionAdded,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
|
||||
// Positions List
|
||||
...positions.asMap().entries.map((
|
||||
MapEntry<int, OrderPositionUiModel> entry,
|
||||
) {
|
||||
final int index = entry.key;
|
||||
final OrderPositionUiModel position = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: OneTimeOrderPositionCard(
|
||||
index: index,
|
||||
position: position,
|
||||
isRemovable: positions.length > 1,
|
||||
positionLabel: labels.positions_title,
|
||||
roleLabel: labels.select_role,
|
||||
workersLabel: labels.workers_label,
|
||||
startLabel: labels.start_label,
|
||||
endLabel: labels.end_label,
|
||||
lunchLabel: labels.lunch_break_label,
|
||||
roles: roles,
|
||||
onUpdated: (OrderPositionUiModel updated) {
|
||||
onPositionUpdated(index, updated);
|
||||
},
|
||||
onRemoved: () {
|
||||
onPositionRemoved(index);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A header widget for the one-time order flow with a colored background.
|
||||
class OneTimeOrderHeader extends StatelessWidget {
|
||||
/// Creates a [OneTimeOrderHeader].
|
||||
const OneTimeOrderHeader({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onBack,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The title of the page.
|
||||
final String title;
|
||||
|
||||
/// The subtitle or description.
|
||||
final String subtitle;
|
||||
|
||||
/// Callback when the back button is pressed.
|
||||
final VoidCallback onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + UiConstants.space5,
|
||||
bottom: UiConstants.space5,
|
||||
left: UiConstants.space5,
|
||||
right: UiConstants.space5,
|
||||
),
|
||||
color: UiColors.primary,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
GestureDetector(
|
||||
onTap: onBack,
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white.withValues(alpha: 0.2),
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.chevronLeft,
|
||||
color: UiColors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.headline3m.copyWith(color: UiColors.white),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: UiTypography.footnote2r.copyWith(
|
||||
color: UiColors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,11 @@ 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';
|
||||
|
||||
import '../order_bottom_action_button.dart';
|
||||
import '../order_form_skeleton.dart';
|
||||
import '../order_ui_models.dart';
|
||||
import '../hub_manager_selector.dart';
|
||||
import 'one_time_order_date_picker.dart';
|
||||
import 'one_time_order_event_name_input.dart';
|
||||
import 'one_time_order_header.dart';
|
||||
import 'one_time_order_position_card.dart';
|
||||
import 'one_time_order_section_header.dart';
|
||||
import 'one_time_order_form.dart';
|
||||
import 'one_time_order_success_view.dart';
|
||||
|
||||
/// The main content of the One-Time Order page as a dumb widget.
|
||||
@@ -40,6 +38,7 @@ class OneTimeOrderView extends StatelessWidget {
|
||||
required this.onBack,
|
||||
this.title,
|
||||
this.subtitle,
|
||||
this.isDataLoaded = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -59,6 +58,9 @@ class OneTimeOrderView extends StatelessWidget {
|
||||
final String? title;
|
||||
final String? subtitle;
|
||||
|
||||
/// Whether initial data (vendors, hubs) has been fetched from the backend.
|
||||
final bool isDataLoaded;
|
||||
|
||||
final ValueChanged<String> onEventNameChanged;
|
||||
final ValueChanged<Vendor> onVendorChanged;
|
||||
final ValueChanged<DateTime> onDateChanged;
|
||||
@@ -84,7 +86,12 @@ class OneTimeOrderView extends StatelessWidget {
|
||||
context,
|
||||
message: translateErrorKey(errorMessage!),
|
||||
type: UiSnackbarType.error,
|
||||
margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16),
|
||||
// bottom: 140 clears the bottom navigation bar area
|
||||
margin: const EdgeInsets.only(
|
||||
bottom: 140,
|
||||
left: UiConstants.space4,
|
||||
right: UiConstants.space4,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -98,322 +105,96 @@ class OneTimeOrderView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
showBackButton: true,
|
||||
onLeadingPressed: onBack,
|
||||
title: title ?? labels.title,
|
||||
subtitle: subtitle ?? labels.subtitle,
|
||||
),
|
||||
body: _buildBody(context, labels),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the main body of the One-Time Order page, showing either the form or a loading indicator.
|
||||
Widget _buildBody(
|
||||
BuildContext context,
|
||||
TranslationsClientCreateOrderOneTimeEn labels,
|
||||
) {
|
||||
if (!isDataLoaded) {
|
||||
return const OrderFormSkeleton();
|
||||
}
|
||||
|
||||
if (vendors.isEmpty && status != OrderFormStatus.loading) {
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
OneTimeOrderHeader(
|
||||
title: title ?? labels.title,
|
||||
subtitle: subtitle ?? labels.subtitle,
|
||||
onBack: onBack,
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.search,
|
||||
size: 64,
|
||||
color: UiColors.iconInactive,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Text(
|
||||
'No Vendors Available',
|
||||
style: UiTypography.headline3m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
'There are no staffing vendors associated with your account.',
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.search,
|
||||
size: 64,
|
||||
color: UiColors.iconInactive,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Text(
|
||||
t.client_create_order.no_vendors_title,
|
||||
style: UiTypography.headline3m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
t.client_create_order.no_vendors_description,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
OneTimeOrderHeader(
|
||||
title: title ?? labels.title,
|
||||
subtitle: subtitle ?? labels.subtitle,
|
||||
onBack: onBack,
|
||||
),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
_OneTimeOrderForm(
|
||||
eventName: eventName,
|
||||
selectedVendor: selectedVendor,
|
||||
vendors: vendors,
|
||||
date: date,
|
||||
selectedHub: selectedHub,
|
||||
hubs: hubs,
|
||||
selectedHubManager: selectedHubManager,
|
||||
hubManagers: hubManagers,
|
||||
positions: positions,
|
||||
roles: roles,
|
||||
onEventNameChanged: onEventNameChanged,
|
||||
onVendorChanged: onVendorChanged,
|
||||
onDateChanged: onDateChanged,
|
||||
onHubChanged: onHubChanged,
|
||||
onHubManagerChanged: onHubManagerChanged,
|
||||
onPositionAdded: onPositionAdded,
|
||||
onPositionUpdated: onPositionUpdated,
|
||||
onPositionRemoved: onPositionRemoved,
|
||||
),
|
||||
if (status == OrderFormStatus.loading)
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
],
|
||||
),
|
||||
),
|
||||
_BottomActionButton(
|
||||
label: status == OrderFormStatus.loading
|
||||
? labels.creating
|
||||
: labels.create_order,
|
||||
isLoading: status == OrderFormStatus.loading,
|
||||
onPressed: isValid ? onSubmit : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OneTimeOrderForm extends StatelessWidget {
|
||||
const _OneTimeOrderForm({
|
||||
required this.eventName,
|
||||
required this.selectedVendor,
|
||||
required this.vendors,
|
||||
required this.date,
|
||||
required this.selectedHub,
|
||||
required this.hubs,
|
||||
required this.selectedHubManager,
|
||||
required this.hubManagers,
|
||||
required this.positions,
|
||||
required this.roles,
|
||||
required this.onEventNameChanged,
|
||||
required this.onVendorChanged,
|
||||
required this.onDateChanged,
|
||||
required this.onHubChanged,
|
||||
required this.onHubManagerChanged,
|
||||
required this.onPositionAdded,
|
||||
required this.onPositionUpdated,
|
||||
required this.onPositionRemoved,
|
||||
});
|
||||
|
||||
final String eventName;
|
||||
final Vendor? selectedVendor;
|
||||
final List<Vendor> vendors;
|
||||
final DateTime date;
|
||||
final OrderHubUiModel? selectedHub;
|
||||
final List<OrderHubUiModel> hubs;
|
||||
final OrderManagerUiModel? selectedHubManager;
|
||||
final List<OrderManagerUiModel> hubManagers;
|
||||
final List<OrderPositionUiModel> positions;
|
||||
final List<OrderRoleUiModel> roles;
|
||||
|
||||
final ValueChanged<String> onEventNameChanged;
|
||||
final ValueChanged<Vendor> onVendorChanged;
|
||||
final ValueChanged<DateTime> onDateChanged;
|
||||
final ValueChanged<OrderHubUiModel> onHubChanged;
|
||||
final ValueChanged<OrderManagerUiModel?> onHubManagerChanged;
|
||||
final VoidCallback onPositionAdded;
|
||||
final void Function(int index, OrderPositionUiModel position)
|
||||
onPositionUpdated;
|
||||
final void Function(int index) onPositionRemoved;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientCreateOrderOneTimeEn labels =
|
||||
t.client_create_order.one_time;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
labels.create_your_order,
|
||||
style: UiTypography.headline3m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
OneTimeOrderEventNameInput(
|
||||
label: 'ORDER NAME',
|
||||
value: eventName,
|
||||
onChanged: onEventNameChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Vendor Selection
|
||||
Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<Vendor>(
|
||||
isExpanded: true,
|
||||
value: selectedVendor,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
OneTimeOrderForm(
|
||||
eventName: eventName,
|
||||
selectedVendor: selectedVendor,
|
||||
vendors: vendors,
|
||||
date: date,
|
||||
selectedHub: selectedHub,
|
||||
hubs: hubs,
|
||||
selectedHubManager: selectedHubManager,
|
||||
hubManagers: hubManagers,
|
||||
positions: positions,
|
||||
roles: roles,
|
||||
onEventNameChanged: onEventNameChanged,
|
||||
onVendorChanged: onVendorChanged,
|
||||
onDateChanged: onDateChanged,
|
||||
onHubChanged: onHubChanged,
|
||||
onHubManagerChanged: onHubManagerChanged,
|
||||
onPositionAdded: onPositionAdded,
|
||||
onPositionUpdated: onPositionUpdated,
|
||||
onPositionRemoved: onPositionRemoved,
|
||||
),
|
||||
onChanged: (Vendor? vendor) {
|
||||
if (vendor != null) {
|
||||
onVendorChanged(vendor);
|
||||
}
|
||||
},
|
||||
items: vendors.map((Vendor vendor) {
|
||||
return DropdownMenuItem<Vendor>(
|
||||
value: vendor,
|
||||
child: Text(
|
||||
vendor.name,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
if (status == OrderFormStatus.loading)
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
OneTimeOrderDatePicker(
|
||||
label: labels.date_label,
|
||||
value: date,
|
||||
onChanged: onDateChanged,
|
||||
OrderBottomActionButton(
|
||||
label: status == OrderFormStatus.loading
|
||||
? labels.creating
|
||||
: labels.create_order,
|
||||
isLoading: status == OrderFormStatus.loading,
|
||||
onPressed: isValid ? onSubmit : null,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
Text('HUB', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<OrderHubUiModel>(
|
||||
isExpanded: true,
|
||||
value: selectedHub,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
onChanged: (OrderHubUiModel? hub) {
|
||||
if (hub != null) {
|
||||
onHubChanged(hub);
|
||||
}
|
||||
},
|
||||
items: hubs.map((OrderHubUiModel hub) {
|
||||
return DropdownMenuItem<OrderHubUiModel>(
|
||||
value: hub,
|
||||
child: Text(hub.name, style: UiTypography.body2m.textPrimary),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
HubManagerSelector(
|
||||
label: labels.hub_manager_label,
|
||||
description: labels.hub_manager_desc,
|
||||
hintText: labels.hub_manager_hint,
|
||||
noManagersText: labels.hub_manager_empty,
|
||||
noneText: labels.hub_manager_none,
|
||||
managers: hubManagers,
|
||||
selectedManager: selectedHubManager,
|
||||
onChanged: onHubManagerChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
OneTimeOrderSectionHeader(
|
||||
title: labels.positions_title,
|
||||
actionLabel: labels.add_position,
|
||||
onAction: onPositionAdded,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
|
||||
// Positions List
|
||||
...positions.asMap().entries.map((
|
||||
MapEntry<int, OrderPositionUiModel> entry,
|
||||
) {
|
||||
final int index = entry.key;
|
||||
final OrderPositionUiModel position = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: OneTimeOrderPositionCard(
|
||||
index: index,
|
||||
position: position,
|
||||
isRemovable: positions.length > 1,
|
||||
positionLabel: labels.positions_title,
|
||||
roleLabel: labels.select_role,
|
||||
workersLabel: labels.workers_label,
|
||||
startLabel: labels.start_label,
|
||||
endLabel: labels.end_label,
|
||||
lunchLabel: labels.lunch_break_label,
|
||||
roles: roles,
|
||||
onUpdated: (OrderPositionUiModel updated) {
|
||||
onPositionUpdated(index, updated);
|
||||
},
|
||||
onRemoved: () {
|
||||
onPositionRemoved(index);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BottomActionButton extends StatelessWidget {
|
||||
const _BottomActionButton({
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.isLoading = false,
|
||||
});
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
left: UiConstants.space5,
|
||||
right: UiConstants.space5,
|
||||
top: UiConstants.space5,
|
||||
bottom: MediaQuery.of(context).padding.bottom + UiConstants.space5,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.white,
|
||||
border: Border(top: BorderSide(color: UiColors.border)),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: UiButton.primary(
|
||||
text: label,
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
size: UiButtonSize.large,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A bottom-pinned action button used across all order type views.
|
||||
///
|
||||
/// Renders a full-width primary button with safe-area padding at the bottom
|
||||
/// and a top border separator. Disables the button while [isLoading] is true.
|
||||
class OrderBottomActionButton extends StatelessWidget {
|
||||
/// Creates an [OrderBottomActionButton].
|
||||
const OrderBottomActionButton({
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.isLoading = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The text displayed on the button.
|
||||
final String label;
|
||||
|
||||
/// Callback invoked when the button is pressed. Pass `null` to disable.
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
/// Whether the form is currently submitting. Disables the button when true.
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
left: UiConstants.space5,
|
||||
right: UiConstants.space5,
|
||||
top: UiConstants.space5,
|
||||
bottom: MediaQuery.of(context).padding.bottom + UiConstants.space5,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.white,
|
||||
border: Border(top: BorderSide(color: UiColors.border, width: 0.5)),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: UiButton.primary(
|
||||
text: label,
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
size: UiButtonSize.large,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user