feat: Add recurring order form components including date picker, event name input, header, position card, section header, success view, and main view logic

- Implemented RecurringOrderDatePicker for selecting start and end dates.
- Created RecurringOrderEventNameInput for entering the order name.
- Developed RecurringOrderHeader for displaying the title and subtitle with a back button.
- Added RecurringOrderPositionCard for editing individual positions in the order.
- Introduced RecurringOrderSectionHeader for section titles with optional action buttons.
- Built RecurringOrderSuccessView to show a success message after order creation.
- Integrated all components into RecurringOrderView to manage the overall order creation flow.
This commit is contained in:
Achintha Isuru
2026-02-21 19:25:07 -05:00
parent 376bb51647
commit 0dc56d56ca
24 changed files with 3566 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
// UI Models
export 'src/presentation/widgets/order_ui_models.dart';
// One Time Order Widgets
export 'src/presentation/widgets/one_time_order/one_time_order_date_picker.dart';
export 'src/presentation/widgets/one_time_order/one_time_order_event_name_input.dart';
export 'src/presentation/widgets/one_time_order/one_time_order_header.dart';
export 'src/presentation/widgets/one_time_order/one_time_order_location_input.dart';
export 'src/presentation/widgets/one_time_order/one_time_order_position_card.dart';
export 'src/presentation/widgets/one_time_order/one_time_order_section_header.dart';
export 'src/presentation/widgets/one_time_order/one_time_order_success_view.dart';
export 'src/presentation/widgets/one_time_order/one_time_order_view.dart';
// Permanent Order Widgets
export 'src/presentation/widgets/permanent_order/permanent_order_date_picker.dart';
export 'src/presentation/widgets/permanent_order/permanent_order_event_name_input.dart';
export 'src/presentation/widgets/permanent_order/permanent_order_header.dart';
export 'src/presentation/widgets/permanent_order/permanent_order_position_card.dart';
export 'src/presentation/widgets/permanent_order/permanent_order_section_header.dart';
export 'src/presentation/widgets/permanent_order/permanent_order_success_view.dart';
export 'src/presentation/widgets/permanent_order/permanent_order_view.dart';
// Recurring Order Widgets
export 'src/presentation/widgets/recurring_order/recurring_order_date_picker.dart';
export 'src/presentation/widgets/recurring_order/recurring_order_event_name_input.dart';
export 'src/presentation/widgets/recurring_order/recurring_order_header.dart';
export 'src/presentation/widgets/recurring_order/recurring_order_position_card.dart';
export 'src/presentation/widgets/recurring_order/recurring_order_section_header.dart';
export 'src/presentation/widgets/recurring_order/recurring_order_success_view.dart';
export 'src/presentation/widgets/recurring_order/recurring_order_view.dart';

View File

@@ -0,0 +1,74 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
/// A date picker field for the one-time order form.
/// Matches the prototype input field style.
class OneTimeOrderDatePicker extends StatefulWidget {
/// Creates a [OneTimeOrderDatePicker].
const OneTimeOrderDatePicker({
required this.label,
required this.value,
required this.onChanged,
super.key,
});
/// The label text to display above the field.
final String label;
/// The currently selected date.
final DateTime value;
/// Callback when a new date is selected.
final ValueChanged<DateTime> onChanged;
@override
State<OneTimeOrderDatePicker> createState() => _OneTimeOrderDatePickerState();
}
class _OneTimeOrderDatePickerState extends State<OneTimeOrderDatePicker> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(
text: DateFormat('yyyy-MM-dd').format(widget.value),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
void didUpdateWidget(OneTimeOrderDatePicker oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != oldWidget.value) {
_controller.text = DateFormat('yyyy-MM-dd').format(widget.value);
}
}
@override
Widget build(BuildContext context) {
return UiTextField(
label: widget.label,
controller: _controller,
readOnly: true,
prefixIcon: UiIcons.calendar,
onTap: () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: widget.value,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (picked != null) {
widget.onChanged(picked);
}
},
);
}
}

View File

@@ -0,0 +1,56 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A text input for the order name in the one-time order form.
class OneTimeOrderEventNameInput extends StatefulWidget {
const OneTimeOrderEventNameInput({
required this.label,
required this.value,
required this.onChanged,
super.key,
});
final String label;
final String value;
final ValueChanged<String> onChanged;
@override
State<OneTimeOrderEventNameInput> createState() =>
_OneTimeOrderEventNameInputState();
}
class _OneTimeOrderEventNameInputState
extends State<OneTimeOrderEventNameInput> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.value);
}
@override
void didUpdateWidget(OneTimeOrderEventNameInput oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != _controller.text) {
_controller.text = widget.value;
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return UiTextField(
label: widget.label,
controller: _controller,
onChanged: widget.onChanged,
hintText: 'Order name',
prefixIcon: UiIcons.briefcase,
);
}
}

View File

@@ -0,0 +1,71 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A header widget for the one-time order flow with a colored background.
class OneTimeOrderHeader extends StatelessWidget {
/// Creates a [OneTimeOrderHeader].
const OneTimeOrderHeader({
required this.title,
required this.subtitle,
required this.onBack,
super.key,
});
/// The title of the page.
final String title;
/// The subtitle or description.
final String subtitle;
/// Callback when the back button is pressed.
final VoidCallback onBack;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + UiConstants.space5,
bottom: UiConstants.space5,
left: UiConstants.space5,
right: UiConstants.space5,
),
color: UiColors.primary,
child: Row(
children: <Widget>[
GestureDetector(
onTap: onBack,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: UiColors.white.withValues(alpha: 0.2),
borderRadius: UiConstants.radiusMd,
),
child: const Icon(
UiIcons.chevronLeft,
color: UiColors.white,
size: 24,
),
),
),
const SizedBox(width: UiConstants.space3),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
title,
style: UiTypography.headline3m.copyWith(color: UiColors.white),
),
Text(
subtitle,
style: UiTypography.footnote2r.copyWith(
color: UiColors.white.withValues(alpha: 0.8),
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,62 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A location input field for the one-time order form.
/// Matches the prototype input field style.
class OneTimeOrderLocationInput extends StatefulWidget {
/// Creates a [OneTimeOrderLocationInput].
const OneTimeOrderLocationInput({
required this.label,
required this.value,
required this.onChanged,
super.key,
});
/// The label text to display above the field.
final String label;
/// The current location value.
final String value;
/// Callback when the location value changes.
final ValueChanged<String> onChanged;
@override
State<OneTimeOrderLocationInput> createState() =>
_OneTimeOrderLocationInputState();
}
class _OneTimeOrderLocationInputState extends State<OneTimeOrderLocationInput> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.value);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
void didUpdateWidget(OneTimeOrderLocationInput oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != _controller.text) {
_controller.text = widget.value;
}
}
@override
Widget build(BuildContext context) {
return UiTextField(
label: widget.label,
controller: _controller,
onChanged: widget.onChanged,
hintText: 'Enter address',
prefixIcon: UiIcons.mapPin,
);
}
}

View File

