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

@@ -41,7 +41,7 @@ val keystoreProperties = Properties().apply {
}
android {
namespace = "dev.krowwithus.client"
namespace = "com.krowwithus.client"
compileSdk = flutter.compileSdkVersion
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);
}
/// Pushes the review order page before submission.
///
/// Returns `true` if the user confirmed submission, `null` if they went back.
Future<bool?> toCreateOrderReview({Object? arguments}) async {
return safePush<bool>(ClientPaths.createOrderReview, arguments: arguments);
}
// ==========================================================================
// VIEW ORDER
// ==========================================================================

View File

@@ -154,4 +154,9 @@ class ClientPaths {
///
/// Create a long-term or permanent staffing position.
static const String createOrderPermanent = '/create-order/permanent';
/// Review order before submission.
///
/// Summary page shown before posting any order type.
static const String createOrderReview = '/create-order/review';
}

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../utils/time_parsing_utils.dart';
enum 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,

View File

@@ -1,5 +1,6 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../utils/time_parsing_utils.dart';
enum PermanentOrderStatus { initial, loading, success, failure }
@@ -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,

View File

@@ -1,5 +1,6 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../utils/time_parsing_utils.dart';
enum 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,

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

View File

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

View File

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

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