Merge branch 'dev' into feature/session-persistence-new

This commit is contained in:
2026-03-11 10:56:48 +05:30
committed by GitHub
353 changed files with 29419 additions and 3689 deletions

View File

@@ -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';

View File

@@ -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 '';
}
}
}

View File

@@ -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>(),

View File

@@ -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
// ==========================================================================

View File

@@ -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';
}

View File

@@ -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');
}

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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].

View File

@@ -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();

View File

@@ -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';

View File

@@ -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,

View File

@@ -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,
);
}
}

View File

@@ -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),
);
}),
);
}
}

View File

@@ -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,
),
);
}
}

View File

@@ -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

View File

@@ -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:

View File

@@ -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];
}

View File

@@ -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>(),

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -0,0 +1 @@
export 'billing_page_skeleton/index.dart';

View File

@@ -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(),
],
),
),
);
}
}

View File

@@ -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),
],
);
}
}

View File

@@ -0,0 +1,3 @@
export 'billing_page_skeleton.dart';
export 'breakdown_row_skeleton.dart';
export 'invoice_card_skeleton.dart';

View File

@@ -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,
),
],
),
],
),
);
}
}

View File

@@ -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,
),
],
),
],
),
),
);
}),
),
),
);
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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,
),
),
),
);
}
}

View File

@@ -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,
),
),
);
}
}

View File

@@ -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,
),
),
),
);
}
}

View File

@@ -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,
),
),
],
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1 @@
export 'coverage_page_skeleton/index.dart';

View File

@@ -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(),
],
),
),
);
}
}

View File

@@ -0,0 +1,2 @@
export 'coverage_page_skeleton.dart';
export 'shift_card_skeleton.dart';

View File

@@ -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(),
],
),
),
],
),
);
}
}

View File

@@ -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,
),
],
),
);
}
}

View File

@@ -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,
);
},
),
],
),
],
),
);
}
}

View File

@@ -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,
),
),
],
),
);
}
}

View File

@@ -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,
),
),
],
),
],
),
);
}
}

View File

@@ -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),
),
),
],

View File

@@ -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,
),
],
),
);
}
}

View File

@@ -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,
),
),
),
],
),
],
),
);
}
}

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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),
],
),
],
),
),
);
}
}

View File

@@ -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';

View File

@@ -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),
],
),
);
}
}

View File

@@ -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()),
],
),
],
);
}
}

View File

@@ -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),
],
),
);
}
}

View File

@@ -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()),
],
),
],
);
}
}

View File

@@ -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(),
],
);
}
}

View File

@@ -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),
],
),
);
}
}

View File

@@ -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,
),
],
),
);
}
}

View File

@@ -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()),
],
),
),
],
);
}
}

View File

@@ -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),
],
),
),
],
),
);
}
}

View File

@@ -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(),
],
);
}
}

View File

@@ -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 {

View File

@@ -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),
],
),
),
);
}),
),
);
}
}

View File

@@ -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(),
);
}
}

View File

@@ -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();
}

View File

@@ -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,
];
}

View File

@@ -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];
}

View File

@@ -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];
}

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -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,
];
}

View File

@@ -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();

View File

@@ -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,
];
}

View File

@@ -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();

View File

@@ -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,
];
}

View File

@@ -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;
}

View File

@@ -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:

View File

@@ -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());
}
}

View File

@@ -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());
}
}

View File

@@ -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,
);
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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),
],
);
}
}

View File

@@ -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),
],
);
}
}

View File

@@ -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),
],
);
}
}

View File

@@ -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,
),
),
],
),
),
);
}
}

View File

@@ -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),
],
);
}
}

View File

@@ -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,
),
),
],
);
}
}

View File

@@ -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;
}

View File

@@ -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,
),
),
],
),
);
}
}

View File

@@ -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,
),
],
),
);
}
}

View File

@@ -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,
),
],
),
);
}
}

View File

@@ -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';

View File

@@ -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),

View File

@@ -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);
},
),
);
}),
],
);
}
}

View File

@@ -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),
),
),
],
),
],
),
);
}
}

View File

@@ -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,
),
),
);
}
}

View File

@@ -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