@@ -0,0 +1,322 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../order_ui_models.dart';
/// A card widget for editing a specific position in a one-time order.
class OneTimeOrderPositionCard extends StatelessWidget {
const OneTimeOrderPositionCard({
required this.index,
required this.position,
required this.isRemovable,
required this.onUpdated,
required this.onRemoved,
required this.positionLabel,
required this.roleLabel,
required this.workersLabel,
required this.startLabel,
required this.endLabel,
required this.lunchLabel,
required this.roles,
super.key,
});
final int index;
final OrderPositionUiModel position;
final bool isRemovable;
final ValueChanged<OrderPositionUiModel> onUpdated;
final VoidCallback onRemoved;
final String positionLabel;
final String roleLabel;
final String workersLabel;
final String startLabel;
final String endLabel;
final String lunchLabel;
final List<OrderRoleUiModel> roles;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
'$positionLabel #${index + 1}',
style: UiTypography.footnote1m.textSecondary,
),
if (isRemovable)
GestureDetector(
onTap: onRemoved,
child: Text(
t.client_create_order.one_time.remove,
style: UiTypography.footnote1m.copyWith(
color: UiColors.destructive,
),
),
),
],
),
const SizedBox(height: UiConstants.space3),
// Role (Dropdown)
Container(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
height: 44,
decoration: BoxDecoration(
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
isExpanded: true,
hint: Text(
roleLabel,
style: UiTypography.body2r.textPlaceholder,
),
value: position.role.isEmpty ? null : position.role,
icon: const Icon(
UiIcons.chevronDown,
size: 18,
color: UiColors.iconSecondary,
),
onChanged: (String? val) {
if (val != null) {
onUpdated(position.copyWith(role: val));
}
},
items: _buildRoleItems(),
),
),
),
const SizedBox(height: UiConstants.space3),
// Start/End/Workers Row
Row(
children: <Widget>[
// Start Time
Expanded(
child: _buildTimeInput(
context: context,
label: startLabel,
value: position.startTime,
onTap: () async {
final TimeOfDay? picked = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
);
if (picked != null && context.mounted) {
onUpdated(
position.copyWith(startTime: picked.format(context)),
);
}
},
),
),
const SizedBox(width: UiConstants.space2),
// End Time
Expanded(
child: _buildTimeInput(
context: context,
label: endLabel,
value: position.endTime,
onTap: () async {
final TimeOfDay? picked = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
);
if (picked != null && context.mounted) {
onUpdated(
position.copyWith(endTime: picked.format(context)),
);
}
},
),
),
const SizedBox(width: UiConstants.space2),
// Workers Count
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
workersLabel,
style: UiTypography.footnote2r.textSecondary,
),
const SizedBox(height: UiConstants.space1),
Container(
height: 40,
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: UiConstants.radiusSm,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
GestureDetector(
onTap: () {
if (position.count > 1) {
onUpdated(
position.copyWith(count: position.count - 1),
);
}
},
child: const Icon(UiIcons.minus, size: 12),
),
Text(
'${position.count}',
style: UiTypography.body2b.textPrimary,
),
GestureDetector(
onTap: () {
onUpdated(
position.copyWith(count: position.count + 1),
);
},
child: const Icon(UiIcons.add, size: 12),
),
],
),
),
],
),
),
],
),
const SizedBox(height: UiConstants.space4),
// Lunch Break
Text(lunchLabel, style: UiTypography.footnote2r.textSecondary),
const SizedBox(height: UiConstants.space1),
Container(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
height: 44,
decoration: BoxDecoration(
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
isExpanded: true,
value: position.lunchBreak,
icon: const Icon(
UiIcons.chevronDown,
size: 18,
color: UiColors.iconSecondary,
),
onChanged: (String? val) {
if (val != null) {
onUpdated(position.copyWith(lunchBreak: val));
}
},
items: <String>[
'NO_BREAK',
'MIN_10',
'MIN_15',
'MIN_30',
'MIN_45',
'MIN_60',
].map((String value) {
final String label = switch (value) {
'NO_BREAK' => 'No Break',
'MIN_10' => '10 min (Paid)',
'MIN_15' => '15 min (Paid)',
'MIN_30' => '30 min (Unpaid)',
'MIN_45' => '45 min (Unpaid)',
'MIN_60' => '60 min (Unpaid)',
_ => value,
};
return DropdownMenuItem<String>(
value: value,
child: Text(
label,
style: UiTypography.body2r.textPrimary,
),
);
}).toList(),
),
),
),
],
),
);
}
Widget _buildTimeInput({
required BuildContext context,
required String label,
required String value,
required VoidCallback onTap,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(label, style: UiTypography.footnote2r.textSecondary),
const SizedBox(height: UiConstants.space1),
GestureDetector(
onTap: onTap,
child: Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
decoration: BoxDecoration(
borderRadius: UiConstants.radiusSm,
border: Border.all(color: UiColors.border),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
value.isEmpty ? '--:--' : value,
style: UiTypography.body2r.textPrimary,
),
const Icon(
UiIcons.clock,
size: 14,
color: UiColors.iconSecondary,
),
],
),
),
),
],
);
}
List<DropdownMenuItem<String>> _buildRoleItems() {
final List<DropdownMenuItem<String>> items = roles
.map(
(OrderRoleUiModel role) => DropdownMenuItem<String>(
value: role.id,
child: Text(
'${role.name} - \$${role.costPerHour.toStringAsFixed(0)}',
style: UiTypography.body2r.textPrimary,
),
),
)
.toList();
final bool hasSelected =
roles.any((OrderRoleUiModel role) => role.id == position.role);
if (position.role.isNotEmpty && !hasSelected) {
items.add(
DropdownMenuItem<String>(
value: position.role,
child: Text(
position.role,
style: UiTypography.body2r.textPrimary,
),
),
);
}
return items;
}
}

View File

@@ -0,0 +1,52 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A header widget for sections in the one-time order form.
class OneTimeOrderSectionHeader extends StatelessWidget {
/// Creates a [OneTimeOrderSectionHeader].
const OneTimeOrderSectionHeader({
required this.title,
this.actionLabel,
this.onAction,
super.key,
});
/// The title text for the section.
final String title;
/// Optional label for an action button on the right.
final String? actionLabel;
/// Callback when the action button is tapped.
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(title, style: UiTypography.headline4m.textPrimary),
if (actionLabel != null && onAction != null)
TextButton(
onPressed: onAction,
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const Icon(UiIcons.add, size: 16, color: UiColors.primary),
const SizedBox(width: UiConstants.space2),
Text(
actionLabel!,
style: UiTypography.body2m.primary,
),
],
),
),
],
);
}
}

View File

