From 316a148726251a2c673b51659afd79bf66e3bb76 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 9 Mar 2026 19:49:23 -0400 Subject: [PATCH] feat: Implement review order flow for one-time, recurring, and permanent orders - Added ReviewOrderPage to handle order review before submission. - Created ReviewOrderArguments model to pass data between pages. - Implemented schedule sections for one-time, recurring, and permanent orders. - Enhanced navigation flow to confirm order submission after review. - Refactored order submission logic in OneTimeOrderPage, PermanentOrderPage, and RecurringOrderPage. - Introduced utility functions for time parsing and scheduling. - Created reusable widgets for displaying order information in the review section. - Updated navigation methods to use popSafe for safer back navigation. - Added MainActivity for Android platform integration. --- .../architecture-reviewer/MEMORY.md | 33 +++++ .claude/agents/architecture-reviewer.md | 4 +- .../apps/client/android/app/build.gradle.kts | 2 +- .../client}/MainActivity.kt | 0 .../krowwithus_staff/MainActivity.kt | 5 - .../staff}/MainActivity.kt | 0 .../lib/src/routing/client/navigator.dart | 7 ++ .../lib/src/routing/client/route_paths.dart | 5 + .../lib/src/l10n/en.i18n.json | 3 + .../lib/src/l10n/es.i18n.json | 3 + .../lib/src/create_order_module.dart | 8 ++ .../one_time_order/one_time_order_state.dart | 73 +++++++++++ .../permanent_order_state.dart | 45 +++++++ .../recurring_order_state.dart | 45 +++++++ .../models/review_order_arguments.dart | 48 ++++++++ .../pages/one_time_order_page.dart | 53 +++++++- .../pages/permanent_order_page.dart | 89 +++++++------- .../pages/recurring_order_page.dart | 90 +++++++------- .../presentation/pages/review_order_page.dart | 88 ++++++++++++++ .../presentation/utils/schedule_utils.dart | 47 ++++++++ .../utils/time_parsing_utils.dart | 28 +++++ .../one_time_schedule_section.dart | 31 +++++ .../permanent_schedule_section.dart | 31 +++++ .../recurring_schedule_section.dart | 34 ++++++ .../review_order/review_order_action_bar.dart | 74 ++++++++++++ .../review_order_basics_card.dart | 33 +++++ .../review_order/review_order_header.dart | 33 +++++ .../review_order/review_order_info_row.dart | 40 ++++++ .../review_order_positions_card.dart | 102 ++++++++++++++++ .../review_order_section_card.dart | 59 +++++++++ .../review_order_total_banner.dart | 41 +++++++ .../review_order/review_order_view.dart | 114 ++++++++++++++++++ 32 files changed, 1165 insertions(+), 103 deletions(-) create mode 100644 .claude/agent-memory/architecture-reviewer/MEMORY.md rename apps/mobile/apps/client/android/app/src/main/kotlin/com/{example/krow_client => krowwithus/client}/MainActivity.kt (100%) delete mode 100644 apps/mobile/apps/staff/android/app/src/main/kotlin/com/krowwithus/krowwithus_staff/MainActivity.kt rename apps/mobile/apps/staff/android/app/src/main/kotlin/com/{example/krow_staff => krowwithus/staff}/MainActivity.kt (100%) create mode 100644 apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/models/review_order_arguments.dart create mode 100644 apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/review_order_page.dart create mode 100644 apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/schedule_utils.dart create mode 100644 apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/time_parsing_utils.dart create mode 100644 apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/one_time_schedule_section.dart create mode 100644 apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/permanent_schedule_section.dart create mode 100644 apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/recurring_schedule_section.dart create mode 100644 apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_action_bar.dart create mode 100644 apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_basics_card.dart create mode 100644 apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_header.dart create mode 100644 apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_info_row.dart create mode 100644 apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_positions_card.dart create mode 100644 apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_section_card.dart create mode 100644 apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_total_banner.dart create mode 100644 apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_view.dart diff --git a/.claude/agent-memory/architecture-reviewer/MEMORY.md b/.claude/agent-memory/architecture-reviewer/MEMORY.md new file mode 100644 index 00000000..d23f742e --- /dev/null +++ b/.claude/agent-memory/architecture-reviewer/MEMORY.md @@ -0,0 +1,33 @@ +# Architecture Reviewer Memory + +## Project Structure Confirmed +- Feature packages: `apps/mobile/packages/features///` +- Domain: `apps/mobile/packages/domain/` +- Design system: `apps/mobile/packages/design_system/` +- Core: `apps/mobile/packages/core/` +- Data Connect: `apps/mobile/packages/data_connect/` +- `client_orders_common` is at `apps/mobile/packages/features/client/orders/orders_common/` (shared across order features) + +## BLoC Registration Pattern +- BLoCs registered with `i.add<>()` (transient) per CLAUDE.md -- NOT singletons +- This means `BlocProvider(create:)` is CORRECT (not `BlocProvider.value()`) +- `SafeBloc` mixin exists in core alongside `BlocErrorHandler` + +## Known Pre-existing Issues (create_order feature) +- All 3 order BLoCs make direct `_service.connector` calls for loading vendors, hubs, roles, and managers instead of going through use cases/repositories (CRITICAL per rules, but pre-existing) +- `firebase_data_connect` and `firebase_auth` are listed as direct dependencies in `client_create_order/pubspec.yaml` (should only be in `data_connect` package) +- All 3 order pages use `Modular.to.pop()` instead of `Modular.to.popSafe()` for the back button + +## Design System Tokens +- Colors: `UiColors.*` +- Typography: `UiTypography.*` +- Spacing: `UiConstants.space*` (e.g., `space3`, `space4`, `space6`) +- App bar: `UiAppBar` + +## Review Patterns (grep-based checks) +- `Color(0x` for hardcoded colors +- `TextStyle(` for custom text styles +- `Navigator.` for direct navigator usage +- `import.*features/` for cross-feature imports (must be zero) +- `_service.connector` in BLoC files for direct data connect calls +- `Modular.to.pop()` for unsafe navigation (should be `popSafe()`) diff --git a/.claude/agents/architecture-reviewer.md b/.claude/agents/architecture-reviewer.md index 0205922d..8918f26d 100644 --- a/.claude/agents/architecture-reviewer.md +++ b/.claude/agents/architecture-reviewer.md @@ -1,12 +1,12 @@ --- -name: architecture-reviewer +name: mobile-architecture-reviewer description: "Use this agent when code changes need to be reviewed for Clean Architecture compliance, design system adherence, and established pattern conformance in the KROW Workforce mobile platform. This includes pull request reviews, branch comparisons, or any time new or modified code needs architectural validation.\\n\\nExamples:\\n\\n- Example 1:\\n user: \"Review the changes in the current branch for architecture compliance\"\\n assistant: \"I'll use the Architecture Review Agent to perform a comprehensive architectural review of the current changes.\"\\n \\n The user wants a code review, so use the Agent tool to launch the architecture-reviewer agent to analyze the changes.\\n \\n\\n- Example 2:\\n user: \"I just finished implementing the scheduling feature. Here's the PR.\"\\n assistant: \"Let me use the Architecture Review Agent to review your scheduling feature implementation for Clean Architecture compliance and design system adherence.\"\\n \\n A new feature has been implemented. Use the Agent tool to launch the architecture-reviewer agent to validate the code against architectural rules before it gets merged.\\n \\n\\n- Example 3:\\n user: \"Can you check if my BLoC implementation follows our patterns?\"\\n assistant: \"I'll launch the Architecture Review Agent to validate your BLoC implementation against our established patterns including SessionHandlerMixin, BlocErrorHandler, and singleton registration.\"\\n \\n The user is asking about pattern compliance for a specific component. Use the Agent tool to launch the architecture-reviewer agent to check BLoC patterns.\\n \\n\\n- Example 4 (proactive usage):\\n Context: Another agent or the user has just completed a significant code change to a mobile feature.\\n assistant: \"The feature implementation is complete. Let me now run the Architecture Review Agent to ensure everything complies with our Clean Architecture rules and design system before we proceed.\"\\n \\n Since significant mobile feature code was written, proactively use the Agent tool to launch the architecture-reviewer agent to catch violations early.\\n " model: opus color: green memory: project --- -You are the **Architecture Review Agent**, an elite software architect specializing in Clean Architecture enforcement for the KROW Workforce Flutter mobile platform. You have deep expertise in Flutter/Dart, BLoC state management, Clean Architecture layer separation, and design system governance. You operate with **zero tolerance** for critical and high-severity violations. +You are the **Mobile Architecture Review Agent**, an elite software architect specializing in Clean Architecture enforcement for the KROW Workforce Flutter mobile platform. You have deep expertise in Flutter/Dart, BLoC state management, Clean Architecture layer separation, and design system governance. You operate with **zero tolerance** for critical and high-severity violations. ## Initialization diff --git a/apps/mobile/apps/client/android/app/build.gradle.kts b/apps/mobile/apps/client/android/app/build.gradle.kts index cf4c5b37..837bc911 100644 --- a/apps/mobile/apps/client/android/app/build.gradle.kts +++ b/apps/mobile/apps/client/android/app/build.gradle.kts @@ -41,7 +41,7 @@ val keystoreProperties = Properties().apply { } android { - namespace = "dev.krowwithus.client" + namespace = "com.krowwithus.client" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion diff --git a/apps/mobile/apps/client/android/app/src/main/kotlin/com/example/krow_client/MainActivity.kt b/apps/mobile/apps/client/android/app/src/main/kotlin/com/krowwithus/client/MainActivity.kt similarity index 100% rename from apps/mobile/apps/client/android/app/src/main/kotlin/com/example/krow_client/MainActivity.kt rename to apps/mobile/apps/client/android/app/src/main/kotlin/com/krowwithus/client/MainActivity.kt diff --git a/apps/mobile/apps/staff/android/app/src/main/kotlin/com/krowwithus/krowwithus_staff/MainActivity.kt b/apps/mobile/apps/staff/android/app/src/main/kotlin/com/krowwithus/krowwithus_staff/MainActivity.kt deleted file mode 100644 index 994d7695..00000000 --- a/apps/mobile/apps/staff/android/app/src/main/kotlin/com/krowwithus/krowwithus_staff/MainActivity.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.krowwithus.krowwithus_staff - -import io.flutter.embedding.android.FlutterActivity - -class MainActivity : FlutterActivity() diff --git a/apps/mobile/apps/staff/android/app/src/main/kotlin/com/example/krow_staff/MainActivity.kt b/apps/mobile/apps/staff/android/app/src/main/kotlin/com/krowwithus/staff/MainActivity.kt similarity index 100% rename from apps/mobile/apps/staff/android/app/src/main/kotlin/com/example/krow_staff/MainActivity.kt rename to apps/mobile/apps/staff/android/app/src/main/kotlin/com/krowwithus/staff/MainActivity.kt diff --git a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart index 54746a8d..e767ade7 100644 --- a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart @@ -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 toCreateOrderReview({Object? arguments}) async { + return safePush(ClientPaths.createOrderReview, arguments: arguments); + } + // ========================================================================== // VIEW ORDER // ========================================================================== diff --git a/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart b/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart index 7575229d..a7e7e174 100644 --- a/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart +++ b/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart @@ -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'; } diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 8b597294..a69e7984 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -397,6 +397,9 @@ "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." } }, "client_main": { diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index cb5f4477..f4b30b63 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -397,6 +397,9 @@ "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." } }, "client_main": { diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart index b17c6513..84a33c9a 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart @@ -18,6 +18,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. /// @@ -95,5 +96,12 @@ class ClientCreateOrderModule extends Module { ), child: (BuildContext context) => const PermanentOrderPage(), ); + r.child( + ClientPaths.childRoute( + ClientPaths.createOrder, + ClientPaths.createOrderReview, + ), + child: (BuildContext context) => const ReviewOrderPage(), + ); } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart index c2964f35..96fb40f3 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +import '../../utils/time_parsing_utils.dart'; enum OneTimeOrderStatus { initial, loading, success, failure } @@ -98,6 +99,78 @@ 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 != null && + first.lunchBreak != 'NO_BREAK' && + first.lunchBreak!.isNotEmpty) { + buffer.write(' (${first.lunchBreak} break)'); + } + + return buffer.toString(); + } + @override List get props => [ date, diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart index 4cd04e66..229ff05d 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart @@ -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 } @@ -118,6 +119,50 @@ 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)), + ); + + /// Estimated total cost: sum of (count * costPerHour * hours) per position. + double get estimatedTotal { + 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; + } + + /// Formatted repeat days (e.g. "Mon, Tue, Wed"). + String get formattedRepeatDays => permanentDays.map( + (String day) => day[0] + day.substring(1).toLowerCase(), + ).join(', '); + @override List get props => [ startDate, diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart index 8a22eb64..eaa5d0b4 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +import '../../utils/time_parsing_utils.dart'; enum RecurringOrderStatus { initial, loading, success, failure } @@ -125,6 +126,50 @@ 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)), + ); + + /// Estimated total cost: sum of (count * costPerHour * hours) per position. + double get estimatedTotal { + 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; + } + + /// Formatted repeat days (e.g. "Mon, Tue, Wed"). + String get formattedRepeatDays => recurringDays.map( + (String day) => day[0] + day.substring(1).toLowerCase(), + ).join(', '); + @override List get props => [ startDate, diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/models/review_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/models/review_order_arguments.dart new file mode 100644 index 00000000..f833ca8b --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/models/review_order_arguments.dart @@ -0,0 +1,48 @@ +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, + }); + + final ReviewOrderType orderType; + final String orderName; + final String hubName; + final String shiftContactName; + final List 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; +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart index 1c83311f..2dfe92ef 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart @@ -2,18 +2,24 @@ 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 '../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}); @@ -90,15 +96,50 @@ 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 _navigateToReview( + OneTimeOrderState state, + OneTimeOrderBloc bloc, + ) async { + final List reviewPositions = state.positions.map( + (OneTimeOrderPosition p) => ReviewPositionItem( + roleName: state.roleNameById(p.role) ?? p.role, + workerCount: p.count, + costPerHour: state.roleCostById(p.role), + ), + ).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: diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart index 26109e7a..d1220dd2 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart @@ -2,13 +2,24 @@ 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 '../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}); @@ -89,64 +100,54 @@ 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 permanentDays, - ) { - final DateTime start = DateTime( - startDate.year, - startDate.month, - startDate.day, - ); - final DateTime end = start.add(const Duration(days: 29)); - final Set 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 _navigateToReview( + PermanentOrderState state, + PermanentOrderBloc bloc, + ) async { + final List reviewPositions = state.positions.map( + (PermanentOrderPosition p) => ReviewPositionItem( + roleName: state.roleNameById(p.role) ?? p.role, + workerCount: p.count, + costPerHour: state.roleCostById(p.role), + ), + ).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, + ), + ); + + if (confirmed == true) { + bloc.add(const PermanentOrderSubmitted()); } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart index c65c26a3..c7fe4979 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart @@ -2,13 +2,24 @@ 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 '../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}); @@ -92,7 +103,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 +112,53 @@ 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 recurringDays, - ) { - final DateTime start = DateTime( - startDate.year, - startDate.month, - startDate.day, - ); - final DateTime end = DateTime(endDate.year, endDate.month, endDate.day); - final Set 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 _navigateToReview( + RecurringOrderState state, + RecurringOrderBloc bloc, + ) async { + final List reviewPositions = state.positions.map( + (RecurringOrderPosition p) => ReviewPositionItem( + roleName: state.roleNameById(p.role) ?? p.role, + workerCount: p.count, + costPerHour: state.roleCostById(p.role), + ), + ).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()); } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/review_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/review_order_page.dart new file mode 100644 index 00000000..44500629 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/review_order_page.dart @@ -0,0 +1,88 @@ +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, + 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(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, + ); + } + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/schedule_utils.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/schedule_utils.dart new file mode 100644 index 00000000..4928816c --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/schedule_utils.dart @@ -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 selectedDays, +) { + final DateTime start = DateTime(startDate.year, startDate.month, startDate.day); + final DateTime end = DateTime(endDate.year, endDate.month, endDate.day); + final Set 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; +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/time_parsing_utils.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/time_parsing_utils.dart new file mode 100644 index 00000000..0cf51154 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/time_parsing_utils.dart @@ -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 ['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; +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/one_time_schedule_section.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/one_time_schedule_section.dart new file mode 100644 index 00000000..190b1215 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/one_time_schedule_section.dart @@ -0,0 +1,31 @@ +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: 'Schedule', + children: [ + ReviewOrderInfoRow(label: 'Date', value: date), + ReviewOrderInfoRow(label: 'Time', value: time), + ReviewOrderInfoRow(label: 'Duration', value: duration), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/permanent_schedule_section.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/permanent_schedule_section.dart new file mode 100644 index 00000000..e656bb17 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/permanent_schedule_section.dart @@ -0,0 +1,31 @@ +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: 'Schedule', + onEdit: onEdit, + children: [ + ReviewOrderInfoRow(label: 'Start Date', value: startDate), + ReviewOrderInfoRow(label: 'Repeat', value: repeatDays), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/recurring_schedule_section.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/recurring_schedule_section.dart new file mode 100644 index 00000000..d1bbcab3 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/recurring_schedule_section.dart @@ -0,0 +1,34 @@ +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: 'Schedule', + onEdit: onEdit, + children: [ + ReviewOrderInfoRow(label: 'Start Date', value: startDate), + ReviewOrderInfoRow(label: 'End Date', value: endDate), + ReviewOrderInfoRow(label: 'Repeat', value: repeatDays), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_action_bar.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_action_bar.dart new file mode 100644 index 00000000..12f01da7 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_action_bar.dart @@ -0,0 +1,74 @@ +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 = 'Post Order', + 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: [ + SizedBox( + width: 80, + height: 52, + child: OutlinedButton( + onPressed: onBack, + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: UiConstants.radiusXl, + ), + side: const BorderSide( + color: UiColors.border, + width: 1.5, + ), + ), + child: const Icon( + UiIcons.chevronLeft, + size: UiConstants.iconMd, + color: UiColors.iconPrimary, + ), + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: SizedBox( + height: 52, + child: UiButton.primary( + text: submitLabel, + onPressed: onSubmit, + isLoading: isLoading, + size: UiButtonSize.large, + fullWidth: true, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_basics_card.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_basics_card.dart new file mode 100644 index 00000000..26655b13 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_basics_card.dart @@ -0,0 +1,33 @@ +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: 'Basics', + onEdit: onEdit, + children: [ + ReviewOrderInfoRow(label: 'Order Name', value: orderName), + ReviewOrderInfoRow(label: 'Hub', value: hubName), + ReviewOrderInfoRow(label: 'Shift Contact', value: shiftContactName), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_header.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_header.dart new file mode 100644 index 00000000..75c05c80 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_header.dart @@ -0,0 +1,33 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Displays the "Review & Submit" title and subtitle at the top of the +/// review order page. +class ReviewOrderHeader extends StatelessWidget { + const ReviewOrderHeader({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only( + left: UiConstants.space6, + right: UiConstants.space6, + top: UiConstants.space4, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Review & Submit', + style: UiTypography.headline2m, + ), + const SizedBox(height: UiConstants.space1), + Text( + 'Confirm details before posting', + style: UiTypography.body2r.textSecondary, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_info_row.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_info_row.dart new file mode 100644 index 00000000..9add76e5 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_info_row.dart @@ -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, + children: [ + Flexible( + child: Text( + label, + style: UiTypography.body3r.textSecondary, + ), + ), + const SizedBox(width: UiConstants.space3), + Flexible( + child: Text( + value, + style: UiTypography.body3m, + textAlign: TextAlign.end, + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_positions_card.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_positions_card.dart new file mode 100644 index 00000000..18812630 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_positions_card.dart @@ -0,0 +1,102 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'review_order_info_row.dart'; + +/// Displays a summary of all positions with a divider and total row. +/// +/// Each position shows the role name and "N workers . $X/hr". +/// A divider separates the individual positions from the total. +class ReviewOrderPositionsCard extends StatelessWidget { + const ReviewOrderPositionsCard({ + required this.positions, + required this.totalWorkers, + required this.totalCostPerHour, + this.onEdit, + super.key, + }); + + final List positions; + final int totalWorkers; + final double totalCostPerHour; + final VoidCallback? onEdit; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusXl, + border: Border.all(color: UiColors.border), + ), + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'POSITIONS', + style: UiTypography.titleUppercase4b.textSecondary, + ), + if (onEdit != null) + GestureDetector( + onTap: onEdit, + child: Text( + 'Edit', + style: UiTypography.body3m.primary, + ), + ), + ], + ), + ...positions.map( + (ReviewPositionItem position) => Padding( + padding: const EdgeInsets.only(top: UiConstants.space3), + child: ReviewOrderInfoRow( + label: position.roleName, + value: + '${position.workerCount} workers \u00B7 \$${position.costPerHour.toStringAsFixed(0)}/hr', + ), + ), + ), + 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: [ + Text( + 'Total', + style: UiTypography.body3m, + ), + Text( + '$totalWorkers workers \u00B7 \$${totalCostPerHour.toStringAsFixed(0)}/hr', + style: UiTypography.body3b.primary, + ), + ], + ), + ), + ], + ), + ); + } +} + +/// A single position item for the positions card. +class ReviewPositionItem { + const ReviewPositionItem({ + required this.roleName, + required this.workerCount, + required this.costPerHour, + }); + + final String roleName; + final int workerCount; + final double costPerHour; +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_section_card.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_section_card.dart new file mode 100644 index 00000000..33f8b5e8 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_section_card.dart @@ -0,0 +1,59 @@ +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 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), + ), + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title.toUpperCase(), + style: UiTypography.titleUppercase4b.textSecondary, + ), + if (onEdit != null) + GestureDetector( + onTap: onEdit, + child: Text( + 'Edit', + style: UiTypography.body3m.primary, + ), + ), + ], + ), + ...children.map( + (Widget child) => Padding( + padding: const EdgeInsets.only(top: UiConstants.space3), + child: child, + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_total_banner.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_total_banner.dart new file mode 100644 index 00000000..0b34924b --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_total_banner.dart @@ -0,0 +1,41 @@ +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. +class ReviewOrderTotalBanner extends StatelessWidget { + const ReviewOrderTotalBanner({ + required this.totalAmount, + super.key, + }); + + final double totalAmount; + + @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: [ + Text( + 'Estimated Total', + style: UiTypography.body2m, + ), + Text( + '\$${totalAmount.toStringAsFixed(2)}', + style: UiTypography.headline3b.primary, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_view.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_view.dart new file mode 100644 index 00000000..46fb7453 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_view.dart @@ -0,0 +1,114 @@ +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_header.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 = 'Post Order', + this.isLoading = false, + super.key, + }); + + final String orderName; + final String hubName; + final String shiftContactName; + final Widget scheduleSection; + final List 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; + final bool isLoading; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: UiColors.bgMenu, + appBar: UiAppBar( + showBackButton: true, + onLeadingPressed: onBack, + ), + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ReviewOrderHeader(), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space6, + ), + child: Column( + children: [ + 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), + const SizedBox(height: UiConstants.space4), + ], + ), + ), + ], + ), + ), + ), + ReviewOrderActionBar( + onBack: onBack, + onSubmit: onSubmit, + submitLabel: submitLabel, + isLoading: isLoading, + ), + ], + ), + ); + } +}