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:
33
.claude/agent-memory/architecture-reviewer/MEMORY.md
Normal file
33
.claude/agent-memory/architecture-reviewer/MEMORY.md
Normal 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()`)
|
||||
@@ -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>"
|
||||
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
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ val keystoreProperties = Properties().apply {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "dev.krowwithus.client"
|
||||
namespace = "com.krowwithus.client"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.krowwithus.krowwithus_staff
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -210,6 +210,13 @@ extension ClientNavigator on IModularNavigator {
|
||||
safePush(ClientPaths.createOrderPermanent, arguments: arguments);
|
||||
}
|
||||
|
||||
/// Pushes the review order page before submission.
|
||||
///
|
||||
/// Returns `true` if the user confirmed submission, `null` if they went back.
|
||||
Future<bool?> toCreateOrderReview({Object? arguments}) async {
|
||||
return safePush<bool>(ClientPaths.createOrderReview, arguments: arguments);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// VIEW ORDER
|
||||
// ==========================================================================
|
||||
|
||||
@@ -154,4 +154,9 @@ class ClientPaths {
|
||||
///
|
||||
/// Create a long-term or permanent staffing position.
|
||||
static const String createOrderPermanent = '/create-order/permanent';
|
||||
|
||||
/// Review order before submission.
|
||||
///
|
||||
/// Summary page shown before posting any order type.
|
||||
static const String createOrderReview = '/create-order/review';
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Object?> get props => <Object?>[
|
||||
date,
|
||||
|
||||
@@ -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<Object?> get props => <Object?>[
|
||||
startDate,
|
||||
|
||||
@@ -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<Object?> get props => <Object?>[
|
||||
startDate,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<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) {
|
||||
switch (status) {
|
||||
case OneTimeOrderStatus.initial:
|
||||
|
||||
@@ -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<String> permanentDays,
|
||||
) {
|
||||
final DateTime start = DateTime(
|
||||
startDate.year,
|
||||
startDate.month,
|
||||
startDate.day,
|
||||
);
|
||||
final DateTime end = start.add(const Duration(days: 29));
|
||||
final Set<String> selected = permanentDays.toSet();
|
||||
for (
|
||||
DateTime day = start;
|
||||
!day.isAfter(end);
|
||||
day = day.add(const Duration(days: 1))
|
||||
) {
|
||||
if (selected.contains(_weekdayLabel(day))) {
|
||||
return day;
|
||||
}
|
||||
}
|
||||
return start;
|
||||
}
|
||||
/// Builds [ReviewOrderArguments] from the current BLoC state and navigates
|
||||
/// to the review page. Submits the order only if the user confirms.
|
||||
Future<void> _navigateToReview(
|
||||
PermanentOrderState state,
|
||||
PermanentOrderBloc bloc,
|
||||
) async {
|
||||
final List<ReviewPositionItem> reviewPositions = state.positions.map(
|
||||
(PermanentOrderPosition p) => ReviewPositionItem(
|
||||
roleName: state.roleNameById(p.role) ?? p.role,
|
||||
workerCount: p.count,
|
||||
costPerHour: state.roleCostById(p.role),
|
||||
),
|
||||
).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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> recurringDays,
|
||||
) {
|
||||
final DateTime start = DateTime(
|
||||
startDate.year,
|
||||
startDate.month,
|
||||
startDate.day,
|
||||
);
|
||||
final DateTime end = DateTime(endDate.year, endDate.month, endDate.day);
|
||||
final Set<String> selected = recurringDays.toSet();
|
||||
for (
|
||||
DateTime day = start;
|
||||
!day.isAfter(end);
|
||||
day = day.add(const Duration(days: 1))
|
||||
) {
|
||||
if (selected.contains(_weekdayLabel(day))) {
|
||||
return day;
|
||||
}
|
||||
}
|
||||
return start;
|
||||
}
|
||||
/// Builds [ReviewOrderArguments] from the current BLoC state and navigates
|
||||
/// to the review page. Submits the order only if the user confirms.
|
||||
Future<void> _navigateToReview(
|
||||
RecurringOrderState state,
|
||||
RecurringOrderBloc bloc,
|
||||
) async {
|
||||
final List<ReviewPositionItem> reviewPositions = state.positions.map(
|
||||
(RecurringOrderPosition p) => ReviewPositionItem(
|
||||
roleName: state.roleNameById(p.role) ?? p.role,
|
||||
workerCount: p.count,
|
||||
costPerHour: state.roleCostById(p.role),
|
||||
),
|
||||
).toList();
|
||||
|
||||
String _weekdayLabel(DateTime date) {
|
||||
switch (date.weekday) {
|
||||
case DateTime.monday:
|
||||
return 'MON';
|
||||
case DateTime.tuesday:
|
||||
return 'TUE';
|
||||
case DateTime.wednesday:
|
||||
return 'WED';
|
||||
case DateTime.thursday:
|
||||
return 'THU';
|
||||
case DateTime.friday:
|
||||
return 'FRI';
|
||||
case DateTime.saturday:
|
||||
return 'SAT';
|
||||
case DateTime.sunday:
|
||||
return 'SUN';
|
||||
default:
|
||||
return 'SUN';
|
||||
final bool? confirmed = await Modular.to.toCreateOrderReview(
|
||||
arguments: ReviewOrderArguments(
|
||||
orderType: ReviewOrderType.recurring,
|
||||
orderName: state.eventName,
|
||||
hubName: state.selectedHub?.name ?? '',
|
||||
shiftContactName: state.selectedManager?.name ?? '',
|
||||
positions: reviewPositions,
|
||||
totalWorkers: state.totalWorkers,
|
||||
totalCostPerHour: state.totalCostPerHour,
|
||||
estimatedTotal: state.estimatedTotal,
|
||||
scheduleStartDate: DateFormat.yMMMd().format(state.startDate),
|
||||
scheduleEndDate: DateFormat.yMMMd().format(state.endDate),
|
||||
scheduleRepeatDays: state.formattedRepeatDays,
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
bloc.add(const RecurringOrderSubmitted());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/// Returns the uppercase three-letter weekday label for [date].
|
||||
///
|
||||
/// Maps `DateTime.weekday` (1=Monday..7=Sunday) to labels like "MON", "TUE".
|
||||
String weekdayLabel(DateTime date) {
|
||||
switch (date.weekday) {
|
||||
case DateTime.monday:
|
||||
return 'MON';
|
||||
case DateTime.tuesday:
|
||||
return 'TUE';
|
||||
case DateTime.wednesday:
|
||||
return 'WED';
|
||||
case DateTime.thursday:
|
||||
return 'THU';
|
||||
case DateTime.friday:
|
||||
return 'FRI';
|
||||
case DateTime.saturday:
|
||||
return 'SAT';
|
||||
case DateTime.sunday:
|
||||
return 'SUN';
|
||||
default:
|
||||
return 'SUN';
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the first date within [startDate]..[endDate] whose weekday matches
|
||||
/// one of the [selectedDays] labels (e.g. "MON", "TUE").
|
||||
///
|
||||
/// Returns [startDate] if no match is found.
|
||||
DateTime firstScheduledShiftDate(
|
||||
DateTime startDate,
|
||||
DateTime endDate,
|
||||
List<String> selectedDays,
|
||||
) {
|
||||
final DateTime start = DateTime(startDate.year, startDate.month, startDate.day);
|
||||
final DateTime end = DateTime(endDate.year, endDate.month, endDate.day);
|
||||
final Set<String> selected = selectedDays.toSet();
|
||||
for (
|
||||
DateTime day = start;
|
||||
!day.isAfter(end);
|
||||
day = day.add(const Duration(days: 1))
|
||||
) {
|
||||
if (selected.contains(weekdayLabel(day))) {
|
||||
return day;
|
||||
}
|
||||
}
|
||||
return start;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Parses a time string in common formats ("6:00 PM", "18:00", "6:00PM").
|
||||
///
|
||||
/// Returns `null` if no format matches.
|
||||
DateTime? parseTime(String time) {
|
||||
for (final String format in <String>['h:mm a', 'HH:mm', 'h:mma']) {
|
||||
try {
|
||||
return DateFormat(format).parse(time.trim());
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Calculates the number of hours between [startTime] and [endTime].
|
||||
///
|
||||
/// Handles overnight shifts (negative difference wraps to 24h).
|
||||
/// Returns `0` if either time string cannot be parsed.
|
||||
double parseHoursFromTimes(String startTime, String endTime) {
|
||||
final DateTime? start = parseTime(startTime);
|
||||
final DateTime? end = parseTime(endTime);
|
||||
if (start == null || end == null) return 0;
|
||||
Duration diff = end.difference(start);
|
||||
if (diff.isNegative) diff += const Duration(hours: 24);
|
||||
return diff.inMinutes / 60;
|
||||
}
|
||||
@@ -0,0 +1,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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user