@@ -0,0 +1,107 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A view to display when a one-time order has been successfully created.
/// Matches the prototype success view layout with a gradient background and centered card.
class OneTimeOrderSuccessView extends StatelessWidget {
/// Creates a [OneTimeOrderSuccessView].
const OneTimeOrderSuccessView({
required this.title,
required this.message,
required this.buttonLabel,
required this.onDone,
super.key,
});
/// The title of the success message.
final String title;
/// The body of the success message.
final String message;
/// Label for the completion button.
final String buttonLabel;
/// Callback when the completion button is tapped.
final VoidCallback onDone;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
width: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[UiColors.primary, UiColors.buttonPrimaryHover],
),
),
child: SafeArea(
child: Center(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: UiConstants.space10),
padding: const EdgeInsets.all(UiConstants.space8),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg * 1.5,
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.2),
blurRadius: 20,
offset: const Offset(0, UiConstants.space2 + 2),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
width: UiConstants.space16,
height: UiConstants.space16,
decoration: const BoxDecoration(
color: UiColors.accent,
shape: BoxShape.circle,
),
child: const Center(
child: Icon(
UiIcons.check,
color: UiColors.black,
size: UiConstants.space8,
),
),
),
const SizedBox(height: UiConstants.space6),
Text(
title,
style: UiTypography.headline2m.textPrimary,
textAlign: TextAlign.center,
),
const SizedBox(height: UiConstants.space3),
Text(
message,
textAlign: TextAlign.center,
style: UiTypography.body2r.textSecondary.copyWith(
height: 1.5,
),
),
const SizedBox(height: UiConstants.space8),
SizedBox(
width: double.infinity,
child: UiButton.primary(
text: buttonLabel,
onPressed: onDone,
size: UiButtonSize.large,
),
),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,388 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
import '../order_ui_models.dart';
import 'one_time_order_date_picker.dart';
import 'one_time_order_event_name_input.dart';
import 'one_time_order_header.dart';
import 'one_time_order_position_card.dart';
import 'one_time_order_section_header.dart';
import 'one_time_order_success_view.dart';
/// The main content of the One-Time Order page as a dumb widget.
class OneTimeOrderView extends StatelessWidget {
const OneTimeOrderView({
required this.status,
required this.errorMessage,
required this.eventName,
required this.selectedVendor,
required this.vendors,
required this.date,
required this.selectedHub,
required this.hubs,
required this.positions,
required this.roles,
required this.isValid,
required this.onEventNameChanged,
required this.onVendorChanged,
required this.onDateChanged,
required this.onHubChanged,
required this.onPositionAdded,
required this.onPositionUpdated,
required this.onPositionRemoved,
required this.onSubmit,
required this.onDone,
required this.onBack,
super.key,
});
final OrderFormStatus status;
final String? errorMessage;
final String eventName;
final Vendor? selectedVendor;
final List<Vendor> vendors;
final DateTime date;
final OrderHubUiModel? selectedHub;
final List<OrderHubUiModel> hubs;
final List<OrderPositionUiModel> positions;
final List<OrderRoleUiModel> roles;
final bool isValid;
final ValueChanged<String> onEventNameChanged;
final ValueChanged<Vendor> onVendorChanged;
final ValueChanged<DateTime> onDateChanged;
final ValueChanged<OrderHubUiModel> onHubChanged;
final VoidCallback onPositionAdded;
final void Function(int index, OrderPositionUiModel position) onPositionUpdated;
final void Function(int index) onPositionRemoved;
final VoidCallback onSubmit;
final VoidCallback onDone;
final VoidCallback onBack;
@override
Widget build(BuildContext context) {
final TranslationsClientCreateOrderOneTimeEn labels =
t.client_create_order.one_time;
// React to error messages
if (status == OrderFormStatus.failure && errorMessage != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
UiSnackbar.show(
context,
message: translateErrorKey(errorMessage!),
type: UiSnackbarType.error,
margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16),
);
});
}
if (status == OrderFormStatus.success) {
return OneTimeOrderSuccessView(
title: labels.success_title,
message: labels.success_message,
buttonLabel: labels.back_to_orders,
onDone: onDone,
);
}
if (vendors.isEmpty && status != OrderFormStatus.loading) {
return Scaffold(
body: Column(
children: <Widget>[
OneTimeOrderHeader(
title: labels.title,
subtitle: labels.subtitle,
onBack: onBack,
),
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(
UiIcons.search,
size: 64,
color: UiColors.iconInactive,
),
const SizedBox(height: UiConstants.space4),
Text(
'No Vendors Available',
style: UiTypography.headline3m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
Text(
'There are no staffing vendors associated with your account.',
style: UiTypography.body2r.textSecondary,
textAlign: TextAlign.center,
),
],
),
),
),
],
),
);
}
return Scaffold(
body: Column(
children: <Widget>[
OneTimeOrderHeader(
title: labels.title,
subtitle: labels.subtitle,
onBack: onBack,
),
Expanded(
child: Stack(
children: <Widget>[
_OneTimeOrderForm(
eventName: eventName,
selectedVendor: selectedVendor,
vendors: vendors,
date: date,
selectedHub: selectedHub,
hubs: hubs,
positions: positions,
roles: roles,
onEventNameChanged: onEventNameChanged,
onVendorChanged: onVendorChanged,
onDateChanged: onDateChanged,
onHubChanged: onHubChanged,
onPositionAdded: onPositionAdded,
onPositionUpdated: onPositionUpdated,
onPositionRemoved: onPositionRemoved,
),
if (status == OrderFormStatus.loading)
const Center(child: CircularProgressIndicator()),
],
),
),
_BottomActionButton(
label: status == OrderFormStatus.loading
? labels.creating
: labels.create_order,
isLoading: status == OrderFormStatus.loading,
onPressed: isValid ? onSubmit : null,
),
],
),
);
}
}
class _OneTimeOrderForm extends StatelessWidget {
const _OneTimeOrderForm({
required this.eventName,
required this.selectedVendor,
required this.vendors,
required this.date,
required this.selectedHub,
required this.hubs,
required this.positions,
required this.roles,
required this.onEventNameChanged,
required this.onVendorChanged,
required this.onDateChanged,
required this.onHubChanged,
required this.onPositionAdded,
required this.onPositionUpdated,
required this.onPositionRemoved,
});
final String eventName;
final Vendor? selectedVendor;
final List<Vendor> vendors;
final DateTime date;
final OrderHubUiModel? selectedHub;
final List<OrderHubUiModel> hubs;
final List<OrderPositionUiModel> positions;
final List<OrderRoleUiModel> roles;
final ValueChanged<String> onEventNameChanged;
final ValueChanged<Vendor> onVendorChanged;
final ValueChanged<DateTime> onDateChanged;
final ValueChanged<OrderHubUiModel> onHubChanged;
final VoidCallback onPositionAdded;
final void Function(int index, OrderPositionUiModel position) onPositionUpdated;
final void Function(int index) onPositionRemoved;
@override
Widget build(BuildContext context) {
final TranslationsClientCreateOrderOneTimeEn labels =
t.client_create_order.one_time;
return ListView(
padding: const EdgeInsets.all(UiConstants.space5),
children: <Widget>[
Text(
labels.create_your_order,
style: UiTypography.headline3m.textPrimary,
),
const SizedBox(height: UiConstants.space4),
OneTimeOrderEventNameInput(
label: 'ORDER NAME',
value: eventName,
onChanged: onEventNameChanged,
),
const SizedBox(height: UiConstants.space4),
// Vendor Selection
Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary),
const SizedBox(height: UiConstants.space2),
Container(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
height: 48,
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<Vendor>(
isExpanded: true,
value: selectedVendor,
icon: const Icon(
UiIcons.chevronDown,
size: 18,
color: UiColors.iconSecondary,
),
onChanged: (Vendor? vendor) {
if (vendor != null) {
onVendorChanged(vendor);
}
},
items: vendors.map((Vendor vendor) {
return DropdownMenuItem<Vendor>(
value: vendor,
child: Text(
vendor.name,
style: UiTypography.body2m.textPrimary,
),
);
}).toList(),
),
),
),
const SizedBox(height: UiConstants.space4),
OneTimeOrderDatePicker(
label: labels.date_label,
value: date,
onChanged: onDateChanged,
),
const SizedBox(height: UiConstants.space4),
Text('HUB', style: UiTypography.footnote2r.textSecondary),
const SizedBox(height: UiConstants.space2),
Container(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
height: 48,
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<OrderHubUiModel>(
isExpanded: true,
value: selectedHub,
icon: const Icon(
UiIcons.chevronDown,
size: 18,
color: UiColors.iconSecondary,
),
onChanged: (OrderHubUiModel? hub) {
if (hub != null) {
onHubChanged(hub);
}
},
items: hubs.map((OrderHubUiModel hub) {
return DropdownMenuItem<OrderHubUiModel>(
value: hub,
child: Text(
hub.name,
style: UiTypography.body2m.textPrimary,
),
);
}).toList(),
),
),
),
const SizedBox(height: UiConstants.space6),
OneTimeOrderSectionHeader(
title: labels.positions_title,
actionLabel: labels.add_position,
onAction: onPositionAdded,
),
const SizedBox(height: UiConstants.space3),
// Positions List
...positions.asMap().entries.map((
MapEntry<int, OrderPositionUiModel> entry,
) {
final int index = entry.key;
final OrderPositionUiModel position = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: OneTimeOrderPositionCard(
index: index,
position: position,
isRemovable: positions.length > 1,
positionLabel: labels.positions_title,
roleLabel: labels.select_role,
workersLabel: labels.workers_label,
startLabel: labels.start_label,
endLabel: labels.end_label,
lunchLabel: labels.lunch_break_label,
roles: roles,
onUpdated: (OrderPositionUiModel updated) {
onPositionUpdated(index, updated);
},
onRemoved: () {
onPositionRemoved(index);
},
),
);
}),
],
);
}
}
class _BottomActionButton extends StatelessWidget {
const _BottomActionButton({
required this.label,
required this.onPressed,
this.isLoading = false,
});
final String label;
final VoidCallback? onPressed;
final bool isLoading;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.only(
left: UiConstants.space5,
right: UiConstants.space5,
top: UiConstants.space5,
bottom: MediaQuery.of(context).padding.bottom + UiConstants.space5,
),
decoration: const BoxDecoration(
color: UiColors.white,
border: Border(top: BorderSide(color: UiColors.border)),
),
child: SizedBox(
width: double.infinity,
child: UiButton.primary(
text: label,
onPressed: isLoading ? null : onPressed,
size: UiButtonSize.large,
),
),
);
}
}

View File

@@ -0,0 +1,96 @@
import 'package:equatable/equatable.dart';
enum OrderFormStatus { initial, loading, success, failure }
class OrderHubUiModel extends Equatable {
const OrderHubUiModel({
required this.id,
required this.name,
required this.address,
this.placeId,
this.latitude,
this.longitude,
this.city,
this.state,
this.street,
this.country,
this.zipCode,
});
final String id;
final String name;
final String address;
final String? placeId;
final double? latitude;
final double? longitude;
final String? city;
final String? state;
final String? street;
final String? country;
final String? zipCode;
@override
List<Object?> get props => <Object?>[
id,
name,
address,
placeId,
latitude,
longitude,
city,
state,
street,
country,
zipCode,
];
}
class OrderRoleUiModel extends Equatable {
const OrderRoleUiModel({
required this.id,
required this.name,
required this.costPerHour,
});
final String id;
final String name;
final double costPerHour;
@override
List<Object?> get props => <Object?>[id, name, costPerHour];
}
class OrderPositionUiModel extends Equatable {
const OrderPositionUiModel({
required this.role,
required this.count,
required this.startTime,
required this.endTime,
this.lunchBreak = 'NO_BREAK',
});
final String role;
final int count;
final String startTime;
final String endTime;
final String lunchBreak;
OrderPositionUiModel copyWith({
String? role,
int? count,
String? startTime,
String? endTime,
String? lunchBreak,
}) {
return OrderPositionUiModel(
role: role ?? this.role,
count: count ?? this.count,
startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime,
lunchBreak: lunchBreak ?? this.lunchBreak,
);
}
@override
List<Object?> get props => <Object?>[role, count, startTime, endTime, lunchBreak];
}

View File

@@ -0,0 +1,74 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
/// A date picker field for the permanent order form.
class PermanentOrderDatePicker extends StatefulWidget {
/// Creates a [PermanentOrderDatePicker].
const PermanentOrderDatePicker({
required this.label,
required this.value,
required this.onChanged,
super.key,
});
/// The label text to display above the field.
final String label;
/// The currently selected date.
final DateTime value;
/// Callback when a new date is selected.
final ValueChanged<DateTime> onChanged;
@override
State<PermanentOrderDatePicker> createState() =>
_PermanentOrderDatePickerState();
}
class _PermanentOrderDatePickerState extends State<PermanentOrderDatePicker> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(
text: DateFormat('yyyy-MM-dd').format(widget.value),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
void didUpdateWidget(PermanentOrderDatePicker oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != oldWidget.value) {
_controller.text = DateFormat('yyyy-MM-dd').format(widget.value);
}
}
@override
Widget build(BuildContext context) {
return UiTextField(
label: widget.label,
controller: _controller,
readOnly: true,
prefixIcon: UiIcons.calendar,
onTap: () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: widget.value,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (picked != null) {
widget.onChanged(picked);
}
},
);
}
}

