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.
This commit is contained in:
Achintha Isuru
2026-03-09 19:49:23 -04:00
parent 972951fd96
commit 316a148726
32 changed files with 1165 additions and 103 deletions

View File

@@ -0,0 +1,33 @@
# Architecture Reviewer Memory
## Project Structure Confirmed
- Feature packages: `apps/mobile/packages/features/<app>/<feature>/`
- 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()`)

View File

@@ -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 <commentary>\\n The user wants a code review, so use the Agent tool to launch the architecture-reviewer agent to analyze the changes.\\n </commentary>\\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 <commentary>\\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 </commentary>\\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 <commentary>\\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 </commentary>\\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 <commentary>\\n Since significant mobile feature code was written, proactively use the Agent tool to launch the architecture-reviewer agent to catch violations early.\\n </commentary>" 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 <commentary>\\n The user wants a code review, so use the Agent tool to launch the architecture-reviewer agent to analyze the changes.\\n </commentary>\\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 <commentary>\\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 </commentary>\\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 <commentary>\\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 </commentary>\\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 <commentary>\\n Since significant mobile feature code was written, proactively use the Agent tool to launch the architecture-reviewer agent to catch violations early.\\n </commentary>"
model: opus model: opus
color: green color: green
memory: project 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 ## Initialization

View File

@@ -41,7 +41,7 @@ val keystoreProperties = Properties().apply {
} }
android { android {
namespace = "dev.krowwithus.client" namespace = "com.krowwithus.client"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion

View File

@@ -1,5 +0,0 @@
package com.krowwithus.krowwithus_staff
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -210,6 +210,13 @@ extension ClientNavigator on IModularNavigator {
safePush(ClientPaths.createOrderPermanent, arguments: arguments); 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 ORDER
// ========================================================================== // ==========================================================================

View File

@@ -154,4 +154,9 @@ class ClientPaths {
/// ///
/// Create a long-term or permanent staffing position. /// Create a long-term or permanent staffing position.
static const String createOrderPermanent = '/create-order/permanent'; 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

@@ -397,6 +397,9 @@
"title": "Permanent Order", "title": "Permanent Order",
"subtitle": "Long-term staffing placement", "subtitle": "Long-term staffing placement",
"placeholder": "Permanent Order Flow (Work in Progress)" "placeholder": "Permanent Order Flow (Work in Progress)"
},
"review": {
"invalid_arguments": "Unable to load order review. Please go back and try again."
} }
}, },
"client_main": { "client_main": {

View File

@@ -397,6 +397,9 @@
"title": "Orden Permanente", "title": "Orden Permanente",
"subtitle": "Colocaci\u00f3n de personal a largo plazo", "subtitle": "Colocaci\u00f3n de personal a largo plazo",
"placeholder": "Flujo de Orden Permanente (Trabajo en Progreso)" "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": { "client_main": {

View File

@@ -18,6 +18,7 @@ import 'presentation/pages/one_time_order_page.dart';
import 'presentation/pages/permanent_order_page.dart'; import 'presentation/pages/permanent_order_page.dart';
import 'presentation/pages/rapid_order_page.dart'; import 'presentation/pages/rapid_order_page.dart';
import 'presentation/pages/recurring_order_page.dart'; import 'presentation/pages/recurring_order_page.dart';
import 'presentation/pages/review_order_page.dart';
/// Module for the Client Create Order feature. /// Module for the Client Create Order feature.
/// ///
@@ -95,5 +96,12 @@ class ClientCreateOrderModule extends Module {
), ),
child: (BuildContext context) => const PermanentOrderPage(), child: (BuildContext context) => const PermanentOrderPage(),
); );
r.child(
ClientPaths.childRoute(
ClientPaths.createOrder,
ClientPaths.createOrderReview,
),
child: (BuildContext context) => const ReviewOrderPage(),
);
} }
} }

View File

@@ -1,5 +1,6 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import '../../utils/time_parsing_utils.dart';
enum OneTimeOrderStatus { initial, loading, success, failure } 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 @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
date, date,

View File

@@ -1,5 +1,6 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import '../../utils/time_parsing_utils.dart';
enum PermanentOrderStatus { initial, loading, success, failure } 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 @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
startDate, startDate,

View File

@@ -1,5 +1,6 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import '../../utils/time_parsing_utils.dart';
enum RecurringOrderStatus { initial, loading, success, failure } 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 @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
startDate, startDate,

View File

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

View File

@@ -2,18 +2,24 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:client_orders_common/client_orders_common.dart'; import 'package:client_orders_common/client_orders_common.dart';
import 'package:intl/intl.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import '../blocs/one_time_order/one_time_order_bloc.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_event.dart';
import '../blocs/one_time_order/one_time_order_state.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. /// 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] /// ## Submission Flow
/// from the common orders package. It follows the KROW Clean Architecture by being ///
/// a [StatelessWidget] and mapping local BLoC state to generic UI models. /// 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 { class OneTimeOrderPage extends StatelessWidget {
/// Creates a [OneTimeOrderPage]. /// Creates a [OneTimeOrderPage].
const OneTimeOrderPage({super.key}); const OneTimeOrderPage({super.key});
@@ -90,15 +96,50 @@ class OneTimeOrderPage extends StatelessWidget {
}, },
onPositionRemoved: (int index) => onPositionRemoved: (int index) =>
bloc.add(OneTimeOrderPositionRemoved(index)), bloc.add(OneTimeOrderPositionRemoved(index)),
onSubmit: () => bloc.add(const OneTimeOrderSubmitted()), onSubmit: () => _navigateToReview(state, bloc),
onDone: () => Modular.to.toOrdersSpecificDate(state.date), 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),
),
).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) { OrderFormStatus _mapStatus(OneTimeOrderStatus status) {
switch (status) { switch (status) {
case OneTimeOrderStatus.initial: case OneTimeOrderStatus.initial:

View File

@@ -2,13 +2,24 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:client_orders_common/client_orders_common.dart'; import 'package:client_orders_common/client_orders_common.dart';
import 'package:intl/intl.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart' hide PermanentOrderPosition; import 'package:krow_domain/krow_domain.dart' hide PermanentOrderPosition;
import '../blocs/permanent_order/permanent_order_bloc.dart'; import '../blocs/permanent_order/permanent_order_bloc.dart';
import '../blocs/permanent_order/permanent_order_event.dart'; import '../blocs/permanent_order/permanent_order_event.dart';
import '../blocs/permanent_order/permanent_order_state.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. /// 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 { class PermanentOrderPage extends StatelessWidget {
/// Creates a [PermanentOrderPage]. /// Creates a [PermanentOrderPage].
const PermanentOrderPage({super.key}); const PermanentOrderPage({super.key});
@@ -89,64 +100,54 @@ class PermanentOrderPage extends StatelessWidget {
}, },
onPositionRemoved: (int index) => onPositionRemoved: (int index) =>
bloc.add(PermanentOrderPositionRemoved(index)), bloc.add(PermanentOrderPositionRemoved(index)),
onSubmit: () => bloc.add(const PermanentOrderSubmitted()), onSubmit: () => _navigateToReview(state, bloc),
onDone: () { onDone: () {
final DateTime initialDate = _firstPermanentShiftDate( final DateTime initialDate = firstScheduledShiftDate(
state.startDate, state.startDate,
state.startDate.add(const Duration(days: 29)),
state.permanentDays, state.permanentDays,
); );
// Navigate to orders page with the initial date set to the first recurring shift date
Modular.to.toOrdersSpecificDate(initialDate); Modular.to.toOrdersSpecificDate(initialDate);
}, },
onBack: () => Modular.to.pop(), onBack: () => Modular.to.popSafe(),
); );
}, },
), ),
); );
} }
DateTime _firstPermanentShiftDate( /// Builds [ReviewOrderArguments] from the current BLoC state and navigates
DateTime startDate, /// to the review page. Submits the order only if the user confirms.
List<String> permanentDays, Future<void> _navigateToReview(
) { PermanentOrderState state,
final DateTime start = DateTime( PermanentOrderBloc bloc,
startDate.year, ) async {
startDate.month, final List<ReviewPositionItem> reviewPositions = state.positions.map(
startDate.day, (PermanentOrderPosition p) => ReviewPositionItem(
); roleName: state.roleNameById(p.role) ?? p.role,
final DateTime end = start.add(const Duration(days: 29)); workerCount: p.count,
final Set<String> selected = permanentDays.toSet(); costPerHour: state.roleCostById(p.role),
for ( ),
DateTime day = start; ).toList();
!day.isAfter(end);
day = day.add(const Duration(days: 1))
) {
if (selected.contains(_weekdayLabel(day))) {
return day;
}
}
return start;
}
String _weekdayLabel(DateTime date) { final bool? confirmed = await Modular.to.toCreateOrderReview(
switch (date.weekday) { arguments: ReviewOrderArguments(
case DateTime.monday: orderType: ReviewOrderType.permanent,
return 'MON'; orderName: state.eventName,
case DateTime.tuesday: hubName: state.selectedHub?.name ?? '',
return 'TUE'; shiftContactName: state.selectedManager?.name ?? '',
case DateTime.wednesday: positions: reviewPositions,
return 'WED'; totalWorkers: state.totalWorkers,
case DateTime.thursday: totalCostPerHour: state.totalCostPerHour,
return 'THU'; estimatedTotal: state.estimatedTotal,
case DateTime.friday: scheduleStartDate: DateFormat.yMMMd().format(state.startDate),
return 'FRI'; scheduleRepeatDays: state.formattedRepeatDays,
case DateTime.saturday: ),
return 'SAT'; );
case DateTime.sunday:
return 'SUN'; if (confirmed == true) {
default: bloc.add(const PermanentOrderSubmitted());
return 'SUN';
} }
} }

View File

@@ -2,13 +2,24 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:client_orders_common/client_orders_common.dart'; import 'package:client_orders_common/client_orders_common.dart';
import 'package:intl/intl.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart' hide RecurringOrderPosition; import 'package:krow_domain/krow_domain.dart' hide RecurringOrderPosition;
import '../blocs/recurring_order/recurring_order_bloc.dart'; import '../blocs/recurring_order/recurring_order_bloc.dart';
import '../blocs/recurring_order/recurring_order_event.dart'; import '../blocs/recurring_order/recurring_order_event.dart';
import '../blocs/recurring_order/recurring_order_state.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. /// 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 { class RecurringOrderPage extends StatelessWidget {
/// Creates a [RecurringOrderPage]. /// Creates a [RecurringOrderPage].
const RecurringOrderPage({super.key}); const RecurringOrderPage({super.key});
@@ -92,7 +103,7 @@ class RecurringOrderPage extends StatelessWidget {
}, },
onPositionRemoved: (int index) => onPositionRemoved: (int index) =>
bloc.add(RecurringOrderPositionRemoved(index)), bloc.add(RecurringOrderPositionRemoved(index)),
onSubmit: () => bloc.add(const RecurringOrderSubmitted()), onSubmit: () => _navigateToReview(state, bloc),
onDone: () { onDone: () {
final DateTime maxEndDate = state.startDate.add( final DateTime maxEndDate = state.startDate.add(
const Duration(days: 29), const Duration(days: 29),
@@ -101,64 +112,53 @@ class RecurringOrderPage extends StatelessWidget {
state.endDate.isAfter(maxEndDate) state.endDate.isAfter(maxEndDate)
? maxEndDate ? maxEndDate
: state.endDate; : state.endDate;
final DateTime initialDate = _firstRecurringShiftDate( final DateTime initialDate = firstScheduledShiftDate(
state.startDate, state.startDate,
effectiveEndDate, effectiveEndDate,
state.recurringDays, state.recurringDays,
); );
// Navigate to orders page with the initial date set to the first recurring shift date
Modular.to.toOrdersSpecificDate(initialDate); Modular.to.toOrdersSpecificDate(initialDate);
}, },
onBack: () => Modular.to.pop(), onBack: () => Modular.to.popSafe(),
); );
}, },
), ),
); );
} }
DateTime _firstRecurringShiftDate( /// Builds [ReviewOrderArguments] from the current BLoC state and navigates
DateTime startDate, /// to the review page. Submits the order only if the user confirms.
DateTime endDate, Future<void> _navigateToReview(
List<String> recurringDays, RecurringOrderState state,
) { RecurringOrderBloc bloc,
final DateTime start = DateTime( ) async {
startDate.year, final List<ReviewPositionItem> reviewPositions = state.positions.map(
startDate.month, (RecurringOrderPosition p) => ReviewPositionItem(
startDate.day, roleName: state.roleNameById(p.role) ?? p.role,
); workerCount: p.count,
final DateTime end = DateTime(endDate.year, endDate.month, endDate.day); costPerHour: state.roleCostById(p.role),
final Set<String> selected = recurringDays.toSet(); ),
for ( ).toList();
DateTime day = start;
!day.isAfter(end);
day = day.add(const Duration(days: 1))
) {
if (selected.contains(_weekdayLabel(day))) {
return day;
}
}
return start;
}
String _weekdayLabel(DateTime date) { final bool? confirmed = await Modular.to.toCreateOrderReview(
switch (date.weekday) { arguments: ReviewOrderArguments(
case DateTime.monday: orderType: ReviewOrderType.recurring,
return 'MON'; orderName: state.eventName,
case DateTime.tuesday: hubName: state.selectedHub?.name ?? '',
return 'TUE'; shiftContactName: state.selectedManager?.name ?? '',
case DateTime.wednesday: positions: reviewPositions,
return 'WED'; totalWorkers: state.totalWorkers,
case DateTime.thursday: totalCostPerHour: state.totalCostPerHour,
return 'THU'; estimatedTotal: state.estimatedTotal,
case DateTime.friday: scheduleStartDate: DateFormat.yMMMd().format(state.startDate),
return 'FRI'; scheduleEndDate: DateFormat.yMMMd().format(state.endDate),
case DateTime.saturday: scheduleRepeatDays: state.formattedRepeatDays,
return 'SAT'; ),
case DateTime.sunday: );
return 'SUN';
default: if (confirmed == true) {
return 'SUN'; bloc.add(const RecurringOrderSubmitted());
} }
} }

View File

@@ -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<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,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: <Widget>[
ReviewOrderInfoRow(label: 'Date', value: date),
ReviewOrderInfoRow(label: 'Time', value: time),
ReviewOrderInfoRow(label: 'Duration', value: duration),
],
);
}
}

View File

@@ -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: <Widget>[
ReviewOrderInfoRow(label: 'Start Date', value: startDate),
ReviewOrderInfoRow(label: 'Repeat', value: repeatDays),
],
);
}
}

View File

@@ -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: <Widget>[
ReviewOrderInfoRow(label: 'Start Date', value: startDate),
ReviewOrderInfoRow(label: 'End Date', value: endDate),
ReviewOrderInfoRow(label: 'Repeat', value: repeatDays),
],
);
}
}

View File

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

View File

@@ -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: <Widget>[
ReviewOrderInfoRow(label: 'Order Name', value: orderName),
ReviewOrderInfoRow(label: 'Hub', value: hubName),
ReviewOrderInfoRow(label: 'Shift Contact', value: shiftContactName),
],
);
}
}

View File

@@ -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: <Widget>[
Text(
'Review & Submit',
style: UiTypography.headline2m,
),
const SizedBox(height: UiConstants.space1),
Text(
'Confirm details before posting',
style: UiTypography.body2r.textSecondary,
),
],
),
);
}
}

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,
children: <Widget>[
Flexible(
child: Text(
label,
style: UiTypography.body3r.textSecondary,
),
),
const SizedBox(width: UiConstants.space3),
Flexible(
child: Text(
value,
style: UiTypography.body3m,
textAlign: TextAlign.end,
),
),
],
);
}
}

View File

@@ -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<ReviewPositionItem> 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: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
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: <Widget>[
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;
}

View File

@@ -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<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),
),
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(
'Edit',
style: UiTypography.body3m.primary,
),
),
],
),
...children.map(
(Widget child) => Padding(
padding: const EdgeInsets.only(top: UiConstants.space3),
child: child,
),
),
],
),
);
}
}

View File

@@ -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: <Widget>[
Text(
'Estimated Total',
style: UiTypography.body2m,
),
Text(
'\$${totalAmount.toStringAsFixed(2)}',
style: UiTypography.headline3b.primary,
),
],
),
);
}
}

View File

@@ -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<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;
final bool isLoading;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: UiColors.bgMenu,
appBar: UiAppBar(
showBackButton: true,
onLeadingPressed: onBack,
),
body: Column(
children: <Widget>[
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const ReviewOrderHeader(),
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),
const SizedBox(height: UiConstants.space4),
],
),
),
],
),
),
),
ReviewOrderActionBar(
onBack: onBack,
onSubmit: onSubmit,
submitLabel: submitLabel,
isLoading: isLoading,
),
],
),
);
}
}