From 0dc56d56cafc865fa752c5e38540f38bd525c359 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 21 Feb 2026 19:25:07 -0500 Subject: [PATCH] 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. --- .../lib/client_orders_common.dart | 30 ++ .../one_time_order_date_picker.dart | 74 +++ .../one_time_order_event_name_input.dart | 56 ++ .../one_time_order/one_time_order_header.dart | 71 +++ .../one_time_order_location_input.dart | 62 +++ .../one_time_order_position_card.dart | 322 ++++++++++++ .../one_time_order_section_header.dart | 52 ++ .../one_time_order_success_view.dart | 107 ++++ .../one_time_order/one_time_order_view.dart | 388 ++++++++++++++ .../presentation/widgets/order_ui_models.dart | 96 ++++ .../permanent_order_date_picker.dart | 74 +++ .../permanent_order_event_name_input.dart | 56 ++ .../permanent_order_header.dart | 71 +++ .../permanent_order_position_card.dart | 321 ++++++++++++ .../permanent_order_section_header.dart | 52 ++ .../permanent_order_success_view.dart | 104 ++++ .../permanent_order/permanent_order_view.dart | 466 +++++++++++++++++ .../recurring_order_date_picker.dart | 74 +++ .../recurring_order_event_name_input.dart | 56 ++ .../recurring_order_header.dart | 71 +++ .../recurring_order_position_card.dart | 321 ++++++++++++ .../recurring_order_section_header.dart | 52 ++ .../recurring_order_success_view.dart | 104 ++++ .../recurring_order/recurring_order_view.dart | 486 ++++++++++++++++++ 24 files changed, 3566 insertions(+) create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/client_orders_common.dart create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_date_picker.dart create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_event_name_input.dart create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_header.dart create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_location_input.dart create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_position_card.dart create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_section_header.dart create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_success_view.dart create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_ui_models.dart create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_date_picker.dart create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_event_name_input.dart create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_header.dart create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_position_card.dart create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_section_header.dart create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_success_view.dart create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_date_picker.dart create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_event_name_input.dart create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_header.dart create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_position_card.dart create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_section_header.dart create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_success_view.dart create mode 100644 apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/client_orders_common.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/client_orders_common.dart new file mode 100644 index 00000000..410be326 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/client_orders_common.dart @@ -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'; diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_date_picker.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_date_picker.dart new file mode 100644 index 00000000..5a0eb751 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_date_picker.dart @@ -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 onChanged; + + @override + State createState() => _OneTimeOrderDatePickerState(); +} + +class _OneTimeOrderDatePickerState extends State { + 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); + } + }, + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_event_name_input.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_event_name_input.dart new file mode 100644 index 00000000..2fe608d0 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_event_name_input.dart @@ -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 onChanged; + + @override + State createState() => + _OneTimeOrderEventNameInputState(); +} + +class _OneTimeOrderEventNameInputState + extends State { + 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, + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_header.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_header.dart new file mode 100644 index 00000000..d39f6c8b --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_header.dart @@ -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: [ + 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: [ + Text( + title, + style: UiTypography.headline3m.copyWith(color: UiColors.white), + ), + Text( + subtitle, + style: UiTypography.footnote2r.copyWith( + color: UiColors.white.withValues(alpha: 0.8), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_location_input.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_location_input.dart new file mode 100644 index 00000000..7eb8baf1 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_location_input.dart @@ -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 onChanged; + + @override + State createState() => + _OneTimeOrderLocationInputState(); +} + +class _OneTimeOrderLocationInputState extends State { + 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, + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_position_card.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_position_card.dart new file mode 100644 index 00000000..b59f81ec --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_position_card.dart @@ -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 onUpdated; + final VoidCallback onRemoved; + final String positionLabel; + final String roleLabel; + final String workersLabel; + final String startLabel; + final String endLabel; + final String lunchLabel; + final List 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: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + 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( + 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: [ + // 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: [ + 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: [ + 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( + 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: [ + '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( + 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: [ + 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: [ + Text( + value.isEmpty ? '--:--' : value, + style: UiTypography.body2r.textPrimary, + ), + const Icon( + UiIcons.clock, + size: 14, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ), + ], + ); + } + + List> _buildRoleItems() { + final List> items = roles + .map( + (OrderRoleUiModel role) => DropdownMenuItem( + 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( + value: position.role, + child: Text( + position.role, + style: UiTypography.body2r.textPrimary, + ), + ), + ); + } + + return items; + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_section_header.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_section_header.dart new file mode 100644 index 00000000..66d076f5 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_section_header.dart @@ -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: [ + 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: [ + const Icon(UiIcons.add, size: 16, color: UiColors.primary), + const SizedBox(width: UiConstants.space2), + Text( + actionLabel!, + style: UiTypography.body2m.primary, + ), + ], + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_success_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_success_view.dart new file mode 100644 index 00000000..a9981270 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_success_view.dart @@ -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: [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( + color: UiColors.black.withValues(alpha: 0.2), + blurRadius: 20, + offset: const Offset(0, UiConstants.space2 + 2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + 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, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart new file mode 100644 index 00000000..ba891dcc --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart @@ -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 vendors; + final DateTime date; + final OrderHubUiModel? selectedHub; + final List hubs; + final List positions; + final List roles; + final bool isValid; + + final ValueChanged onEventNameChanged; + final ValueChanged onVendorChanged; + final ValueChanged onDateChanged; + final ValueChanged 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: [ + OneTimeOrderHeader( + title: labels.title, + subtitle: labels.subtitle, + onBack: onBack, + ), + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + 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: [ + OneTimeOrderHeader( + title: labels.title, + subtitle: labels.subtitle, + onBack: onBack, + ), + Expanded( + child: Stack( + children: [ + _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 vendors; + final DateTime date; + final OrderHubUiModel? selectedHub; + final List hubs; + final List positions; + final List roles; + + final ValueChanged onEventNameChanged; + final ValueChanged onVendorChanged; + final ValueChanged onDateChanged; + final ValueChanged 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: [ + 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( + 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( + 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( + 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( + 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 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, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_ui_models.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_ui_models.dart new file mode 100644 index 00000000..48931710 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_ui_models.dart @@ -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 get props => [ + 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 get props => [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 get props => [role, count, startTime, endTime, lunchBreak]; +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_date_picker.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_date_picker.dart new file mode 100644 index 00000000..7fe41016 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_date_picker.dart @@ -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 onChanged; + + @override + State createState() => + _PermanentOrderDatePickerState(); +} + +class _PermanentOrderDatePickerState extends State { + 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); + } + }, + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_event_name_input.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_event_name_input.dart new file mode 100644 index 00000000..4eb0baa4 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_event_name_input.dart @@ -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 onChanged; + + @override + State createState() => + _PermanentOrderEventNameInputState(); +} + +class _PermanentOrderEventNameInputState + extends State { + 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, + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_header.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_header.dart new file mode 100644 index 00000000..8943f5f1 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_header.dart @@ -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: [ + 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: [ + Text( + title, + style: UiTypography.headline3m.copyWith(color: UiColors.white), + ), + Text( + subtitle, + style: UiTypography.footnote2r.copyWith( + color: UiColors.white.withValues(alpha: 0.8), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_position_card.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_position_card.dart new file mode 100644 index 00000000..25b9b02f --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_position_card.dart @@ -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 onUpdated; + final VoidCallback onRemoved; + final String positionLabel; + final String roleLabel; + final String workersLabel; + final String startLabel; + final String endLabel; + final String lunchLabel; + final List 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: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + 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( + 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: [ + // 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: [ + 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: [ + 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( + 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: [ + '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( + 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: [ + 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: [ + Text( + value.isEmpty ? '--:--' : value, + style: UiTypography.body2r.textPrimary, + ), + const Icon( + UiIcons.clock, + size: 14, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ), + ], + ); + } + + List> _buildRoleItems() { + final List> items = roles + .map( + (OrderRoleUiModel role) => DropdownMenuItem( + 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( + value: position.role, + child: Text( + position.role, + style: UiTypography.body2r.textPrimary, + ), + ), + ); + } + + return items; + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_section_header.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_section_header.dart new file mode 100644 index 00000000..21d47825 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_section_header.dart @@ -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: [ + 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: [ + const Icon(UiIcons.add, size: 16, color: UiColors.primary), + const SizedBox(width: UiConstants.space2), + Text( + actionLabel!, + style: UiTypography.body2m.primary, + ), + ], + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_success_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_success_view.dart new file mode 100644 index 00000000..a4b72cbc --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_success_view.dart @@ -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: [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( + color: UiColors.black.withValues(alpha: 0.2), + blurRadius: 20, + offset: const Offset(0, UiConstants.space2 + 2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + 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, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart new file mode 100644 index 00000000..c33d3641 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart @@ -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 vendors; + final DateTime startDate; + final List permanentDays; + final OrderHubUiModel? selectedHub; + final List hubs; + final List positions; + final List roles; + final bool isValid; + + final ValueChanged onEventNameChanged; + final ValueChanged onVendorChanged; + final ValueChanged onStartDateChanged; + final ValueChanged onDayToggled; + final ValueChanged 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: [ + PermanentOrderHeader( + title: labels.title, + subtitle: labels.subtitle, + onBack: onBack, + ), + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + 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: [ + PermanentOrderHeader( + title: labels.title, + subtitle: labels.subtitle, + onBack: onBack, + ), + Expanded( + child: Stack( + children: [ + _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 vendors; + final DateTime startDate; + final List permanentDays; + final OrderHubUiModel? selectedHub; + final List hubs; + final List positions; + final List roles; + + final ValueChanged onEventNameChanged; + final ValueChanged onVendorChanged; + final ValueChanged onStartDateChanged; + final ValueChanged onDayToggled; + final ValueChanged 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: [ + 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( + 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( + 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( + 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( + 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 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 selectedDays; + final ValueChanged onToggle; + + @override + Widget build(BuildContext context) { + const List labelsShort = [ + 'S', + 'M', + 'T', + 'W', + 'T', + 'F', + 'S', + ]; + const List labelsLong = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + return Wrap( + spacing: UiConstants.space2, + children: List.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, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_date_picker.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_date_picker.dart new file mode 100644 index 00000000..f9b7df68 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_date_picker.dart @@ -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 onChanged; + + @override + State createState() => + _RecurringOrderDatePickerState(); +} + +class _RecurringOrderDatePickerState extends State { + 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); + } + }, + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_event_name_input.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_event_name_input.dart new file mode 100644 index 00000000..22d7cae9 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_event_name_input.dart @@ -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 onChanged; + + @override + State createState() => + _RecurringOrderEventNameInputState(); +} + +class _RecurringOrderEventNameInputState + extends State { + 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, + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_header.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_header.dart new file mode 100644 index 00000000..5913b205 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_header.dart @@ -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: [ + 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: [ + Text( + title, + style: UiTypography.headline3m.copyWith(color: UiColors.white), + ), + Text( + subtitle, + style: UiTypography.footnote2r.copyWith( + color: UiColors.white.withValues(alpha: 0.8), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_position_card.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_position_card.dart new file mode 100644 index 00000000..d6c038af --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_position_card.dart @@ -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 onUpdated; + final VoidCallback onRemoved; + final String positionLabel; + final String roleLabel; + final String workersLabel; + final String startLabel; + final String endLabel; + final String lunchLabel; + final List 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: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + 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( + 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: [ + // 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: [ + 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: [ + 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( + 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: [ + '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( + 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: [ + 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: [ + Text( + value.isEmpty ? '--:--' : value, + style: UiTypography.body2r.textPrimary, + ), + const Icon( + UiIcons.clock, + size: 14, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ), + ], + ); + } + + List> _buildRoleItems() { + final List> items = roles + .map( + (OrderRoleUiModel role) => DropdownMenuItem( + 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( + value: position.role, + child: Text( + position.role, + style: UiTypography.body2r.textPrimary, + ), + ), + ); + } + + return items; + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_section_header.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_section_header.dart new file mode 100644 index 00000000..85326cb6 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_section_header.dart @@ -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: [ + 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: [ + const Icon(UiIcons.add, size: 16, color: UiColors.primary), + const SizedBox(width: UiConstants.space2), + Text( + actionLabel!, + style: UiTypography.body2m.primary, + ), + ], + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_success_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_success_view.dart new file mode 100644 index 00000000..3739c5ad --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_success_view.dart @@ -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: [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( + color: UiColors.black.withValues(alpha: 0.2), + blurRadius: 20, + offset: const Offset(0, UiConstants.space2 + 2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + 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, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart new file mode 100644 index 00000000..18c01872 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart @@ -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 vendors; + final DateTime startDate; + final DateTime endDate; + final List recurringDays; + final OrderHubUiModel? selectedHub; + final List hubs; + final List positions; + final List roles; + final bool isValid; + + final ValueChanged onEventNameChanged; + final ValueChanged onVendorChanged; + final ValueChanged onStartDateChanged; + final ValueChanged onEndDateChanged; + final ValueChanged onDayToggled; + final ValueChanged 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: [ + RecurringOrderHeader( + title: labels.title, + subtitle: labels.subtitle, + onBack: onBack, + ), + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + 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: [ + RecurringOrderHeader( + title: labels.title, + subtitle: labels.subtitle, + onBack: onBack, + ), + Expanded( + child: Stack( + children: [ + _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 vendors; + final DateTime startDate; + final DateTime endDate; + final List recurringDays; + final OrderHubUiModel? selectedHub; + final List hubs; + final List positions; + final List roles; + + final ValueChanged onEventNameChanged; + final ValueChanged onVendorChanged; + final ValueChanged onStartDateChanged; + final ValueChanged onEndDateChanged; + final ValueChanged onDayToggled; + final ValueChanged 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: [ + 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( + 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( + 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( + 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( + 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 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 selectedDays; + final ValueChanged onToggle; + + @override + Widget build(BuildContext context) { + const List labelsShort = [ + 'S', + 'M', + 'T', + 'W', + 'T', + 'F', + 'S', + ]; + const List labelsLong = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + return Wrap( + spacing: UiConstants.space2, + children: List.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, + ), + ), + ); + } +}