View File

@@ -0,0 +1,56 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A text input for the order name in the permanent order form.
class PermanentOrderEventNameInput extends StatefulWidget {
const PermanentOrderEventNameInput({
required this.label,
required this.value,
required this.onChanged,
super.key,
});
final String label;
final String value;
final ValueChanged<String> onChanged;
@override
State<PermanentOrderEventNameInput> createState() =>
_PermanentOrderEventNameInputState();
}
class _PermanentOrderEventNameInputState
extends State<PermanentOrderEventNameInput> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.value);
}
@override
void didUpdateWidget(PermanentOrderEventNameInput oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != _controller.text) {
_controller.text = widget.value;
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return UiTextField(
label: widget.label,
controller: _controller,
onChanged: widget.onChanged,
hintText: 'Order name',
prefixIcon: UiIcons.briefcase,
);
}
}

View File

@@ -0,0 +1,71 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A header widget for the permanent order flow with a colored background.
class PermanentOrderHeader extends StatelessWidget {
/// Creates a [PermanentOrderHeader].
const PermanentOrderHeader({
required this.title,
required this.subtitle,
required this.onBack,
super.key,
});
/// The title of the page.
final String title;
/// The subtitle or description.
final String subtitle;
/// Callback when the back button is pressed.
final VoidCallback onBack;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + UiConstants.space5,
bottom: UiConstants.space5,
left: UiConstants.space5,
right: UiConstants.space5,
),
color: UiColors.primary,
child: Row(
children: <Widget>[
GestureDetector(
onTap: onBack,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: UiColors.white.withValues(alpha: 0.2),
borderRadius: UiConstants.radiusMd,
),
child: const Icon(
UiIcons.chevronLeft,
color: UiColors.white,
size: 24,
),
),
),
const SizedBox(width: UiConstants.space3),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
title,
style: UiTypography.headline3m.copyWith(color: UiColors.white),
),
Text(
subtitle,
style: UiTypography.footnote2r.copyWith(
color: UiColors.white.withValues(alpha: 0.8),
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,321 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../order_ui_models.dart';
/// A card widget for editing a specific position in a permanent order.
class PermanentOrderPositionCard extends StatelessWidget {
const PermanentOrderPositionCard({
required this.index,
required this.position,
required this.isRemovable,
required this.onUpdated,
required this.onRemoved,
required this.positionLabel,
required this.roleLabel,
required this.workersLabel,
required this.startLabel,
required this.endLabel,
required this.lunchLabel,
required this.roles,
super.key,
});
final int index;
final OrderPositionUiModel position;
final bool isRemovable;
final ValueChanged<OrderPositionUiModel> onUpdated;
final VoidCallback onRemoved;
final String positionLabel;
final String roleLabel;
final String workersLabel;
final String startLabel;
final String endLabel;
final String lunchLabel;
final List<OrderRoleUiModel> roles;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
'$positionLabel #${index + 1}',
style: UiTypography.footnote1m.textSecondary,
),
if (isRemovable)
GestureDetector(
onTap: onRemoved,
child: Text(
t.client_create_order.one_time.remove,
style: UiTypography.footnote1m.copyWith(
color: UiColors.destructive,
),
),
),
],
),
const SizedBox(height: UiConstants.space3),
// Role (Dropdown)
Container(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
height: 44,
decoration: BoxDecoration(
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
isExpanded: true,
hint: Text(
roleLabel,
style: UiTypography.body2r.textPlaceholder,
),
value: position.role.isEmpty ? null : position.role,
icon: const Icon(
UiIcons.chevronDown,
size: 18,
color: UiColors.iconSecondary,
),
onChanged: (String? val) {
if (val != null) {
onUpdated(position.copyWith(role: val));
}
},
items: _buildRoleItems(),
),
),
),
const SizedBox(height: UiConstants.space3),
// Start/End/Workers Row
Row(
children: <Widget>[
// Start Time
Expanded(
child: _buildTimeInput(
context: context,
label: startLabel,
value: position.startTime,
onTap: () async {
final TimeOfDay? picked = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
);
if (picked != null && context.mounted) {
onUpdated(
position.copyWith(startTime: picked.format(context)),
);
}
},
),
),
const SizedBox(width: UiConstants.space2),
// End Time
Expanded(
child: _buildTimeInput(
context: context,
label: endLabel,
value: position.endTime,
onTap: () async {
final TimeOfDay? picked = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
);
if (picked != null && context.mounted) {
onUpdated(
position.copyWith(endTime: picked.format(context)),
);
}
},
),
),
const SizedBox(width: UiConstants.space2),
// Workers Count
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
workersLabel,
style: UiTypography.footnote2r.textSecondary,
),
const SizedBox(height: UiConstants.space1),
Container(
height: 40,
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: UiConstants.radiusSm,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
GestureDetector(
onTap: () {
if (position.count > 1) {
onUpdated(
position.copyWith(count: position.count - 1),
);
}
},
child: const Icon(UiIcons.minus, size: 12),
),
Text(
'${position.count}',
style: UiTypography.body2b.textPrimary,
),
GestureDetector(
onTap: () {
onUpdated(
position.copyWith(count: position.count + 1),
);
},
child: const Icon(UiIcons.add, size: 12),
),
],
),
),
],
),
),
],
),
const SizedBox(height: UiConstants.space4),
// Lunch Break
Text(lunchLabel, style: UiTypography.footnote2r.textSecondary),
const SizedBox(height: UiConstants.space1),
Container(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
height: 44,
decoration: BoxDecoration(
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
isExpanded: true,
value: position.lunchBreak,
icon: const Icon(
UiIcons.chevronDown,
size: 18,
color: UiColors.iconSecondary,
),
onChanged: (String? val) {
if (val != null) {
onUpdated(position.copyWith(lunchBreak: val));
}
},
items: <String>[
'NO_BREAK',
'MIN_10',
'MIN_15',
'MIN_30',
'MIN_45',
'MIN_60',
].map((String value) {
final String label = switch (value) {
'NO_BREAK' => 'No Break',
'MIN_10' => '10 min (Paid)',
'MIN_15' => '15 min (Paid)',
'MIN_30' => '30 min (Unpaid)',
'MIN_45' => '45 min (Unpaid)',
'MIN_60' => '60 min (Unpaid)',
_ => value,
};
return DropdownMenuItem<String>(
value: value,
child: Text(
label,
style: UiTypography.body2r.textPrimary,
),
);
}).toList(),
),
),
),
],
),
);
}
Widget _buildTimeInput({
required BuildContext context,
required String label,
required String value,
required VoidCallback onTap,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(label, style: UiTypography.footnote2r.textSecondary),
const SizedBox(height: UiConstants.space1),
GestureDetector(
onTap: onTap,
child: Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
decoration: BoxDecoration(
borderRadius: UiConstants.radiusSm,
border: Border.all(color: UiColors.border),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
value.isEmpty ? '--:--' : value,
style: UiTypography.body2r.textPrimary,
),
const Icon(
UiIcons.clock,
size: 14,
color: UiColors.iconSecondary,
),
],
),
),
),
],
);
}
List<DropdownMenuItem<String>> _buildRoleItems() {
final List<DropdownMenuItem<String>> items = roles
.map(
(OrderRoleUiModel role) => DropdownMenuItem<String>(
value: role.id,
child: Text(
'${role.name} - \$${role.costPerHour.toStringAsFixed(0)}',
style: UiTypography.body2r.textPrimary,
),
),
)
.toList();
final bool hasSelected = roles.any((OrderRoleUiModel role) => role.id == position.role);
if (position.role.isNotEmpty && !hasSelected) {
items.add(
DropdownMenuItem<String>(
value: position.role,
child: Text(
position.role,
style: UiTypography.body2r.textPrimary,
),
),
);
}
return items;
}
}

View File

@@ -0,0 +1,52 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A header widget for sections in the permanent order form.
class PermanentOrderSectionHeader extends StatelessWidget {
/// Creates a [PermanentOrderSectionHeader].
const PermanentOrderSectionHeader({
required this.title,
this.actionLabel,
this.onAction,
super.key,
});
/// The title text for the section.
final String title;
/// Optional label for an action button on the right.
final String? actionLabel;
/// Callback when the action button is tapped.
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(title, style: UiTypography.headline4m.textPrimary),
if (actionLabel != null && onAction != null)
TextButton(
onPressed: onAction,
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const Icon(UiIcons.add, size: 16, color: UiColors.primary),
const SizedBox(width: UiConstants.space2),
Text(
actionLabel!,
style: UiTypography.body2m.primary,
),
],
),
),
],
);
}
}

View File

