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