@@ -0,0 +1,104 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A view to display when a permanent order has been successfully created.
class PermanentOrderSuccessView extends StatelessWidget {
/// Creates a [PermanentOrderSuccessView].
const PermanentOrderSuccessView({
required this.title,
required this.message,
required this.buttonLabel,
required this.onDone,
super.key,
});
/// The title of the success message.
final String title;
/// The body of the success message.
final String message;
/// Label for the completion button.
final String buttonLabel;
/// Callback when the completion button is tapped.
final VoidCallback onDone;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
width: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[UiColors.primary, UiColors.buttonPrimaryHover],
),
),
child: SafeArea(
child: Center(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: UiConstants.space10),
padding: const EdgeInsets.all(UiConstants.space8),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg * 1.5,
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.2),
blurRadius: 20,
offset: const Offset(0, UiConstants.space2 + 2),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
width: UiConstants.space16,
height: UiConstants.space16,
decoration: const BoxDecoration(
color: UiColors.accent,
shape: BoxShape.circle,
),
child: const Center(
child: Icon(
UiIcons.check,
color: UiColors.black,
size: UiConstants.space8,
),
),
),
const SizedBox(height: UiConstants.space6),
Text(
title,
style: UiTypography.headline2m.textPrimary,
textAlign: TextAlign.center,
),
const SizedBox(height: UiConstants.space3),
Text(
message,
textAlign: TextAlign.center,
style: UiTypography.body2r.textSecondary.copyWith(
height: 1.5,
),
),
const SizedBox(height: UiConstants.space8),
SizedBox(
width: double.infinity,
child: UiButton.primary(
text: buttonLabel,
onPressed: onDone,
size: UiButtonSize.large,
),
),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,466 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart' show Vendor;
import '../order_ui_models.dart';
import 'permanent_order_date_picker.dart';
import 'permanent_order_event_name_input.dart';
import 'permanent_order_header.dart';
import 'permanent_order_position_card.dart';
import 'permanent_order_section_header.dart';
import 'permanent_order_success_view.dart';
/// The main content of the Permanent Order page.
class PermanentOrderView extends StatelessWidget {
const PermanentOrderView({
required this.status,
required this.errorMessage,
required this.eventName,
required this.selectedVendor,
required this.vendors,
required this.startDate,
required this.permanentDays,
required this.selectedHub,
required this.hubs,
required this.positions,
required this.roles,
required this.isValid,
required this.onEventNameChanged,
required this.onVendorChanged,
required this.onStartDateChanged,
required this.onDayToggled,
required this.onHubChanged,
required this.onPositionAdded,
required this.onPositionUpdated,
required this.onPositionRemoved,
required this.onSubmit,
required this.onDone,
required this.onBack,
super.key,
});
final OrderFormStatus status;
final String? errorMessage;
final String eventName;
final Vendor? selectedVendor;
final List<Vendor> vendors;
final DateTime startDate;
final List<String> permanentDays;
final OrderHubUiModel? selectedHub;
final List<OrderHubUiModel> hubs;
final List<OrderPositionUiModel> positions;
final List<OrderRoleUiModel> roles;
final bool isValid;
final ValueChanged<String> onEventNameChanged;
final ValueChanged<Vendor> onVendorChanged;
final ValueChanged<DateTime> onStartDateChanged;
final ValueChanged<int> onDayToggled;
final ValueChanged<OrderHubUiModel> onHubChanged;
final VoidCallback onPositionAdded;
final void Function(int index, OrderPositionUiModel position) onPositionUpdated;
final void Function(int index) onPositionRemoved;
final VoidCallback onSubmit;
final VoidCallback onDone;
final VoidCallback onBack;
@override
Widget build(BuildContext context) {
final TranslationsClientCreateOrderPermanentEn labels =
t.client_create_order.permanent;
final TranslationsClientCreateOrderOneTimeEn oneTimeLabels =
t.client_create_order.one_time;
if (status == OrderFormStatus.failure && errorMessage != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
UiSnackbar.show(
context,
message: translateErrorKey(errorMessage!),
type: UiSnackbarType.error,
margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16),
);
});
}
if (status == OrderFormStatus.success) {
return PermanentOrderSuccessView(
title: labels.title,
message: labels.subtitle,
buttonLabel: oneTimeLabels.back_to_orders,
onDone: onDone,
);
}
if (vendors.isEmpty && status != OrderFormStatus.loading) {
return Scaffold(
body: Column(
children: <Widget>[
PermanentOrderHeader(
title: labels.title,
subtitle: labels.subtitle,
onBack: onBack,
),
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(
UiIcons.search,
size: 64,
color: UiColors.iconInactive,
),
const SizedBox(height: UiConstants.space4),
Text(
'No Vendors Available',
style: UiTypography.headline3m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
Text(
'There are no staffing vendors associated with your account.',
style: UiTypography.body2r.textSecondary,
textAlign: TextAlign.center,
),
],
),
),
),
],
),
);
}
return Scaffold(
body: Column(
children: <Widget>[
PermanentOrderHeader(
title: labels.title,
subtitle: labels.subtitle,
onBack: onBack,
),
Expanded(
child: Stack(
children: <Widget>[
_PermanentOrderForm(
eventName: eventName,
selectedVendor: selectedVendor,
vendors: vendors,
startDate: startDate,
permanentDays: permanentDays,
selectedHub: selectedHub,
hubs: hubs,
positions: positions,
roles: roles,
onEventNameChanged: onEventNameChanged,
onVendorChanged: onVendorChanged,
onStartDateChanged: onStartDateChanged,
onDayToggled: onDayToggled,
onHubChanged: onHubChanged,
onPositionAdded: onPositionAdded,
onPositionUpdated: onPositionUpdated,
onPositionRemoved: onPositionRemoved,
),
if (status == OrderFormStatus.loading)
const Center(child: CircularProgressIndicator()),
],
),
),
_BottomActionButton(
label: status == OrderFormStatus.loading
? oneTimeLabels.creating
: oneTimeLabels.create_order,
isLoading: status == OrderFormStatus.loading,
onPressed: isValid ? onSubmit : null,
),
],
),
);
}
}
class _PermanentOrderForm extends StatelessWidget {
const _PermanentOrderForm({
required this.eventName,
required this.selectedVendor,
required this.vendors,
required this.startDate,
required this.permanentDays,
required this.selectedHub,
required this.hubs,
required this.positions,
required this.roles,
required this.onEventNameChanged,
required this.onVendorChanged,
required this.onStartDateChanged,
required this.onDayToggled,
required this.onHubChanged,
required this.onPositionAdded,
required this.onPositionUpdated,
required this.onPositionRemoved,
});
final String eventName;
final Vendor? selectedVendor;
final List<Vendor> vendors;
final DateTime startDate;
final List<String> permanentDays;
final OrderHubUiModel? selectedHub;
final List<OrderHubUiModel> hubs;
final List<OrderPositionUiModel> positions;
final List<OrderRoleUiModel> roles;
final ValueChanged<String> onEventNameChanged;
final ValueChanged<Vendor> onVendorChanged;
final ValueChanged<DateTime> onStartDateChanged;
final ValueChanged<int> onDayToggled;
final ValueChanged<OrderHubUiModel> onHubChanged;
final VoidCallback onPositionAdded;
final void Function(int index, OrderPositionUiModel position) onPositionUpdated;
final void Function(int index) onPositionRemoved;
@override
Widget build(BuildContext context) {
final TranslationsClientCreateOrderPermanentEn labels =
t.client_create_order.permanent;
final TranslationsClientCreateOrderOneTimeEn oneTimeLabels =
t.client_create_order.one_time;
return ListView(
padding: const EdgeInsets.all(UiConstants.space5),
children: <Widget>[
Text(
labels.title,
style: UiTypography.headline3m.textPrimary,
),
const SizedBox(height: UiConstants.space4),
PermanentOrderEventNameInput(
label: 'ORDER NAME',
value: eventName,
onChanged: onEventNameChanged,
),
const SizedBox(height: UiConstants.space4),
// Vendor Selection
Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary),
const SizedBox(height: UiConstants.space2),
Container(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
height: 48,
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<Vendor>(
isExpanded: true,
value: selectedVendor,
icon: const Icon(
UiIcons.chevronDown,
size: 18,
color: UiColors.iconSecondary,
),
onChanged: (Vendor? vendor) {
if (vendor != null) {
onVendorChanged(vendor);
}
},
items: vendors.map((Vendor vendor) {
return DropdownMenuItem<Vendor>(
value: vendor,
child: Text(
vendor.name,
style: UiTypography.body2m.textPrimary,
),
);
}).toList(),
),
),
),
const SizedBox(height: UiConstants.space4),
PermanentOrderDatePicker(
label: 'Start Date',
value: startDate,
onChanged: onStartDateChanged,
),
const SizedBox(height: UiConstants.space4),
Text('Permanent Days', style: UiTypography.footnote2r.textSecondary),
const SizedBox(height: UiConstants.space2),
_PermanentDaysSelector(
selectedDays: permanentDays,
onToggle: onDayToggled,
),
const SizedBox(height: UiConstants.space4),
Text('HUB', style: UiTypography.footnote2r.textSecondary),
const SizedBox(height: UiConstants.space2),
Container(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
height: 48,
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<OrderHubUiModel>(
isExpanded: true,
value: selectedHub,
icon: const Icon(
UiIcons.chevronDown,
size: 18,
color: UiColors.iconSecondary,
),
onChanged: (OrderHubUiModel? hub) {
if (hub != null) {
onHubChanged(hub);
}
},
items: hubs.map((OrderHubUiModel hub) {
return DropdownMenuItem<OrderHubUiModel>(
value: hub,
child: Text(
hub.name,
style: UiTypography.body2m.textPrimary,
),
);
}).toList(),
),
),
),
const SizedBox(height: UiConstants.space6),
PermanentOrderSectionHeader(
title: oneTimeLabels.positions_title,
actionLabel: oneTimeLabels.add_position,
onAction: onPositionAdded,
),
const SizedBox(height: UiConstants.space3),
// Positions List
...positions.asMap().entries.map((
MapEntry<int, OrderPositionUiModel> entry,
) {
final int index = entry.key;
final OrderPositionUiModel position = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: PermanentOrderPositionCard(
index: index,
position: position,
isRemovable: positions.length > 1,
positionLabel: oneTimeLabels.positions_title,
roleLabel: oneTimeLabels.select_role,
workersLabel: oneTimeLabels.workers_label,
startLabel: oneTimeLabels.start_label,
endLabel: oneTimeLabels.end_label,
lunchLabel: oneTimeLabels.lunch_break_label,
roles: roles,
onUpdated: (OrderPositionUiModel updated) {
onPositionUpdated(index, updated);
},
onRemoved: () {
onPositionRemoved(index);
},
),
);
}),
],
);
}
}
class _PermanentDaysSelector extends StatelessWidget {
const _PermanentDaysSelector({
required this.selectedDays,
required this.onToggle,
});
final List<String> selectedDays;
final ValueChanged<int> onToggle;
@override
Widget build(BuildContext context) {
const List<String> labelsShort = <String>[
'S',
'M',
'T',
'W',
'T',
'F',
'S',
];
const List<String> labelsLong = <String>[
'SUN',
'MON',
'TUE',
'WED',
'THU',
'FRI',
'SAT',
];
return Wrap(
spacing: UiConstants.space2,
children: List<Widget>.generate(labelsShort.length, (int index) {
final bool isSelected = selectedDays.contains(labelsLong[index]);
return GestureDetector(
onTap: () => onToggle(index),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: isSelected ? UiColors.primary : UiColors.white,
shape: BoxShape.circle,
border: Border.all(color: UiColors.border),
),
alignment: Alignment.center,
child: Text(
labelsShort[index],
style: UiTypography.body2m.copyWith(
color: isSelected ? UiColors.white : UiColors.textSecondary,
),
),
),
);
}),
);
}
}
class _BottomActionButton extends StatelessWidget {
const _BottomActionButton({
required this.label,
required this.onPressed,
this.isLoading = false,
});
final String label;
final VoidCallback? onPressed;
final bool isLoading;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.only(
left: UiConstants.space5,
right: UiConstants.space5,
top: UiConstants.space5,
bottom: MediaQuery.of(context).padding.bottom + UiConstants.space5,
),
decoration: const BoxDecoration(
color: UiColors.white,
border: Border(top: BorderSide(color: UiColors.border)),
),
child: SizedBox(
width: double.infinity,
child: UiButton.primary(
text: label,
onPressed: isLoading ? null : onPressed,
size: UiButtonSize.large,
),
),
);
}
}

View File

@@ -0,0 +1,74 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
/// A date picker field for the recurring order form.
class RecurringOrderDatePicker extends StatefulWidget {
/// Creates a [RecurringOrderDatePicker].
const RecurringOrderDatePicker({
required this.label,
required this.value,
required this.onChanged,
super.key,
});
/// The label text to display above the field.
final String label;
/// The currently selected date.
final DateTime value;
/// Callback when a new date is selected.
final ValueChanged<DateTime> onChanged;
@override
State<RecurringOrderDatePicker> createState() =>
_RecurringOrderDatePickerState();
}
class _RecurringOrderDatePickerState extends State<RecurringOrderDatePicker> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(
text: DateFormat('yyyy-MM-dd').format(widget.value),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
void didUpdateWidget(RecurringOrderDatePicker oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != oldWidget.value) {
_controller.text = DateFormat('yyyy-MM-dd').format(widget.value);
}
}
@override
Widget build(BuildContext context) {
return UiTextField(
label: widget.label,
controller: _controller,
readOnly: true,
prefixIcon: UiIcons.calendar,
onTap: () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: widget.value,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (picked != null) {
widget.onChanged(picked);
}
},
);
}
}

View File

@@ -0,0 +1,56 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A text input for the order name in the recurring order form.
class RecurringOrderEventNameInput extends StatefulWidget {
const RecurringOrderEventNameInput({
required this.label,
required this.value,
required this.onChanged,
super.key,
});
final String label;
final String value;
final ValueChanged<String> onChanged;
@override
State<RecurringOrderEventNameInput> createState() =>
_RecurringOrderEventNameInputState();
}
class _RecurringOrderEventNameInputState
extends State<RecurringOrderEventNameInput> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.value);
}
@override
void didUpdateWidget(RecurringOrderEventNameInput oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != _controller.text) {
_controller.text = widget.value;
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return UiTextField(
label: widget.label,
controller: _controller,
onChanged: widget.onChanged,
hintText: 'Order name',
prefixIcon: UiIcons.briefcase,
);
}
}

View File

@@ -0,0 +1,71 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A header widget for the recurring order flow with a colored background.
class RecurringOrderHeader extends StatelessWidget {
/// Creates a [RecurringOrderHeader].
const RecurringOrderHeader({
required this.title,
required this.subtitle,
required this.onBack,
super.key,
});
/// The title of the page.
final String title;
/// The subtitle or description.
final String subtitle;
/// Callback when the back button is pressed.
final VoidCallback onBack;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + UiConstants.space5,
bottom: UiConstants.space5,
left: UiConstants.space5,
right: UiConstants.space5,
),
color: UiColors.primary,
child: Row(
children: <Widget>[
GestureDetector(
onTap: onBack,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: UiColors.white.withValues(alpha: 0.2),
borderRadius: UiConstants.radiusMd,
),
child: const Icon(
UiIcons.chevronLeft,
color: UiColors.white,
size: 24,
),
),
),
const SizedBox(width: UiConstants.space3),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
title,
style: UiTypography.headline3m.copyWith(color: UiColors.white),
),
Text(
subtitle,
style: UiTypography.footnote2r.copyWith(
color: UiColors.white.withValues(alpha: 0.8),
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,321 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../order_ui_models.dart';
/// A card widget for editing a specific position in a recurring order.
class RecurringOrderPositionCard extends StatelessWidget {
const RecurringOrderPositionCard({
required this.index,
required this.position,
required this.isRemovable,
required this.onUpdated,
required this.onRemoved,
required this.positionLabel,
required this.roleLabel,
required this.workersLabel,
required this.startLabel,
required this.endLabel,
required this.lunchLabel,
required this.roles,
super.key,
});
final int index;
final OrderPositionUiModel position;
final bool isRemovable;
final ValueChanged<OrderPositionUiModel> onUpdated;
final VoidCallback onRemoved;
final String positionLabel;
final String roleLabel;
final String workersLabel;
final String startLabel;
final String endLabel;
final String lunchLabel;
final List<OrderRoleUiModel> roles;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
'$positionLabel #${index + 1}',
style: UiTypography.footnote1m.textSecondary,
),
if (isRemovable)
GestureDetector(
onTap: onRemoved,
child: Text(
t.client_create_order.one_time.remove,
style: UiTypography.footnote1m.copyWith(
color: UiColors.destructive,
),
),
),
],
),
const SizedBox(height: UiConstants.space3),
// Role (Dropdown)
Container(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
height: 44,
decoration: BoxDecoration(
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
isExpanded: true,
hint: Text(
roleLabel,
style: UiTypography.body2r.textPlaceholder,
),
value: position.role.isEmpty ? null : position.role,
icon: const Icon(
UiIcons.chevronDown,
size: 18,
color: UiColors.iconSecondary,
),
onChanged: (String? val) {
if (val != null) {
onUpdated(position.copyWith(role: val));
}
},
items: _buildRoleItems(),
),
),
),
const SizedBox(height: UiConstants.space3),
// Start/End/Workers Row
Row(
children: <Widget>[
// Start Time
Expanded(
child: _buildTimeInput(
context: context,
label: startLabel,
value: position.startTime,
onTap: () async {
final TimeOfDay? picked = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
);
if (picked != null && context.mounted) {
onUpdated(
position.copyWith(startTime: picked.format(context)),
);
}
},
),
),
const SizedBox(width: UiConstants.space2),
// End Time
Expanded(
child: _buildTimeInput(
context: context,
label: endLabel,
value: position.endTime,
onTap: () async {
final TimeOfDay? picked = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
);
if (picked != null && context.mounted) {
onUpdated(
position.copyWith(endTime: picked.format(context)),
);
}
},
),
),
const SizedBox(width: UiConstants.space2),
// Workers Count
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
workersLabel,
style: UiTypography.footnote2r.textSecondary,
),
const SizedBox(height: UiConstants.space1),
Container(
height: 40,
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: UiConstants.radiusSm,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
GestureDetector(
onTap: () {
if (position.count > 1) {
onUpdated(
position.copyWith(count: position.count - 1),
);
}
},
child: const Icon(UiIcons.minus, size: 12),
),
Text(
'${position.count}',
style: UiTypography.body2b.textPrimary,
),
GestureDetector(
onTap: () {
onUpdated(
position.copyWith(count: position.count + 1),
);
},
child: const Icon(UiIcons.add, size: 12),
),
],
),
),
],
),
),
],
),
const SizedBox(height: UiConstants.space4),
// Lunch Break
Text(lunchLabel, style: UiTypography.footnote2r.textSecondary),
const SizedBox(height: UiConstants.space1),
Container(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
height: 44,
decoration: BoxDecoration(
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
isExpanded: true,
value: position.lunchBreak,
icon: const Icon(
UiIcons.chevronDown,
size: 18,
color: UiColors.iconSecondary,
),
onChanged: (String? val) {
if (val != null) {
onUpdated(position.copyWith(lunchBreak: val));
}
},
items: <String>[
'NO_BREAK',
'MIN_10',
'MIN_15',
'MIN_30',
'MIN_45',
'MIN_60',
].map((String value) {
final String label = switch (value) {
'NO_BREAK' => 'No Break',
'MIN_10' => '10 min (Paid)',
'MIN_15' => '15 min (Paid)',
'MIN_30' => '30 min (Unpaid)',
'MIN_45' => '45 min (Unpaid)',
'MIN_60' => '60 min (Unpaid)',
_ => value,
};
return DropdownMenuItem<String>(
value: value,
child: Text(
label,
style: UiTypography.body2r.textPrimary,
),
);
}).toList(),
),
),
),
],
),
);
}
Widget _buildTimeInput({
required BuildContext context,
required String label,
required String value,
required VoidCallback onTap,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(label, style: UiTypography.footnote2r.textSecondary),
const SizedBox(height: UiConstants.space1),
GestureDetector(
onTap: onTap,
child: Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
decoration: BoxDecoration(
borderRadius: UiConstants.radiusSm,
border: Border.all(color: UiColors.border),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
value.isEmpty ? '--:--' : value,
style: UiTypography.body2r.textPrimary,
),
const Icon(
UiIcons.clock,
size: 14,
color: UiColors.iconSecondary,
),
],
),
),
),
],
);
}
List<DropdownMenuItem<String>> _buildRoleItems() {
final List<DropdownMenuItem<String>> items = roles
.map(
(OrderRoleUiModel role) => DropdownMenuItem<String>(
value: role.id,
child: Text(
'${role.name} - \$${role.costPerHour.toStringAsFixed(0)}',
style: UiTypography.body2r.textPrimary,
),
),
)
.toList();
final bool hasSelected = roles.any((OrderRoleUiModel role) => role.id == position.role);
if (position.role.isNotEmpty && !hasSelected) {
items.add(
DropdownMenuItem<String>(
value: position.role,
child: Text(
position.role,
style: UiTypography.body2r.textPrimary,
),
),
);
}
return items;
}
}

View File

@@ -0,0 +1,52 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A header widget for sections in the recurring order form.
class RecurringOrderSectionHeader extends StatelessWidget {
/// Creates a [RecurringOrderSectionHeader].
const RecurringOrderSectionHeader({
required this.title,
this.actionLabel,
this.onAction,
super.key,
});
/// The title text for the section.
final String title;
/// Optional label for an action button on the right.
final String? actionLabel;
/// Callback when the action button is tapped.
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(title, style: UiTypography.headline4m.textPrimary),
if (actionLabel != null && onAction != null)
TextButton(
onPressed: onAction,
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const Icon(UiIcons.add, size: 16, color: UiColors.primary),
const SizedBox(width: UiConstants.space2),
Text(
actionLabel!,
style: UiTypography.body2m.primary,
),
],
),
),
],
);
}
}

View File

@@ -0,0 +1,104 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A view to display when a recurring order has been successfully created.
class RecurringOrderSuccessView extends StatelessWidget {
/// Creates a [RecurringOrderSuccessView].
const RecurringOrderSuccessView({
required this.title,
required this.message,
required this.buttonLabel,
required this.onDone,
super.key,
});
/// The title of the success message.
final String title;
/// The body of the success message.
final String message;
/// Label for the completion button.
final String buttonLabel;
/// Callback when the completion button is tapped.
final VoidCallback onDone;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
width: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[UiColors.primary, UiColors.buttonPrimaryHover],
),
),
child: SafeArea(
child: Center(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: UiConstants.space10),
padding: const EdgeInsets.all(UiConstants.space8),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg * 1.5,
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.2),
blurRadius: 20,
offset: const Offset(0, UiConstants.space2 + 2),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
width: UiConstants.space16,
height: UiConstants.space16,
decoration: const BoxDecoration(
color: UiColors.accent,
shape: BoxShape.circle,
),
child: const Center(
child: Icon(
UiIcons.check,
color: UiColors.black,
size: UiConstants.space8,
),
),
),
const SizedBox(height: UiConstants.space6),
Text(
title,
style: UiTypography.headline2m.textPrimary,
textAlign: TextAlign.center,
),
const SizedBox(height: UiConstants.space3),
Text(
message,
textAlign: TextAlign.center,
style: UiTypography.body2r.textSecondary.copyWith(
height: 1.5,
),
),
const SizedBox(height: UiConstants.space8),
SizedBox(
width: double.infinity,
child: UiButton.primary(
text: buttonLabel,
onPressed: onDone,
size: UiButtonSize.large,
),
),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,486 @@
import 'package:core_localization/core_localization.dart';
import 'package:krow_domain/krow_domain.dart' show Vendor;
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../order_ui_models.dart';
import 'recurring_order_date_picker.dart';
import 'recurring_order_event_name_input.dart';
import 'recurring_order_header.dart';
import 'recurring_order_position_card.dart';
import 'recurring_order_section_header.dart';
import 'recurring_order_success_view.dart';
/// The main content of the Recurring Order page.
class RecurringOrderView extends StatelessWidget {
const RecurringOrderView({
required this.status,
required this.errorMessage,
required this.eventName,
required this.selectedVendor,
required this.vendors,
required this.startDate,
required this.endDate,
required this.recurringDays,
required this.selectedHub,
required this.hubs,
required this.positions,
required this.roles,
required this.isValid,
required this.onEventNameChanged,
required this.onVendorChanged,
required this.onStartDateChanged,
required this.onEndDateChanged,
required this.onDayToggled,
required this.onHubChanged,
required this.onPositionAdded,
required this.onPositionUpdated,
required this.onPositionRemoved,
required this.onSubmit,
required this.onDone,
required this.onBack,
super.key,
});
final OrderFormStatus status;
final String? errorMessage;
final String eventName;
final Vendor? selectedVendor;
final List<Vendor> vendors;
final DateTime startDate;
final DateTime endDate;
final List<String> recurringDays;
final OrderHubUiModel? selectedHub;
final List<OrderHubUiModel> hubs;
final List<OrderPositionUiModel> positions;
final List<OrderRoleUiModel> roles;
final bool isValid;
final ValueChanged<String> onEventNameChanged;
final ValueChanged<Vendor> onVendorChanged;
final ValueChanged<DateTime> onStartDateChanged;
final ValueChanged<DateTime> onEndDateChanged;
final ValueChanged<int> onDayToggled;
final ValueChanged<OrderHubUiModel> onHubChanged;
final VoidCallback onPositionAdded;
final void Function(int index, OrderPositionUiModel position) onPositionUpdated;
final void Function(int index) onPositionRemoved;
final VoidCallback onSubmit;
final VoidCallback onDone;
final VoidCallback onBack;
@override
Widget build(BuildContext context) {
final TranslationsClientCreateOrderRecurringEn labels =
t.client_create_order.recurring;
final TranslationsClientCreateOrderOneTimeEn oneTimeLabels =
t.client_create_order.one_time;
if (status == OrderFormStatus.failure && errorMessage != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final String message = errorMessage == 'placeholder'
? labels.placeholder
: translateErrorKey(errorMessage!);
UiSnackbar.show(
context,
message: message,
type: UiSnackbarType.error,
margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16),
);
});
}
if (status == OrderFormStatus.success) {
return RecurringOrderSuccessView(
title: labels.title,
message: labels.subtitle,
buttonLabel: oneTimeLabels.back_to_orders,
onDone: onDone,
);
}
if (vendors.isEmpty && status != OrderFormStatus.loading) {
return Scaffold(
body: Column(
children: <Widget>[
RecurringOrderHeader(
title: labels.title,
subtitle: labels.subtitle,
onBack: onBack,
),
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(
UiIcons.search,
size: 64,
color: UiColors.iconInactive,
),
const SizedBox(height: UiConstants.space4),
Text(
'No Vendors Available',
style: UiTypography.headline3m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
Text(
'There are no staffing vendors associated with your account.',
style: UiTypography.body2r.textSecondary,
textAlign: TextAlign.center,
),
],
),
),
),
],
),
);
}
return Scaffold(
body: Column(
children: <Widget>[
RecurringOrderHeader(
title: labels.title,
subtitle: labels.subtitle,
onBack: onBack,
),
Expanded(
child: Stack(
children: <Widget>[
_RecurringOrderForm(
eventName: eventName,
selectedVendor: selectedVendor,
vendors: vendors,
startDate: startDate,
endDate: endDate,
recurringDays: recurringDays,
selectedHub: selectedHub,
hubs: hubs,
positions: positions,
roles: roles,
onEventNameChanged: onEventNameChanged,
onVendorChanged: onVendorChanged,
onStartDateChanged: onStartDateChanged,
onEndDateChanged: onEndDateChanged,
onDayToggled: onDayToggled,
onHubChanged: onHubChanged,
onPositionAdded: onPositionAdded,
onPositionUpdated: onPositionUpdated,
onPositionRemoved: onPositionRemoved,
),
if (status == OrderFormStatus.loading)
const Center(child: CircularProgressIndicator()),
],
),
),
_BottomActionButton(
label: status == OrderFormStatus.loading
? oneTimeLabels.creating
: oneTimeLabels.create_order,
isLoading: status == OrderFormStatus.loading,
onPressed: isValid ? onSubmit : null,
),
],
),
);
}
}
class _RecurringOrderForm extends StatelessWidget {
const _RecurringOrderForm({
required this.eventName,
required this.selectedVendor,
required this.vendors,
required this.startDate,
required this.endDate,
required this.recurringDays,
required this.selectedHub,
required this.hubs,
required this.positions,
required this.roles,
required this.onEventNameChanged,
required this.onVendorChanged,
required this.onStartDateChanged,
required this.onEndDateChanged,
required this.onDayToggled,
required this.onHubChanged,
required this.onPositionAdded,
required this.onPositionUpdated,
required this.onPositionRemoved,
});
final String eventName;
final Vendor? selectedVendor;
final List<Vendor> vendors;
final DateTime startDate;
final DateTime endDate;
final List<String> recurringDays;
final OrderHubUiModel? selectedHub;
final List<OrderHubUiModel> hubs;
final List<OrderPositionUiModel> positions;
final List<OrderRoleUiModel> roles;
final ValueChanged<String> onEventNameChanged;
final ValueChanged<Vendor> onVendorChanged;
final ValueChanged<DateTime> onStartDateChanged;
final ValueChanged<DateTime> onEndDateChanged;
final ValueChanged<int> onDayToggled;
final ValueChanged<OrderHubUiModel> onHubChanged;
final VoidCallback onPositionAdded;
final void Function(int index, OrderPositionUiModel position) onPositionUpdated;
final void Function(int index) onPositionRemoved;
@override
Widget build(BuildContext context) {
final TranslationsClientCreateOrderRecurringEn labels =
t.client_create_order.recurring;
final TranslationsClientCreateOrderOneTimeEn oneTimeLabels =
t.client_create_order.one_time;
return ListView(
padding: const EdgeInsets.all(UiConstants.space5),
children: <Widget>[
Text(
labels.title,
style: UiTypography.headline3m.textPrimary,
),
const SizedBox(height: UiConstants.space4),
RecurringOrderEventNameInput(
label: 'ORDER NAME',
value: eventName,
onChanged: onEventNameChanged,
),
const SizedBox(height: UiConstants.space4),
// Vendor Selection
Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary),
const SizedBox(height: UiConstants.space2),
Container(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
height: 48,
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<Vendor>(
isExpanded: true,
value: selectedVendor,
icon: const Icon(
UiIcons.chevronDown,
size: 18,
color: UiColors.iconSecondary,
),
onChanged: (Vendor? vendor) {
if (vendor != null) {
onVendorChanged(vendor);
}
},
items: vendors.map((Vendor vendor) {
return DropdownMenuItem<Vendor>(
value: vendor,
child: Text(
vendor.name,
style: UiTypography.body2m.textPrimary,
),
);
}).toList(),
),
),
),
const SizedBox(height: UiConstants.space4),
RecurringOrderDatePicker(
label: 'Start Date',
value: startDate,
onChanged: onStartDateChanged,
),
const SizedBox(height: UiConstants.space4),
RecurringOrderDatePicker(
label: 'End Date',
value: endDate,
onChanged: onEndDateChanged,
),
const SizedBox(height: UiConstants.space4),
Text('Recurring Days', style: UiTypography.footnote2r.textSecondary),
const SizedBox(height: UiConstants.space2),
_RecurringDaysSelector(
selectedDays: recurringDays,
onToggle: onDayToggled,
),
const SizedBox(height: UiConstants.space4),
Text('HUB', style: UiTypography.footnote2r.textSecondary),
const SizedBox(height: UiConstants.space2),
Container(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
height: 48,
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<OrderHubUiModel>(
isExpanded: true,
value: selectedHub,
icon: const Icon(
UiIcons.chevronDown,
size: 18,
color: UiColors.iconSecondary,
),
onChanged: (OrderHubUiModel? hub) {
if (hub != null) {
onHubChanged(hub);
}
},
items: hubs.map((OrderHubUiModel hub) {
return DropdownMenuItem<OrderHubUiModel>(
value: hub,
child: Text(
hub.name,
style: UiTypography.body2m.textPrimary,
),
);
}).toList(),
),
),
),
const SizedBox(height: UiConstants.space6),
RecurringOrderSectionHeader(
title: oneTimeLabels.positions_title,
actionLabel: oneTimeLabels.add_position,
onAction: onPositionAdded,
),
const SizedBox(height: UiConstants.space3),
// Positions List
...positions.asMap().entries.map((
MapEntry<int, OrderPositionUiModel> entry,
) {
final int index = entry.key;
final OrderPositionUiModel position = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: RecurringOrderPositionCard(
index: index,
position: position,
isRemovable: positions.length > 1,
positionLabel: oneTimeLabels.positions_title,
roleLabel: oneTimeLabels.select_role,
workersLabel: oneTimeLabels.workers_label,
startLabel: oneTimeLabels.start_label,
endLabel: oneTimeLabels.end_label,
lunchLabel: oneTimeLabels.lunch_break_label,
roles: roles,
onUpdated: (OrderPositionUiModel updated) {
onPositionUpdated(index, updated);
},
onRemoved: () {
onPositionRemoved(index);
},
),
);
}),
],
);
}
}
class _RecurringDaysSelector extends StatelessWidget {
const _RecurringDaysSelector({
required this.selectedDays,
required this.onToggle,
});
final List<String> selectedDays;
final ValueChanged<int> onToggle;
@override
Widget build(BuildContext context) {
const List<String> labelsShort = <String>[
'S',
'M',
'T',
'W',
'T',
'F',
'S',
];
const List<String> labelsLong = <String>[
'SUN',
'MON',
'TUE',
'WED',
'THU',
'FRI',
'SAT',
];
return Wrap(
spacing: UiConstants.space2,
children: List<Widget>.generate(labelsShort.length, (int index) {
final bool isSelected = selectedDays.contains(labelsLong[index]);
return GestureDetector(
onTap: () => onToggle(index),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: isSelected ? UiColors.primary : UiColors.white,
shape: BoxShape.circle,
border: Border.all(color: UiColors.border),
),
alignment: Alignment.center,
child: Text(
labelsShort[index],
style: UiTypography.body2m.copyWith(
color: isSelected ? UiColors.white : UiColors.textSecondary,
),
),
),
);
}),
);
}
}
class _BottomActionButton extends StatelessWidget {
const _BottomActionButton({
required this.label,
required this.onPressed,
this.isLoading = false,
});
final String label;
final VoidCallback? onPressed;
final bool isLoading;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.only(
left: UiConstants.space5,
right: UiConstants.space5,
top: UiConstants.space5,
bottom: MediaQuery.of(context).padding.bottom + UiConstants.space5,
),
decoration: const BoxDecoration(
color: UiColors.white,
border: Border(top: BorderSide(color: UiColors.border)),
),
child: SizedBox(
width: double.infinity,
child: UiButton.primary(
text: label,
onPressed: isLoading ? null : onPressed,
size: UiButtonSize.large,
),
),
);
}
}