From 960b21ec8c6a03019eb32898189323ca23d107d9 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 23 Jan 2026 11:28:51 -0500 Subject: [PATCH 1/5] Add client view orders feature module Introduces the client 'View Orders' feature, including domain entity, repository, use case, Cubit, state, navigation extension, UI page, and widget. Integrates the feature into the client main module, updates localization files for English and Spanish, and adds supporting icons to the design system. Also updates the mock repository to provide sample order data. --- .../lib/src/l10n/en.i18n.json | 29 + .../lib/src/l10n/es.i18n.json | 29 + .../lib/src/mocks/order_repository_mock.dart | 117 +++- .../design_system/lib/src/ui_icons.dart | 9 + .../packages/domain/lib/krow_domain.dart | 1 + .../lib/src/entities/orders/order_item.dart | 80 +++ .../lib/src/client_main_module.dart | 7 +- .../features/client/client_main/pubspec.yaml | 2 + .../view_orders_repository_impl.dart | 17 + .../i_view_orders_repository.dart | 7 + .../domain/usecases/get_orders_use_case.dart | 19 + .../presentation/blocs/view_orders_cubit.dart | 156 ++++++ .../presentation/blocs/view_orders_state.dart | 70 +++ .../navigation/view_orders_navigator.dart | 14 + .../presentation/pages/view_orders_page.dart | 472 ++++++++++++++++ .../presentation/widgets/view_order_card.dart | 525 ++++++++++++++++++ .../lib/src/view_orders_module.dart | 39 ++ .../client/view_orders/lib/view_orders.dart | 3 + .../features/client/view_orders/pubspec.yaml | 42 ++ apps/mobile/pubspec.lock | 64 +++ apps/mobile/pubspec.yaml | 1 + 21 files changed, 1695 insertions(+), 8 deletions(-) create mode 100644 apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart create mode 100644 apps/mobile/packages/features/client/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart create mode 100644 apps/mobile/packages/features/client/view_orders/lib/src/domain/repositories/i_view_orders_repository.dart create mode 100644 apps/mobile/packages/features/client/view_orders/lib/src/domain/usecases/get_orders_use_case.dart create mode 100644 apps/mobile/packages/features/client/view_orders/lib/src/presentation/blocs/view_orders_cubit.dart create mode 100644 apps/mobile/packages/features/client/view_orders/lib/src/presentation/blocs/view_orders_state.dart create mode 100644 apps/mobile/packages/features/client/view_orders/lib/src/presentation/navigation/view_orders_navigator.dart create mode 100644 apps/mobile/packages/features/client/view_orders/lib/src/presentation/pages/view_orders_page.dart create mode 100644 apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart create mode 100644 apps/mobile/packages/features/client/view_orders/lib/src/view_orders_module.dart create mode 100644 apps/mobile/packages/features/client/view_orders/lib/view_orders.dart create mode 100644 apps/mobile/packages/features/client/view_orders/pubspec.yaml diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 0d5db935..f97c1777 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -311,6 +311,35 @@ "orders": "Orders", "reports": "Reports" } + }, + "client_view_orders": { + "title": "Orders", + "post_button": "Post", + "post_order": "Post an Order", + "no_orders": "No orders for $date", + "tabs": { + "up_next": "Up Next", + "active": "Active", + "completed": "Completed" + }, + "card": { + "open": "OPEN", + "filled": "FILLED", + "confirmed": "CONFIRMED", + "in_progress": "IN PROGRESS", + "completed": "COMPLETED", + "cancelled": "CANCELLED", + "get_direction": "Get direction", + "total": "Total", + "hrs": "HRS", + "workers": "workers", + "clock_in": "CLOCK IN", + "clock_out": "CLOCK OUT", + "coverage": "Coverage", + "workers_label": "$filled/$needed Workers", + "confirmed_workers": "Workers Confirmed", + "no_workers": "No workers confirmed yet." + } } } diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index fcabb08d..c141a406 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -311,5 +311,34 @@ "orders": "Órdenes", "reports": "Reportes" } + }, + "client_view_orders": { + "title": "Órdenes", + "post_button": "Publicar", + "post_order": "Publicar una Orden", + "no_orders": "No hay órdenes para $date", + "tabs": { + "up_next": "Próximos", + "active": "Activos", + "completed": "Completados" + }, + "card": { + "open": "ABIERTO", + "filled": "LLENO", + "confirmed": "CONFIRMADO", + "in_progress": "EN PROGRESO", + "completed": "COMPLETADO", + "cancelled": "CANCELADO", + "get_direction": "Obtener dirección", + "total": "Total", + "hrs": "HRS", + "workers": "trabajadores", + "clock_in": "ENTRADA", + "clock_out": "SALIDA", + "coverage": "Cobertura", + "workers_label": "$filled/$needed Trabajadores", + "confirmed_workers": "Trabajadores Confirmados", + "no_workers": "Ningún trabajador confirmado aún." + } } } diff --git a/apps/mobile/packages/data_connect/lib/src/mocks/order_repository_mock.dart b/apps/mobile/packages/data_connect/lib/src/mocks/order_repository_mock.dart index 8e7979ea..95c6f025 100644 --- a/apps/mobile/packages/data_connect/lib/src/mocks/order_repository_mock.dart +++ b/apps/mobile/packages/data_connect/lib/src/mocks/order_repository_mock.dart @@ -7,7 +7,7 @@ import 'package:krow_domain/krow_domain.dart'; class OrderRepositoryMock { /// Returns a list of available [OrderType]s. Future> getOrderTypes() async { - await Future.delayed(const Duration(milliseconds: 500)); + await Future.delayed(const Duration(milliseconds: 500)); return const [ OrderType( id: 'rapid', @@ -34,11 +34,122 @@ class OrderRepositoryMock { /// Simulates creating a one-time order. Future createOneTimeOrder(OneTimeOrder order) async { - await Future.delayed(const Duration(milliseconds: 800)); + await Future.delayed(const Duration(milliseconds: 800)); } /// Simulates creating a rapid order. Future createRapidOrder(String description) async { - await Future.delayed(const Duration(seconds: 1)); + await Future.delayed(const Duration(seconds: 1)); + } + + /// Returns a mock list of client orders. + Future> getOrders() async { + await Future.delayed(const Duration(milliseconds: 500)); + return [ + OrderItem( + id: '1', + title: 'Server - Wedding', + clientName: 'Grand Plaza Hotel', + status: 'filled', + date: DateTime.now() + .add(const Duration(days: 1)) + .toIso8601String() + .split('T')[0], + startTime: '16:00', + endTime: '23:00', + location: 'Grand Plaza Hotel, 123 Main St', + locationAddress: 'Grand Plaza Hotel, 123 Main St', + filled: 10, + workersNeeded: 10, + hourlyRate: 22.0, + confirmedApps: List>.generate( + 10, + (int index) => { + 'id': 'app_$index', + 'worker_id': 'w_$index', + 'worker_name': 'Worker ${String.fromCharCode(65 + index)}', + 'status': 'confirmed', + 'check_in_time': index < 5 ? '15:55' : null, + }, + ), + ), + OrderItem( + id: '2', + title: 'Bartender - Private Event', + clientName: 'Taste of the Town', + status: 'open', + date: DateTime.now() + .add(const Duration(days: 1)) + .toIso8601String() + .split('T')[0], + startTime: '18:00', + endTime: '02:00', + location: 'Downtown Loft, 456 High St', + locationAddress: 'Downtown Loft, 456 High St', + filled: 4, + workersNeeded: 5, + hourlyRate: 28.0, + confirmedApps: List>.generate( + 4, + (int index) => { + 'id': 'app_b_$index', + 'worker_id': 'w_b_$index', + 'worker_name': 'Bartender ${index + 1}', + 'status': 'confirmed', + }, + ), + ), + OrderItem( + id: '3', + title: 'Event Staff', + clientName: 'City Center', + status: 'in_progress', + date: DateTime.now().toIso8601String().split('T')[0], + startTime: '08:00', + endTime: '16:00', + location: 'Convention Center, 789 Blvd', + locationAddress: 'Convention Center, 789 Blvd', + filled: 15, + workersNeeded: 15, + hourlyRate: 20.0, + confirmedApps: List>.generate( + 15, + (int index) => { + 'id': 'app_c_$index', + 'worker_id': 'w_c_$index', + 'worker_name': 'Staff ${index + 1}', + 'status': 'confirmed', + 'check_in_time': '07:55', + }, + ), + ), + OrderItem( + id: '4', + title: 'Coat Check', + clientName: 'The Met Museum', + status: 'completed', + date: DateTime.now() + .subtract(const Duration(days: 1)) + .toIso8601String() + .split('T')[0], + startTime: '17:00', + endTime: '22:00', + location: 'The Met Museum, 1000 5th Ave', + locationAddress: 'The Met Museum, 1000 5th Ave', + filled: 2, + workersNeeded: 2, + hourlyRate: 18.0, + confirmedApps: List>.generate( + 2, + (int index) => { + 'id': 'app_d_$index', + 'worker_id': 'w_d_$index', + 'worker_name': 'Checker ${index + 1}', + 'status': 'confirmed', + 'check_in_time': '16:50', + }, + ), + ), + ]; } } diff --git a/apps/mobile/packages/design_system/lib/src/ui_icons.dart b/apps/mobile/packages/design_system/lib/src/ui_icons.dart index c24c5140..2b3d3669 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_icons.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_icons.dart @@ -34,6 +34,9 @@ class UiIcons { /// User icon for profile static const IconData user = _IconLib.user; + /// Users icon for groups or staff + static const IconData users = _IconLib.users; + /// Settings icon static const IconData settings = _IconLib.settings; @@ -81,6 +84,9 @@ class UiIcons { /// Chevron down icon static const IconData chevronDown = _IconLib.chevronDown; + /// Chevron up icon + static const IconData chevronUp = _IconLib.chevronUp; + // --- Status & Feedback --- /// Info icon @@ -139,6 +145,9 @@ class UiIcons { /// Sparkles icon for features or AI static const IconData sparkles = _IconLib.sparkles; + /// Navigation/Compass icon + static const IconData navigation = _IconLib.navigation; + /// Star icon for ratings static const IconData star = _IconLib.star; diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index 07c99633..f4d6110b 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -31,6 +31,7 @@ export 'src/entities/events/work_session.dart'; export 'src/entities/orders/order_type.dart'; export 'src/entities/orders/one_time_order.dart'; export 'src/entities/orders/one_time_order_position.dart'; +export 'src/entities/orders/order_item.dart'; // Skills & Certs export 'src/entities/skills/skill.dart'; diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart b/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart new file mode 100644 index 00000000..6950c7b6 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart @@ -0,0 +1,80 @@ +import 'package:equatable/equatable.dart'; + +/// Represents a customer's view of an order or shift. +/// +/// This entity captures the details necessary for the dashboard/view orders screen, +/// including status and worker assignments. +class OrderItem extends Equatable { + /// Creates an [OrderItem]. + const OrderItem({ + required this.id, + required this.title, + required this.clientName, + required this.status, + required this.date, + required this.startTime, + required this.endTime, + required this.location, + required this.locationAddress, + required this.filled, + required this.workersNeeded, + required this.hourlyRate, + this.confirmedApps = const >[], + }); + + /// Unique identifier of the order. + final String id; + + /// Title or name of the role. + final String title; + + /// Name of the client company. + final String clientName; + + /// status of the order (e.g., 'open', 'filled', 'completed'). + final String status; + + /// Date of the shift (ISO format). + final String date; + + /// Start time of the shift. + final String startTime; + + /// End time of the shift. + final String endTime; + + /// Location name. + final String location; + + /// Full address of the location. + final String locationAddress; + + /// Number of workers currently filled. + final int filled; + + /// Total number of workers required. + final int workersNeeded; + + /// Hourly pay rate. + final double hourlyRate; + + /// List of confirmed worker applications. + final List> confirmedApps; + + @override + List get props => [ + id, + title, + clientName, + status, + date, + startTime, + endTime, + location, + locationAddress, + filled, + workersNeeded, + hourlyRate, + confirmedApps, + ]; +} diff --git a/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart b/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart index 60337e31..2569e7fd 100644 --- a/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart +++ b/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart @@ -1,6 +1,7 @@ import 'package:client_home/client_home.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:view_orders/view_orders.dart'; import 'presentation/blocs/client_main_cubit.dart'; import 'presentation/pages/client_main_page.dart'; @@ -30,11 +31,7 @@ class ClientMainModule extends Module { child: (BuildContext context) => const PlaceholderPage(title: 'Billing'), ), - ChildRoute( - '/orders', - child: (BuildContext context) => - const PlaceholderPage(title: 'Orders'), - ), + ModuleRoute('/orders', module: ViewOrdersModule()), ChildRoute( '/reports', child: (BuildContext context) => diff --git a/apps/mobile/packages/features/client/client_main/pubspec.yaml b/apps/mobile/packages/features/client/client_main/pubspec.yaml index 48a037b6..c2ac9a6f 100644 --- a/apps/mobile/packages/features/client/client_main/pubspec.yaml +++ b/apps/mobile/packages/features/client/client_main/pubspec.yaml @@ -23,6 +23,8 @@ dependencies: path: ../../../core_localization client_home: path: ../home + view_orders: + path: ../view_orders # Intentionally commenting these out as they might not exist yet # client_settings: # path: ../settings diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart b/apps/mobile/packages/features/client/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart new file mode 100644 index 00000000..cdc54d00 --- /dev/null +++ b/apps/mobile/packages/features/client/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart @@ -0,0 +1,17 @@ +import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../../domain/repositories/i_view_orders_repository.dart'; + +/// Implementation of [IViewOrdersRepository] providing data from [OrderRepositoryMock]. +class ViewOrdersRepositoryImpl implements IViewOrdersRepository { + final OrderRepositoryMock _orderRepositoryMock; + + /// Creates a [ViewOrdersRepositoryImpl] with the given [OrderRepositoryMock]. + ViewOrdersRepositoryImpl({required OrderRepositoryMock orderRepositoryMock}) + : _orderRepositoryMock = orderRepositoryMock; + + @override + Future> getOrders() { + return _orderRepositoryMock.getOrders(); + } +} diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/domain/repositories/i_view_orders_repository.dart b/apps/mobile/packages/features/client/view_orders/lib/src/domain/repositories/i_view_orders_repository.dart new file mode 100644 index 00000000..d6b129ed --- /dev/null +++ b/apps/mobile/packages/features/client/view_orders/lib/src/domain/repositories/i_view_orders_repository.dart @@ -0,0 +1,7 @@ +import 'package:krow_domain/krow_domain.dart'; + +/// Repository interface for fetching and managing client orders. +abstract class IViewOrdersRepository { + /// Fetches a list of [OrderItem] for the client. + Future> getOrders(); +} diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/domain/usecases/get_orders_use_case.dart b/apps/mobile/packages/features/client/view_orders/lib/src/domain/usecases/get_orders_use_case.dart new file mode 100644 index 00000000..3f0018e2 --- /dev/null +++ b/apps/mobile/packages/features/client/view_orders/lib/src/domain/usecases/get_orders_use_case.dart @@ -0,0 +1,19 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../repositories/i_view_orders_repository.dart'; + +/// Use case for retrieving the list of client orders. +/// +/// This use case encapsulates the business rule of fetching orders +/// and delegates the data retrieval to the [IViewOrdersRepository]. +class GetOrdersUseCase implements NoInputUseCase> { + final IViewOrdersRepository _repository; + + /// Creates a [GetOrdersUseCase] with the required [IViewOrdersRepository]. + GetOrdersUseCase(this._repository); + + @override + Future> call() { + return _repository.getOrders(); + } +} diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/blocs/view_orders_cubit.dart b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/blocs/view_orders_cubit.dart new file mode 100644 index 00000000..72ecfb6c --- /dev/null +++ b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/blocs/view_orders_cubit.dart @@ -0,0 +1,156 @@ +import 'package:intl/intl.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../../domain/usecases/get_orders_use_case.dart'; +import 'view_orders_state.dart'; + +/// Cubit for managing the state of the View Orders feature. +/// +/// This Cubit handles loading orders, date selection, and tab filtering. +class ViewOrdersCubit extends Cubit { + ViewOrdersCubit({required GetOrdersUseCase getOrdersUseCase}) + : _getOrdersUseCase = getOrdersUseCase, + super(ViewOrdersState(selectedDate: DateTime.now())) { + _init(); + } + + final GetOrdersUseCase _getOrdersUseCase; + + void _init() { + updateWeekOffset(0); // Initialize calendar days + loadOrders(); + } + + /// Loads the list of orders using the [GetOrdersUseCase]. + Future loadOrders() async { + emit(state.copyWith(status: ViewOrdersStatus.loading)); + try { + final List orders = await _getOrdersUseCase(); + emit(state.copyWith(status: ViewOrdersStatus.success, orders: orders)); + _updateDerivedState(); + } catch (_) { + emit(state.copyWith(status: ViewOrdersStatus.failure)); + } + } + + void selectDate(DateTime date) { + emit(state.copyWith(selectedDate: date)); + _updateDerivedState(); + } + + void selectFilterTab(String tabId) { + emit(state.copyWith(filterTab: tabId)); + _updateDerivedState(); + } + + void updateWeekOffset(int offset) { + final int newWeekOffset = state.weekOffset + offset; + final List calendarDays = _calculateCalendarDays(newWeekOffset); + emit(state.copyWith(weekOffset: newWeekOffset, calendarDays: calendarDays)); + _updateDerivedState(); + } + + void _updateDerivedState() { + final List filteredOrders = _calculateFilteredOrders(state); + final int activeCount = _calculateCategoryCount('active'); + final int completedCount = _calculateCategoryCount('completed'); + final int upNextCount = _calculateUpNextCount(); + + emit( + state.copyWith( + filteredOrders: filteredOrders, + activeCount: activeCount, + completedCount: completedCount, + upNextCount: upNextCount, + ), + ); + } + + List _calculateCalendarDays(int weekOffset) { + final DateTime now = DateTime.now(); + final int jsDay = now.weekday == 7 ? 0 : now.weekday; + final int daysSinceFriday = (jsDay + 2) % 7; + + final DateTime startDate = DateTime(now.year, now.month, now.day) + .subtract(Duration(days: daysSinceFriday)) + .add(Duration(days: weekOffset * 7)); + + return List.generate( + 7, + (int index) => startDate.add(Duration(days: index)), + ); + } + + List _calculateFilteredOrders(ViewOrdersState state) { + if (state.selectedDate == null) return []; + + final String selectedDateStr = DateFormat( + 'yyyy-MM-dd', + ).format(state.selectedDate!); + + // Filter by date + final List ordersOnDate = state.orders + .where((OrderItem s) => s.date == selectedDateStr) + .toList(); + + // Sort by start time + ordersOnDate.sort( + (OrderItem a, OrderItem b) => a.startTime.compareTo(b.startTime), + ); + + if (state.filterTab == 'all') { + return ordersOnDate + .where( + (OrderItem s) => + ['open', 'filled', 'confirmed'].contains(s.status), + ) + .toList(); + } else if (state.filterTab == 'active') { + return ordersOnDate + .where((OrderItem s) => s.status == 'in_progress') + .toList(); + } else if (state.filterTab == 'completed') { + return ordersOnDate + .where((OrderItem s) => s.status == 'completed') + .toList(); + } + return []; + } + + int _calculateCategoryCount(String category) { + if (state.selectedDate == null) return 0; + final String selectedDateStr = DateFormat( + 'yyyy-MM-dd', + ).format(state.selectedDate!); + final List ordersOnDate = state.orders + .where((OrderItem s) => s.date == selectedDateStr) + .toList(); + + if (category == 'active') { + return ordersOnDate + .where((OrderItem s) => s.status == 'in_progress') + .length; + } else if (category == 'completed') { + return ordersOnDate + .where((OrderItem s) => s.status == 'completed') + .length; + } + return 0; + } + + int _calculateUpNextCount() { + if (state.selectedDate == null) return 0; + final String selectedDateStr = DateFormat( + 'yyyy-MM-dd', + ).format(state.selectedDate!); + final List ordersOnDate = state.orders + .where((OrderItem s) => s.date == selectedDateStr) + .toList(); + return ordersOnDate + .where( + (OrderItem s) => + ['open', 'filled', 'confirmed'].contains(s.status), + ) + .length; + } +} diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/blocs/view_orders_state.dart b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/blocs/view_orders_state.dart new file mode 100644 index 00000000..af67fa19 --- /dev/null +++ b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/blocs/view_orders_state.dart @@ -0,0 +1,70 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +enum ViewOrdersStatus { initial, loading, success, failure } + +class ViewOrdersState extends Equatable { + const ViewOrdersState({ + this.status = ViewOrdersStatus.initial, + this.orders = const [], + this.filteredOrders = const [], + this.calendarDays = const [], + this.selectedDate, + this.filterTab = 'all', + this.weekOffset = 0, + this.activeCount = 0, + this.completedCount = 0, + this.upNextCount = 0, + }); + + final ViewOrdersStatus status; + final List orders; + final List filteredOrders; + final List calendarDays; + final DateTime? selectedDate; + final String filterTab; + final int weekOffset; + final int activeCount; + final int completedCount; + final int upNextCount; + + ViewOrdersState copyWith({ + ViewOrdersStatus? status, + List? orders, + List? filteredOrders, + List? calendarDays, + DateTime? selectedDate, + String? filterTab, + int? weekOffset, + int? activeCount, + int? completedCount, + int? upNextCount, + }) { + return ViewOrdersState( + status: status ?? this.status, + orders: orders ?? this.orders, + filteredOrders: filteredOrders ?? this.filteredOrders, + calendarDays: calendarDays ?? this.calendarDays, + selectedDate: selectedDate ?? this.selectedDate, + filterTab: filterTab ?? this.filterTab, + weekOffset: weekOffset ?? this.weekOffset, + activeCount: activeCount ?? this.activeCount, + completedCount: completedCount ?? this.completedCount, + upNextCount: upNextCount ?? this.upNextCount, + ); + } + + @override + List get props => [ + status, + orders, + filteredOrders, + calendarDays, + selectedDate, + filterTab, + weekOffset, + activeCount, + completedCount, + upNextCount, + ]; +} diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/navigation/view_orders_navigator.dart b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/navigation/view_orders_navigator.dart new file mode 100644 index 00000000..7160bb59 --- /dev/null +++ b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/navigation/view_orders_navigator.dart @@ -0,0 +1,14 @@ +import 'package:flutter_modular/flutter_modular.dart'; + +/// Extension to provide typed navigation for the View Orders feature. +extension ViewOrdersNavigator on IModularNavigator { + /// Navigates to the Create Order feature. + void navigateToCreateOrder() { + pushNamed('/client/create-order/'); + } + + /// Navigates to the Order Details (placeholder for now). + void navigateToOrderDetails(String orderId) { + // pushNamed('/view-orders/$orderId'); + } +} diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/pages/view_orders_page.dart b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/pages/view_orders_page.dart new file mode 100644 index 00000000..c47b8518 --- /dev/null +++ b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/pages/view_orders_page.dart @@ -0,0 +1,472 @@ +import 'dart:ui'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:intl/intl.dart'; + +import 'package:core_localization/core_localization.dart'; +import '../blocs/view_orders_cubit.dart'; +import '../blocs/view_orders_state.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../widgets/view_order_card.dart'; +import '../navigation/view_orders_navigator.dart'; + +/// The main page for viewing client orders. +/// +/// This page follows the KROW Clean Architecture by: +/// - Being a [StatelessWidget]. +/// - Using [ViewOrdersCubit] for state management. +/// - Adhering to the project's Design System. +class ViewOrdersPage extends StatelessWidget { + /// Creates a [ViewOrdersPage]. + const ViewOrdersPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (BuildContext context) => Modular.get(), + child: const ViewOrdersView(), + ); + } +} + +/// The internal view implementation for [ViewOrdersPage]. +class ViewOrdersView extends StatelessWidget { + /// Creates a [ViewOrdersView]. + const ViewOrdersView({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (BuildContext context, ViewOrdersState state) { + final List calendarDays = state.calendarDays; + final List filteredOrders = state.filteredOrders; + + // Header Colors logic from prototype + String sectionTitle = ''; + Color dotColor = UiColors.transparent; + + if (state.filterTab == 'all') { + sectionTitle = t.client_view_orders.tabs.up_next; + dotColor = UiColors.primary; + } else if (state.filterTab == 'active') { + sectionTitle = t.client_view_orders.tabs.active; + dotColor = UiColors.textWarning; + } else if (state.filterTab == 'completed') { + sectionTitle = t.client_view_orders.tabs.completed; + dotColor = + UiColors.primary; // Reverting to primary blue for consistency + } + + return Scaffold( + backgroundColor: UiColors.white, + body: Stack( + children: [ + // Background Gradient + Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [UiColors.bgSecondary, UiColors.white], + stops: [0.0, 0.3], + ), + ), + ), + + SafeArea( + child: Column( + children: [ + // Header + Filter + Calendar (Sticky behavior) + _buildHeader( + context: context, + state: state, + calendarDays: calendarDays, + ), + + // Content List + Expanded( + child: filteredOrders.isEmpty + ? _buildEmptyState(context: context, state: state) + : ListView( + padding: const EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space4, + UiConstants.space5, + 100, + ), + children: [ + if (filteredOrders.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + bottom: UiConstants.space3, + ), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: dotColor, + shape: BoxShape.circle, + ), + ), + const SizedBox( + width: UiConstants.space2, + ), + Text( + sectionTitle.toUpperCase(), + style: UiTypography.titleUppercase2m + .copyWith( + color: UiColors.textPrimary, + ), + ), + const SizedBox( + width: UiConstants.space1, + ), + Text( + '(${filteredOrders.length})', + style: UiTypography.footnote1r + .copyWith( + color: UiColors.textSecondary, + ), + ), + ], + ), + ), + ...filteredOrders.map( + (OrderItem order) => Padding( + padding: const EdgeInsets.only( + bottom: UiConstants.space3, + ), + child: ViewOrderCard(order: order), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } + + /// Builds the sticky header section. + Widget _buildHeader({ + required BuildContext context, + required ViewOrdersState state, + required List calendarDays, + }) { + return ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + decoration: const BoxDecoration( + color: Color(0xCCFFFFFF), // White with 0.8 alpha + border: Border( + bottom: BorderSide(color: UiColors.separatorSecondary), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Top Bar + Padding( + padding: const EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space5, + UiConstants.space5, + UiConstants.space3, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + t.client_view_orders.title, + style: UiTypography.headline3m.copyWith( + color: UiColors.textPrimary, + fontWeight: FontWeight.bold, + ), + ), + UiButton.primary( + text: t.client_view_orders.post_button, + leadingIcon: UiIcons.add, + onPressed: () => Modular.to.navigateToCreateOrder(), + size: UiButtonSize.small, + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 48), + maximumSize: const Size(0, 48), + ), + ), + ], + ), + ), + + // Filter Tabs + Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildFilterTab( + context, + label: t.client_view_orders.tabs.up_next, + isSelected: state.filterTab == 'all', + tabId: 'all', + ), + const SizedBox(width: UiConstants.space6), + _buildFilterTab( + context, + label: t.client_view_orders.tabs.active, + isSelected: state.filterTab == 'active', + tabId: 'active', + count: state.activeCount + state.upNextCount, + ), + const SizedBox(width: UiConstants.space6), + _buildFilterTab( + context, + label: t.client_view_orders.tabs.completed, + isSelected: state.filterTab == 'completed', + tabId: 'completed', + count: state.completedCount, + ), + ], + ), + ), + + // Calendar Header controls + Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + vertical: UiConstants.space2, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon( + UiIcons.chevronLeft, + size: 20, + color: UiColors.iconSecondary, + ), + onPressed: () => BlocProvider.of( + context, + ).updateWeekOffset(-1), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + splashRadius: 20, + ), + Text( + DateFormat('MMMM yyyy').format(calendarDays.first), + style: UiTypography.body2m.copyWith( + color: UiColors.textSecondary, + ), + ), + IconButton( + icon: const Icon( + UiIcons.chevronRight, + size: 20, + color: UiColors.iconSecondary, + ), + onPressed: () => BlocProvider.of( + context, + ).updateWeekOffset(1), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + splashRadius: 20, + ), + ], + ), + ), + + // Calendar Grid + SizedBox( + height: 72, + child: ListView.separated( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), + scrollDirection: Axis.horizontal, + itemCount: 7, + separatorBuilder: (BuildContext context, int index) => + const SizedBox(width: UiConstants.space2), + itemBuilder: (BuildContext context, int index) { + final DateTime date = calendarDays[index]; + final bool isSelected = + state.selectedDate != null && + date.year == state.selectedDate!.year && + date.month == state.selectedDate!.month && + date.day == state.selectedDate!.day; + + // Check if this date has any shifts + final String dateStr = DateFormat( + 'yyyy-MM-dd', + ).format(date); + final bool hasShifts = state.orders.any( + (OrderItem s) => s.date == dateStr, + ); + + return GestureDetector( + onTap: () => BlocProvider.of( + context, + ).selectDate(date), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 48, + decoration: BoxDecoration( + color: isSelected ? UiColors.primary : UiColors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected + ? UiColors.primary + : UiColors.separatorPrimary, + ), + boxShadow: isSelected + ? [ + BoxShadow( + color: UiColors.primary.withValues( + alpha: 0.25, + ), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ] + : null, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + DateFormat('dd').format(date), + style: UiTypography.title2b.copyWith( + fontSize: 18, + color: isSelected + ? UiColors.white + : UiColors.textPrimary, + ), + ), + Text( + DateFormat('E').format(date), + style: UiTypography.footnote2m.copyWith( + color: isSelected + ? UiColors.white.withValues(alpha: 0.8) + : UiColors.textSecondary, + ), + ), + if (hasShifts) ...[ + const SizedBox(height: UiConstants.space1), + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: isSelected + ? UiColors.white + : UiColors.primary, + shape: BoxShape.circle, + ), + ), + ], + ], + ), + ), + ); + }, + ), + ), + const SizedBox(height: UiConstants.space4), + ], + ), + ), + ), + ); + } + + /// Builds a single filter tab. + Widget _buildFilterTab( + BuildContext context, { + required String label, + required bool isSelected, + required String tabId, + int? count, + }) { + String text = label; + if (count != null) { + text = '$label ($count)'; + } + + return GestureDetector( + onTap: () => + BlocProvider.of(context).selectFilterTab(tabId), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space2), + child: Text( + text, + style: UiTypography.body2m.copyWith( + color: isSelected ? UiColors.primary : UiColors.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: 2, + width: isSelected ? 40 : 0, + decoration: BoxDecoration( + color: UiColors.primary, + borderRadius: BorderRadius.circular(2), + ), + ), + if (!isSelected) const SizedBox(height: 2), + ], + ), + ); + } + + /// Builds the empty state view. + Widget _buildEmptyState({ + required BuildContext context, + required ViewOrdersState state, + }) { + final String dateStr = state.selectedDate != null + ? _formatDateHeader(state.selectedDate!) + : 'this date'; + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(UiIcons.calendar, size: 48, color: UiColors.iconInactive), + const SizedBox(height: UiConstants.space3), + Text( + t.client_view_orders.no_orders(date: dateStr), + style: UiTypography.body2r.copyWith(color: UiColors.textSecondary), + ), + const SizedBox(height: UiConstants.space4), + UiButton.primary( + text: t.client_view_orders.post_order, + leadingIcon: UiIcons.add, + onPressed: () => Modular.to.navigateToCreateOrder(), + ), + ], + ), + ); + } + + static String _formatDateHeader(DateTime date) { + final DateTime now = DateTime.now(); + final DateTime today = DateTime(now.year, now.month, now.day); + final DateTime tomorrow = today.add(const Duration(days: 1)); + final DateTime checkDate = DateTime(date.year, date.month, date.day); + + if (checkDate == today) return 'Today'; + if (checkDate == tomorrow) return 'Tomorrow'; + return DateFormat('EEE, MMM d').format(date); + } +} diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart new file mode 100644 index 00000000..cec480ca --- /dev/null +++ b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart @@ -0,0 +1,525 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// A rich card displaying details of a client order/shift. +/// +/// This widget complies with the KROW Design System by using +/// tokens from `package:design_system`. +class ViewOrderCard extends StatefulWidget { + /// Creates a [ViewOrderCard] for the given [order]. + const ViewOrderCard({required this.order, super.key}); + + /// The order item to display. + final OrderItem order; + + @override + State createState() => _ViewOrderCardState(); +} + +class _ViewOrderCardState extends State { + bool _expanded = false; + + /// Returns the semantic color for the given status. + Color _getStatusColor({required String status}) { + switch (status) { + case 'open': + return UiColors.primary; + case 'filled': + case 'confirmed': + return UiColors.textSuccess; + case 'in_progress': + return UiColors.textWarning; + case 'completed': + return UiColors.primary; + case 'cancelled': + return UiColors.destructive; + default: + return UiColors.textSecondary; + } + } + + /// Returns the localized label for the given status. + String _getStatusLabel({required String status}) { + switch (status) { + case 'open': + return t.client_view_orders.card.open; + case 'filled': + return t.client_view_orders.card.filled; + case 'confirmed': + return t.client_view_orders.card.confirmed; + case 'in_progress': + return t.client_view_orders.card.in_progress; + case 'completed': + return t.client_view_orders.card.completed; + case 'cancelled': + return t.client_view_orders.card.cancelled; + default: + return status.toUpperCase(); + } + } + + /// Formats the date string for display. + String _formatDate({required String dateStr}) { + try { + final DateTime date = DateTime.parse(dateStr); + return DateFormat('EEE, MMM d').format(date); + } catch (_) { + return dateStr; + } + } + + /// Formats the time string for display. + String _formatTime({required String timeStr}) { + return timeStr; + } + + @override + Widget build(BuildContext context) { + final OrderItem order = widget.order; + final Color statusColor = _getStatusColor(status: order.status); + final String statusLabel = _getStatusLabel(status: order.status); + final int coveragePercent = order.workersNeeded > 0 + ? ((order.filled / order.workersNeeded) * 100).round() + : 0; + + // Simulation of cost/hours calculation + const double hours = 8.0; + final double cost = + order.hourlyRate * + hours * + (order.filled > 0 ? order.filled : order.workersNeeded); + + return Container( + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all( + color: UiColors.primary.withValues(alpha: 0.12), + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: UiColors.primary.withValues(alpha: 0.08), + blurRadius: 3, + offset: const Offset(0, 1), + ), + ], + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Row + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Status Dot & Label + Row( + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: statusColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: UiConstants.space2), + Text( + statusLabel, + style: UiTypography.footnote2b.copyWith( + color: statusColor, + letterSpacing: 0.5, + ), + ), + ], + ), + const SizedBox(height: UiConstants.space1), + // Title + Text( + order.title, + style: UiTypography.body1b.copyWith( + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: UiConstants.space1), + // Client & Date + Row( + children: [ + Text( + order.clientName, + style: UiTypography.body3r.copyWith( + color: UiColors.textSecondary, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space1, + ), + child: Text( + '•', + style: UiTypography.body3r.copyWith( + color: UiColors.textInactive, + ), + ), + ), + Text( + _formatDate(dateStr: order.date), + style: UiTypography.body3r.copyWith( + fontWeight: FontWeight.w500, + color: UiColors.textSecondary, + ), + ), + ], + ), + const SizedBox(height: UiConstants.space1), + // Address + Row( + children: [ + const Icon( + UiIcons.mapPin, + size: 12, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Expanded( + child: Text( + order.locationAddress, + style: UiTypography.footnote2r.copyWith( + color: UiColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: UiConstants.space1), + GestureDetector( + onTap: () { + // TODO: Get directions + }, + child: Row( + children: [ + const Icon( + UiIcons.navigation, + size: 12, + color: UiColors.primary, + ), + const SizedBox(width: 2), + Text( + t.client_view_orders.card.get_direction, + style: UiTypography.footnote2m.copyWith( + color: UiColors.primary, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + // Actions + Row( + children: [ + _buildHeaderIconButton( + icon: UiIcons.edit, + color: UiColors.primary, + bgColor: UiColors.tagInProgress, + onTap: () { + // TODO: Open edit sheet + }, + ), + const SizedBox(width: UiConstants.space2), + _buildHeaderIconButton( + icon: _expanded + ? UiIcons.chevronUp + : UiIcons.chevronDown, + color: UiColors.iconSecondary, + bgColor: UiColors.bgSecondary, + onTap: () => setState(() => _expanded = !_expanded), + ), + ], + ), + ], + ), + + const SizedBox(height: UiConstants.space3), + const Divider(height: 1, color: UiColors.separatorSecondary), + const SizedBox(height: UiConstants.space3), + + // Stats Row + Row( + children: [ + Expanded( + child: _buildStatItem( + icon: UiIcons.dollar, + value: '\$${cost.round()}', + label: t.client_view_orders.card.total, + ), + ), + Container( + width: 1, + height: 32, + color: UiColors.separatorSecondary, + ), + Expanded( + child: _buildStatItem( + icon: UiIcons.clock, + value: hours.toStringAsFixed(1), + label: t.client_view_orders.card.hrs, + ), + ), + Container( + width: 1, + height: 32, + color: UiColors.separatorSecondary, + ), + Expanded( + child: _buildStatItem( + icon: UiIcons.users, + value: + '${order.filled > 0 ? order.filled : order.workersNeeded}', + label: t.client_view_orders.card.workers, + ), + ), + ], + ), + + const SizedBox(height: UiConstants.space4), + + // Clock In/Out Boxes + Row( + children: [ + Expanded( + child: _buildTimeBox( + label: t.client_view_orders.card.clock_in, + time: _formatTime(timeStr: order.startTime), + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: _buildTimeBox( + label: t.client_view_orders.card.clock_out, + time: _formatTime(timeStr: order.endTime), + ), + ), + ], + ), + + const SizedBox(height: UiConstants.space3), + + // Coverage Bar + if (order.status != 'completed') ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Text( + t.client_view_orders.card.coverage, + style: UiTypography.footnote2b.copyWith( + color: UiColors.textSecondary, + ), + ), + const SizedBox(width: UiConstants.space1), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: coveragePercent == 100 + ? UiColors.tagSuccess + : UiColors.tagInProgress, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '$coveragePercent%', + style: UiTypography.footnote2b.copyWith( + fontSize: 9, + color: coveragePercent == 100 + ? UiColors.textSuccess + : UiColors.primary, + ), + ), + ), + ], + ), + Text( + t.client_view_orders.card.workers_label( + filled: order.filled, + needed: order.workersNeeded, + ), + style: UiTypography.footnote2m.copyWith( + color: UiColors.textSecondary, + ), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: coveragePercent / 100, + backgroundColor: UiColors.separatorSecondary, + valueColor: AlwaysStoppedAnimation( + coveragePercent == 100 + ? UiColors.textSuccess + : UiColors.primary, + ), + minHeight: 4, + ), + ), + ], + ], + ), + ), + + // Worker Avatars and more details (Expanded section) + if (_expanded) ...[ + const Divider(height: 1, color: UiColors.separatorSecondary), + Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.client_view_orders.card.confirmed_workers, + style: UiTypography.body2b.copyWith( + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: UiConstants.space3), + if (order.confirmedApps.isEmpty) + Text( + t.client_view_orders.card.no_workers, + style: UiTypography.body3r.copyWith( + color: UiColors.textInactive, + ), + ) + else + Wrap( + spacing: -8, + children: order.confirmedApps + .map( + (Map app) => Tooltip( + message: app['worker_name'] as String, + child: CircleAvatar( + radius: 14, + backgroundColor: UiColors.white, + child: CircleAvatar( + radius: 12, + backgroundColor: UiColors.bgSecondary, + child: Text( + (app['worker_name'] as String).substring( + 0, + 1, + ), + style: UiTypography.footnote2b, + ), + ), + ), + ), + ) + .toList(), + ), + ], + ), + ), + ], + ], + ), + ); + } + + /// Builds a small icon button used in row headers. + Widget _buildHeaderIconButton({ + required IconData icon, + required Color color, + required Color bgColor, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 14, color: color), + ), + ); + } + + /// Builds a single stat item (e.g., Cost, Hours, Workers). + Widget _buildStatItem({ + required IconData icon, + required String value, + required String label, + }) { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 10, color: UiColors.iconSecondary), + const SizedBox(width: UiConstants.space1), + Text( + value, + style: UiTypography.body2b.copyWith(color: UiColors.textPrimary), + ), + ], + ), + Text( + label.toUpperCase(), + style: UiTypography.titleUppercase4m.copyWith( + color: UiColors.textInactive, + fontWeight: FontWeight.bold, + ), + ), + ], + ); + } + + /// Builds a box displaying a label and a time value. + Widget _buildTimeBox({required String label, required String time}) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Text( + label, + style: UiTypography.titleUppercase4m.copyWith( + color: UiColors.textSecondary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Text( + time, + style: UiTypography.body2m.copyWith( + color: UiColors.foreground, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/view_orders_module.dart b/apps/mobile/packages/features/client/view_orders/lib/src/view_orders_module.dart new file mode 100644 index 00000000..32536f0b --- /dev/null +++ b/apps/mobile/packages/features/client/view_orders/lib/src/view_orders_module.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; + +import 'data/repositories/view_orders_repository_impl.dart'; +import 'domain/repositories/i_view_orders_repository.dart'; +import 'domain/usecases/get_orders_use_case.dart'; +import 'presentation/blocs/view_orders_cubit.dart'; +import 'presentation/pages/view_orders_page.dart'; + +/// Module for the View Orders feature. +/// +/// This module sets up Dependency Injection for repositories, use cases, +/// and BLoCs, and defines the feature's navigation routes. +class ViewOrdersModule extends Module { + @override + List get imports => [DataConnectModule()]; + + @override + void binds(Injector i) { + // Repositories + i.addLazySingleton( + () => ViewOrdersRepositoryImpl( + orderRepositoryMock: i.get(), + ), + ); + + // UseCases + i.addLazySingleton(GetOrdersUseCase.new); + + // BLoCs + i.addSingleton(ViewOrdersCubit.new); + } + + @override + void routes(RouteManager r) { + r.child('/', child: (BuildContext context) => const ViewOrdersPage()); + } +} diff --git a/apps/mobile/packages/features/client/view_orders/lib/view_orders.dart b/apps/mobile/packages/features/client/view_orders/lib/view_orders.dart new file mode 100644 index 00000000..87ab3a35 --- /dev/null +++ b/apps/mobile/packages/features/client/view_orders/lib/view_orders.dart @@ -0,0 +1,3 @@ +library; + +export 'src/view_orders_module.dart'; diff --git a/apps/mobile/packages/features/client/view_orders/pubspec.yaml b/apps/mobile/packages/features/client/view_orders/pubspec.yaml new file mode 100644 index 00000000..5cbf0541 --- /dev/null +++ b/apps/mobile/packages/features/client/view_orders/pubspec.yaml @@ -0,0 +1,42 @@ +name: view_orders +description: Client View Orders feature package +publish_to: 'none' +version: 1.0.0+1 +resolution: workspace + +environment: + sdk: '>=3.10.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + + # Architecture + flutter_modular: ^6.3.2 + flutter_bloc: ^8.1.3 + equatable: ^2.0.5 + + # Shared packages + design_system: + path: ../../../design_system + core_localization: + path: ../../../core_localization + krow_domain: + path: ../../../domain + krow_core: + path: ../../../core + + # UI + lucide_icons: ^0.257.0 + intl: ^0.20.1 + url_launcher: ^6.3.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + bloc_test: ^9.1.5 + mocktail: ^1.0.1 + +flutter: + uses-material-design: true diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 903fdd09..22b2d153 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -1114,6 +1114,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + url: "https://pub.dev" + source: hosted + version: "6.3.6" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: transitive description: diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index 1f8f5ec3..33599721 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -15,6 +15,7 @@ workspace: - packages/features/client/settings - packages/features/client/hubs - packages/features/client/create_order + - packages/features/client/view_orders - packages/features/client/client_main - apps/staff - apps/client From 868688fb02781cb2ac6a707bdc310fa1f23507fe Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 23 Jan 2026 11:53:36 -0500 Subject: [PATCH 2/5] Enhance UI button and order card features Added fullWidth support to UiButton and updated usages to ensure buttons span container width. Improved ViewOrderCard with expanded worker details, avatar stack, formatted date/time, and an order edit bottom sheet. Introduced new icons and typography style in the design system. --- .../design_system/lib/src/ui_icons.dart | 6 + .../design_system/lib/src/ui_theme.dart | 7 +- .../design_system/lib/src/ui_typography.dart | 9 + .../lib/src/widgets/ui_button.dart | 71 ++- .../pages/client_get_started_page.dart | 2 + .../client_sign_in_form.dart | 1 + .../presentation/widgets/view_order_card.dart | 547 +++++++++++++++--- 7 files changed, 543 insertions(+), 100 deletions(-) diff --git a/apps/mobile/packages/design_system/lib/src/ui_icons.dart b/apps/mobile/packages/design_system/lib/src/ui_icons.dart index 2b3d3669..6b04f468 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_icons.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_icons.dart @@ -163,6 +163,12 @@ class UiIcons { /// Eye off icon for hidden visibility static const IconData eyeOff = _IconLib.eyeOff; + /// Phone icon for calls + static const IconData phone = _IconLib.phone; + + /// Message circle icon for chat + static const IconData messageCircle = _IconLib.messageCircle; + /// Building icon for companies static const IconData building = _IconLib.building2; diff --git a/apps/mobile/packages/design_system/lib/src/ui_theme.dart b/apps/mobile/packages/design_system/lib/src/ui_theme.dart index 47252d81..92e295b2 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_theme.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_theme.dart @@ -12,8 +12,8 @@ class UiTheme { /// Returns the light theme for the Staff application. static ThemeData get light { - final colorScheme = UiColors.colorScheme; - final textTheme = UiTypography.textTheme; + final ColorScheme colorScheme = UiColors.colorScheme; + final TextTheme textTheme = UiTypography.textTheme; return ThemeData( useMaterial3: true, @@ -68,7 +68,6 @@ class UiTheme { horizontal: UiConstants.space6, vertical: UiConstants.space3, ), - minimumSize: const Size(double.infinity, 54), maximumSize: const Size(double.infinity, 54), ).copyWith( side: WidgetStateProperty.resolveWith((states) { @@ -99,7 +98,6 @@ class UiTheme { horizontal: UiConstants.space4, vertical: UiConstants.space2, ), - minimumSize: const Size(double.infinity, 52), maximumSize: const Size(double.infinity, 52), ), ), @@ -117,7 +115,6 @@ class UiTheme { horizontal: UiConstants.space4, vertical: UiConstants.space3, ), - minimumSize: const Size(double.infinity, 52), maximumSize: const Size(double.infinity, 52), ), ), diff --git a/apps/mobile/packages/design_system/lib/src/ui_typography.dart b/apps/mobile/packages/design_system/lib/src/ui_typography.dart index 9f3d5b99..dc795923 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_typography.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_typography.dart @@ -320,6 +320,15 @@ class UiTypography { color: UiColors.textPrimary, ); + /// Body 3 Medium - Font: Instrument Sans, Size: 14, Height: 1.5, Spacing: -0.1 (#121826) + static final TextStyle body3m = _primaryBase.copyWith( + fontWeight: FontWeight.w500, + fontSize: 12, + height: 1.5, + letterSpacing: -0.1, + color: UiColors.textPrimary, + ); + /// Body 4 Regular - Font: Instrument Sans, Size: 14, Height: 1.5, Spacing: 0.05 (#121826) static final TextStyle body4r = _primaryBase.copyWith( fontWeight: FontWeight.w400, diff --git a/apps/mobile/packages/design_system/lib/src/widgets/ui_button.dart b/apps/mobile/packages/design_system/lib/src/widgets/ui_button.dart index 4f0535c6..1460f07a 100644 --- a/apps/mobile/packages/design_system/lib/src/widgets/ui_button.dart +++ b/apps/mobile/packages/design_system/lib/src/widgets/ui_button.dart @@ -27,6 +27,9 @@ class UiButton extends StatelessWidget { /// The size of the button. final UiButtonSize size; + /// Whether the button should take up the full width of its container. + final bool fullWidth; + /// The button widget to use (ElevatedButton, OutlinedButton, or TextButton). final Widget Function( BuildContext context, @@ -48,6 +51,7 @@ class UiButton extends StatelessWidget { this.style, this.iconSize = 20, this.size = UiButtonSize.medium, + this.fullWidth = false, }) : assert( text != null || child != null, 'Either text or child must be provided', @@ -64,6 +68,7 @@ class UiButton extends StatelessWidget { this.style, this.iconSize = 20, this.size = UiButtonSize.medium, + this.fullWidth = false, }) : buttonBuilder = _elevatedButtonBuilder, assert( text != null || child != null, @@ -81,6 +86,7 @@ class UiButton extends StatelessWidget { this.style, this.iconSize = 20, this.size = UiButtonSize.medium, + this.fullWidth = false, }) : buttonBuilder = _outlinedButtonBuilder, assert( text != null || child != null, @@ -98,6 +104,25 @@ class UiButton extends StatelessWidget { this.style, this.iconSize = 20, this.size = UiButtonSize.medium, + this.fullWidth = false, + }) : buttonBuilder = _textButtonBuilder, + assert( + text != null || child != null, + 'Either text or child must be provided', + ); + + /// Creates a ghost button (transparent background). + UiButton.ghost({ + super.key, + this.text, + this.child, + this.onPressed, + this.leadingIcon, + this.trailingIcon, + this.style, + this.iconSize = 20, + this.size = UiButtonSize.medium, + this.fullWidth = false, }) : buttonBuilder = _textButtonBuilder, assert( text != null || child != null, @@ -107,7 +132,18 @@ class UiButton extends StatelessWidget { @override /// Builds the button UI. Widget build(BuildContext context) { - return buttonBuilder(context, onPressed, style, _buildButtonContent()); + final Widget button = buttonBuilder( + context, + onPressed, + style, + _buildButtonContent(), + ); + + if (fullWidth) { + return SizedBox(width: double.infinity, child: button); + } + + return button; } /// Builds the button content with optional leading and trailing icons. @@ -116,27 +152,40 @@ class UiButton extends StatelessWidget { return child!; } - // Single icon or text case + final String buttonText = text ?? ''; + + // Optimization: If no icons, return plain text to avoid Row layout overhead if (leadingIcon == null && trailingIcon == null) { - return Text(text!); + return Text(buttonText, textAlign: TextAlign.center); } - if (leadingIcon != null && text == null && trailingIcon == null) { - return Icon(leadingIcon, size: iconSize); - } - - // Multiple elements case + // Multiple elements case: Use a Row with MainAxisSize.min final List children = []; if (leadingIcon != null) { children.add(Icon(leadingIcon, size: iconSize)); - children.add(const SizedBox(width: UiConstants.space2)); } - children.add(Text(text!)); + if (buttonText.isNotEmpty) { + if (leadingIcon != null) { + children.add(const SizedBox(width: UiConstants.space2)); + } + // Use flexible to ensure text doesn't force infinite width in flex parents + children.add( + Flexible( + child: Text( + buttonText, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), + ), + ); + } if (trailingIcon != null) { - children.add(const SizedBox(width: UiConstants.space2)); + if (buttonText.isNotEmpty || leadingIcon != null) { + children.add(const SizedBox(width: UiConstants.space2)); + } children.add(Icon(trailingIcon, size: iconSize)); } diff --git a/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_get_started_page.dart b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_get_started_page.dart index f673e78d..380efaad 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_get_started_page.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_get_started_page.dart @@ -97,6 +97,7 @@ class ClientGetStartedPage extends StatelessWidget { .get_started_page .sign_in_button, onPressed: () => Modular.to.pushClientSignIn(), + fullWidth: true, ), const SizedBox(height: UiConstants.space3), @@ -108,6 +109,7 @@ class ClientGetStartedPage extends StatelessWidget { .get_started_page .create_account_button, onPressed: () => Modular.to.pushClientSignUp(), + fullWidth: true, ), ], ), diff --git a/apps/mobile/packages/features/client/authentication/lib/src/presentation/widgets/client_sign_in_page/client_sign_in_form.dart b/apps/mobile/packages/features/client/authentication/lib/src/presentation/widgets/client_sign_in_page/client_sign_in_form.dart index c1489ae6..9887a9cb 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/presentation/widgets/client_sign_in_page/client_sign_in_form.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/presentation/widgets/client_sign_in_page/client_sign_in_form.dart @@ -96,6 +96,7 @@ class _ClientSignInFormState extends State { UiButton.primary( text: widget.isLoading ? null : i18n.sign_in_button, onPressed: widget.isLoading ? null : _handleSubmit, + fullWidth: true, child: widget.isLoading ? const SizedBox( height: 24, diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart index cec480ca..93bb7175 100644 --- a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart +++ b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart @@ -20,7 +20,16 @@ class ViewOrderCard extends StatefulWidget { } class _ViewOrderCardState extends State { - bool _expanded = false; + bool _expanded = true; + + void _openEditSheet({required OrderItem order}) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (BuildContext context) => _OrderEditSheet(order: order), + ); + } /// Returns the semantic color for the given status. Color _getStatusColor({required String status}) { @@ -65,6 +74,13 @@ class _ViewOrderCardState extends State { String _formatDate({required String dateStr}) { try { final DateTime date = DateTime.parse(dateStr); + final DateTime now = DateTime.now(); + final DateTime today = DateTime(now.year, now.month, now.day); + final DateTime tomorrow = today.add(const Duration(days: 1)); + final DateTime checkDate = DateTime(date.year, date.month, date.day); + + if (checkDate == today) return 'Today'; + if (checkDate == tomorrow) return 'Tomorrow'; return DateFormat('EEE, MMM d').format(date); } catch (_) { return dateStr; @@ -73,7 +89,18 @@ class _ViewOrderCardState extends State { /// Formats the time string for display. String _formatTime({required String timeStr}) { - return timeStr; + if (timeStr.isEmpty) return ''; + try { + final List parts = timeStr.split(':'); + int hour = int.parse(parts[0]); + final int minute = int.parse(parts[1]); + final String ampm = hour >= 12 ? 'PM' : 'AM'; + hour = hour % 12; + if (hour == 0) hour = 12; + return '$hour:${minute.toString().padLeft(2, '0')} $ampm'; + } catch (_) { + return timeStr; + } } @override @@ -205,7 +232,7 @@ class _ViewOrderCardState extends State { const SizedBox(width: UiConstants.space1), GestureDetector( onTap: () { - // TODO: Get directions + // TODO: Handle location }, child: Row( children: [ @@ -236,9 +263,7 @@ class _ViewOrderCardState extends State { icon: UiIcons.edit, color: UiColors.primary, bgColor: UiColors.tagInProgress, - onTap: () { - // TODO: Open edit sheet - }, + onTap: () => _openEditSheet(order: order), ), const SizedBox(width: UiConstants.space2), _buildHeaderIconButton( @@ -319,120 +344,129 @@ class _ViewOrderCardState extends State { const SizedBox(height: UiConstants.space3), - // Coverage Bar + // Coverage Section if (order.status != 'completed') ...[ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ - Text( - t.client_view_orders.card.coverage, - style: UiTypography.footnote2b.copyWith( - color: UiColors.textSecondary, - ), + const Icon( + UiIcons.success, + size: 16, + color: UiColors.textSuccess, ), - const SizedBox(width: UiConstants.space1), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 1, + const SizedBox(width: 6), + Text( + t.client_view_orders.card.workers_label( + filled: order.filled, + needed: order.workersNeeded, ), - decoration: BoxDecoration( - color: coveragePercent == 100 - ? UiColors.tagSuccess - : UiColors.tagInProgress, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - '$coveragePercent%', - style: UiTypography.footnote2b.copyWith( - fontSize: 9, - color: coveragePercent == 100 - ? UiColors.textSuccess - : UiColors.primary, - ), + style: UiTypography.body2m.copyWith( + color: UiColors.textPrimary, ), ), ], ), Text( - t.client_view_orders.card.workers_label( - filled: order.filled, - needed: order.workersNeeded, - ), - style: UiTypography.footnote2m.copyWith( - color: UiColors.textSecondary, + '$coveragePercent%', + style: UiTypography.body2b.copyWith( + color: UiColors.primary, ), ), ], ), - const SizedBox(height: 6), + const SizedBox(height: 8), ClipRRect( - borderRadius: BorderRadius.circular(2), + borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: coveragePercent / 100, backgroundColor: UiColors.separatorSecondary, - valueColor: AlwaysStoppedAnimation( - coveragePercent == 100 - ? UiColors.textSuccess - : UiColors.primary, + valueColor: const AlwaysStoppedAnimation( + UiColors.primary, ), - minHeight: 4, + minHeight: 8, ), ), + + // Avatar Stack Preview (if not expanded) + if (!_expanded && order.confirmedApps.isNotEmpty) ...[ + const SizedBox(height: UiConstants.space3), + Row( + children: [ + _buildAvatarStack(order.confirmedApps), + if (order.confirmedApps.length > 3) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + '+${order.confirmedApps.length - 3} more', + style: UiTypography.footnote2r.copyWith( + color: UiColors.textSecondary, + ), + ), + ), + ], + ), + ], ], ], ), ), - // Worker Avatars and more details (Expanded section) - if (_expanded) ...[ - const Divider(height: 1, color: UiColors.separatorSecondary), - Padding( + // Assigned Workers (Expanded section) + if (_expanded && order.confirmedApps.isNotEmpty) ...[ + Container( + decoration: const BoxDecoration( + color: UiColors.bgSecondary, + border: Border( + top: BorderSide(color: UiColors.separatorSecondary), + ), + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(UiConstants.radiusBase), + ), + ), padding: const EdgeInsets.all(UiConstants.space4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - t.client_view_orders.card.confirmed_workers, - style: UiTypography.body2b.copyWith( - color: UiColors.textPrimary, - ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + t.client_view_orders.card.confirmed_workers, + style: UiTypography.footnote2b.copyWith( + color: UiColors.textSecondary, + ), + ), + UiButton.primary( + text: 'Message All', + leadingIcon: UiIcons.messageCircle, + size: UiButtonSize.small, + // style: ElevatedButton.styleFrom( + // minimumSize: const Size(0, 32), + // maximumSize: const Size(0, 32), + // ), + onPressed: () { + // TODO: Message all workers + }, + ), + ], ), const SizedBox(height: UiConstants.space3), - if (order.confirmedApps.isEmpty) - Text( - t.client_view_orders.card.no_workers, - style: UiTypography.body3r.copyWith( - color: UiColors.textInactive, + ...order.confirmedApps + .take(5) + .map((Map app) => _buildWorkerRow(app)), + if (order.confirmedApps.length > 5) + Center( + child: TextButton( + onPressed: () => setState(() => _expanded = !_expanded), + child: Text( + 'Show ${order.confirmedApps.length - 5} more workers', + style: UiTypography.body3m.copyWith( + color: UiColors.primary, + ), + ), ), - ) - else - Wrap( - spacing: -8, - children: order.confirmedApps - .map( - (Map app) => Tooltip( - message: app['worker_name'] as String, - child: CircleAvatar( - radius: 14, - backgroundColor: UiColors.white, - child: CircleAvatar( - radius: 12, - backgroundColor: UiColors.bgSecondary, - child: Text( - (app['worker_name'] as String).substring( - 0, - 1, - ), - style: UiTypography.footnote2b, - ), - ), - ), - ), - ) - .toList(), ), ], ), @@ -443,6 +477,163 @@ class _ViewOrderCardState extends State { ); } + /// Builds a stacked avatar UI for a list of applications. + Widget _buildAvatarStack(List> apps) { + const double size = 28.0; + const double overlap = 20.0; + final int count = apps.length > 3 ? 3 : apps.length; + + return SizedBox( + height: size, + width: size + (count - 1) * overlap, + child: Stack( + children: [ + for (int i = 0; i < count; i++) + Positioned( + left: i * overlap, + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: UiColors.white, width: 2), + color: UiColors.tagInProgress, + ), + child: Center( + child: Text( + (apps[i]['worker_name'] as String)[0], + style: UiTypography.footnote2b.copyWith( + color: UiColors.primary, + ), + ), + ), + ), + ), + ], + ), + ); + } + + /// Builds a detailed row for a worker. + Widget _buildWorkerRow(Map app) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + // Avatar + CircleAvatar( + radius: 18, + backgroundColor: UiColors.tagInProgress, + child: Text( + (app['worker_name'] as String)[0], + style: UiTypography.body2b.copyWith( + color: UiColors.primary, + ), + ), + ), + const SizedBox(width: UiConstants.space3), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + app['worker_name'] as String, + style: UiTypography.body2m.copyWith( + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + border: Border.all( + color: UiColors.separatorPrimary, + ), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '⭐ 4.8', + style: UiTypography.titleUppercase4m.copyWith( + color: UiColors.textSecondary, + ), + ), + ), + if (app['check_in_time'] != null) ...[ + const SizedBox(width: 4), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: UiColors.tagSuccess, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Checked In', + style: UiTypography.titleUppercase4m.copyWith( + color: UiColors.textSuccess, + ), + ), + ), + ], + ], + ), + ], + ), + ], + ), + Row( + children: [ + _buildActionIconButton(icon: UiIcons.phone, onTap: () {}), + const SizedBox(width: 8), + _buildActionIconButton( + icon: UiIcons.messageCircle, + onTap: () {}, + ), + const SizedBox(width: 8), + const Icon( + UiIcons.success, + size: 20, + color: UiColors.textSuccess, + ), + ], + ), + ], + ), + ), + ); + } + + /// Specialized action button for worker rows. + Widget _buildActionIconButton({ + required IconData icon, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 32, + height: 32, + decoration: const BoxDecoration(color: UiColors.transparent), + child: Icon(icon, size: 16, color: UiColors.primary), + ), + ); + } + /// Builds a small icon button used in row headers. Widget _buildHeaderIconButton({ required IconData icon, @@ -523,3 +714,191 @@ class _ViewOrderCardState extends State { ); } } + +/// A bottom sheet for editing an existing order. +class _OrderEditSheet extends StatefulWidget { + const _OrderEditSheet({required this.order}); + + final OrderItem order; + + @override + State<_OrderEditSheet> createState() => _OrderEditSheetState(); +} + +class _OrderEditSheetState extends State<_OrderEditSheet> { + late TextEditingController _titleController; + late TextEditingController _dateController; + late TextEditingController _locationController; + late TextEditingController _workersNeededController; + + @override + void initState() { + super.initState(); + _titleController = TextEditingController(text: widget.order.title); + _dateController = TextEditingController(text: widget.order.date); + _locationController = TextEditingController( + text: widget.order.locationAddress, + ); + _workersNeededController = TextEditingController( + text: widget.order.workersNeeded.toString(), + ); + } + + @override + void dispose() { + _titleController.dispose(); + _dateController.dispose(); + _locationController.dispose(); + _workersNeededController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + height: MediaQuery.of(context).size.height * 0.9, + decoration: const BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: BorderRadius.vertical( + top: Radius.circular(UiConstants.radiusBase * 2), + ), + ), + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 16), + decoration: const BoxDecoration( + color: UiColors.primary, + borderRadius: BorderRadius.vertical( + top: Radius.circular(UiConstants.radiusBase * 2), + ), + ), + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Icon( + UiIcons.chevronLeft, + color: UiColors.white, + size: 24, + ), + ), + ), + ), + const SizedBox(width: UiConstants.space3), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Edit Order', + style: UiTypography.title1m.copyWith( + color: UiColors.white, + ), + ), + Text( + 'Refine your staffing needs', + style: UiTypography.body3r.copyWith( + color: UiColors.white.withValues(alpha: 0.7), + ), + ), + ], + ), + ], + ), + ), + + // Content + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionLabel('Position Title'), + UiTextField( + controller: _titleController, + hintText: 'e.g. Server, Bartender', + prefixIcon: UiIcons.briefcase, + ), + const SizedBox(height: UiConstants.space4), + + _buildSectionLabel('Date'), + UiTextField( + controller: _dateController, + hintText: 'Select Date', + prefixIcon: UiIcons.calendar, + readOnly: true, + onTap: () { + // TODO: Show date picker + }, + ), + const SizedBox(height: UiConstants.space4), + + _buildSectionLabel('Location'), + UiTextField( + controller: _locationController, + hintText: 'Business address', + prefixIcon: UiIcons.mapPin, + ), + const SizedBox(height: UiConstants.space4), + + _buildSectionLabel('Workers Needed'), + Row( + children: [ + Expanded( + child: UiTextField( + controller: _workersNeededController, + hintText: 'Quantity', + prefixIcon: UiIcons.users, + keyboardType: TextInputType.number, + ), + ), + ], + ), + const SizedBox(height: UiConstants.space6), + + UiButton.primary( + text: 'Save Changes', + fullWidth: true, + onPressed: () { + // TODO: Implement save logic + Navigator.pop(context); + }, + ), + const SizedBox(height: UiConstants.space3), + UiButton.ghost( + text: 'Cancel', + fullWidth: true, + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildSectionLabel(String label) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + label.toUpperCase(), + style: UiTypography.titleUppercase4m.copyWith( + color: UiColors.textSecondary, + fontWeight: FontWeight.bold, + ), + ), + ); + } +} From d928dfb645f6cb0c2a9fe972bc1857fb895daed7 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 23 Jan 2026 12:05:04 -0500 Subject: [PATCH 3/5] Refactor order creation and edit UI for consistency Refactored BLoC and widget code for order creation flows to improve code style, readability, and consistency. Unified the edit order bottom sheet to follow the Unified Order Flow prototype, supporting multiple positions, review, and confirmation steps. Updated UI components to use more concise widget tree structures and standardized button implementations. --- .../blocs/client_create_order_bloc.dart | 2 +- .../blocs/one_time_order_bloc.dart | 19 +- .../blocs/one_time_order_state.dart | 19 +- .../presentation/blocs/rapid_order_bloc.dart | 21 +- .../presentation/pages/create_order_page.dart | 5 +- .../create_order/create_order_view.dart | 95 ++- .../one_time_order/one_time_order_header.dart | 4 +- .../one_time_order_position_card.dart | 110 +-- .../one_time_order_section_header.dart | 26 +- .../one_time_order/one_time_order_view.dart | 40 +- .../presentation/widgets/order_type_card.dart | 10 +- .../rapid_order/rapid_order_example_card.dart | 5 +- .../rapid_order/rapid_order_header.dart | 6 +- .../rapid_order/rapid_order_success_view.dart | 10 +- .../widgets/rapid_order/rapid_order_view.dart | 45 +- .../presentation/widgets/view_order_card.dart | 673 ++++++++++++++++-- 16 files changed, 840 insertions(+), 250 deletions(-) diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/client_create_order_bloc.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/client_create_order_bloc.dart index ddb2ff8e..4ce8b483 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/client_create_order_bloc.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/client_create_order_bloc.dart @@ -8,7 +8,7 @@ import 'client_create_order_state.dart'; class ClientCreateOrderBloc extends Bloc { ClientCreateOrderBloc(this._getOrderTypesUseCase) - : super(const ClientCreateOrderInitial()) { + : super(const ClientCreateOrderInitial()) { on(_onTypesRequested); } final GetOrderTypesUseCase _getOrderTypesUseCase; diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_bloc.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_bloc.dart index c2db55cb..8ea45002 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_bloc.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_bloc.dart @@ -8,7 +8,7 @@ import 'one_time_order_state.dart'; /// BLoC for managing the multi-step one-time order creation form. class OneTimeOrderBloc extends Bloc { OneTimeOrderBloc(this._createOneTimeOrderUseCase) - : super(OneTimeOrderState.initial()) { + : super(OneTimeOrderState.initial()) { on(_onDateChanged); on(_onLocationChanged); on(_onPositionAdded); @@ -37,13 +37,14 @@ class OneTimeOrderBloc extends Bloc { Emitter emit, ) { final List newPositions = - List.from(state.positions) - ..add(const OneTimeOrderPosition( + List.from(state.positions)..add( + const OneTimeOrderPosition( role: '', count: 1, startTime: '', endTime: '', - )); + ), + ); emit(state.copyWith(positions: newPositions)); } @@ -83,10 +84,12 @@ class OneTimeOrderBloc extends Bloc { await _createOneTimeOrderUseCase(OneTimeOrderArguments(order: order)); emit(state.copyWith(status: OneTimeOrderStatus.success)); } catch (e) { - emit(state.copyWith( - status: OneTimeOrderStatus.failure, - errorMessage: e.toString(), - )); + emit( + state.copyWith( + status: OneTimeOrderStatus.failure, + errorMessage: e.toString(), + ), + ); } } } diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_state.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_state.dart index 2ef862f6..2f286262 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_state.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_state.dart @@ -17,12 +17,7 @@ class OneTimeOrderState extends Equatable { date: DateTime.now(), location: '', positions: const [ - OneTimeOrderPosition( - role: '', - count: 1, - startTime: '', - endTime: '', - ), + OneTimeOrderPosition(role: '', count: 1, startTime: '', endTime: ''), ], ); } @@ -50,10 +45,10 @@ class OneTimeOrderState extends Equatable { @override List get props => [ - date, - location, - positions, - status, - errorMessage, - ]; + date, + location, + positions, + status, + errorMessage, + ]; } diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/rapid_order_bloc.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/rapid_order_bloc.dart index 820baa04..f3b3b63b 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/rapid_order_bloc.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/rapid_order_bloc.dart @@ -7,15 +7,15 @@ import 'rapid_order_state.dart'; /// BLoC for managing the rapid (urgent) order creation flow. class RapidOrderBloc extends Bloc { RapidOrderBloc(this._createRapidOrderUseCase) - : super( - const RapidOrderInitial( - examples: [ - '"We had a call out. Need 2 cooks ASAP"', - '"Need 5 bartenders ASAP until 5am"', - '"Emergency! Need 3 servers right now till midnight"', - ], - ), - ) { + : super( + const RapidOrderInitial( + examples: [ + '"We had a call out. Need 2 cooks ASAP"', + '"Need 5 bartenders ASAP until 5am"', + '"Emergency! Need 3 servers right now till midnight"', + ], + ), + ) { on(_onMessageChanged); on(_onVoiceToggled); on(_onSubmitted); @@ -68,7 +68,8 @@ class RapidOrderBloc extends Bloc { try { await _createRapidOrderUseCase( - RapidOrderArguments(description: message)); + RapidOrderArguments(description: message), + ); emit(const RapidOrderSuccess()); } catch (e) { emit(RapidOrderFailure(e.toString())); diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/create_order_page.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/create_order_page.dart index 9660439f..641363e2 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/create_order_page.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/create_order_page.dart @@ -17,8 +17,9 @@ class ClientCreateOrderPage extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (BuildContext context) => Modular.get() - ..add(const ClientCreateOrderTypesRequested()), + create: (BuildContext context) => + Modular.get() + ..add(const ClientCreateOrderTypesRequested()), child: const CreateOrderView(), ); } diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/create_order/create_order_view.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/create_order/create_order_view.dart index bc007565..eb1775fb 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/create_order/create_order_view.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/create_order/create_order_view.dart @@ -65,59 +65,58 @@ class CreateOrderView extends StatelessWidget { ), ), Expanded( - child: - BlocBuilder( + child: BlocBuilder( builder: (BuildContext context, ClientCreateOrderState state) { - if (state is ClientCreateOrderLoadSuccess) { - return GridView.builder( - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: UiConstants.space4, - crossAxisSpacing: UiConstants.space4, - childAspectRatio: 1, - ), - itemCount: state.orderTypes.length, - itemBuilder: (BuildContext context, int index) { - final OrderType type = state.orderTypes[index]; - final OrderTypeUiMetadata ui = - OrderTypeUiMetadata.fromId(id: type.id); + if (state is ClientCreateOrderLoadSuccess) { + return GridView.builder( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: UiConstants.space4, + crossAxisSpacing: UiConstants.space4, + childAspectRatio: 1, + ), + itemCount: state.orderTypes.length, + itemBuilder: (BuildContext context, int index) { + final OrderType type = state.orderTypes[index]; + final OrderTypeUiMetadata ui = + OrderTypeUiMetadata.fromId(id: type.id); - return OrderTypeCard( - icon: ui.icon, - title: _getTranslation(key: type.titleKey), - description: _getTranslation( - key: type.descriptionKey, - ), - backgroundColor: ui.backgroundColor, - borderColor: ui.borderColor, - iconBackgroundColor: ui.iconBackgroundColor, - iconColor: ui.iconColor, - textColor: ui.textColor, - descriptionColor: ui.descriptionColor, - onTap: () { - switch (type.id) { - case 'rapid': - Modular.to.pushRapidOrder(); - break; - case 'one-time': - Modular.to.pushOneTimeOrder(); - break; - case 'recurring': - Modular.to.pushRecurringOrder(); - break; - case 'permanent': - Modular.to.pushPermanentOrder(); - break; - } + return OrderTypeCard( + icon: ui.icon, + title: _getTranslation(key: type.titleKey), + description: _getTranslation( + key: type.descriptionKey, + ), + backgroundColor: ui.backgroundColor, + borderColor: ui.borderColor, + iconBackgroundColor: ui.iconBackgroundColor, + iconColor: ui.iconColor, + textColor: ui.textColor, + descriptionColor: ui.descriptionColor, + onTap: () { + switch (type.id) { + case 'rapid': + Modular.to.pushRapidOrder(); + break; + case 'one-time': + Modular.to.pushOneTimeOrder(); + break; + case 'recurring': + Modular.to.pushRecurringOrder(); + break; + case 'permanent': + Modular.to.pushPermanentOrder(); + break; + } + }, + ); }, ); - }, - ); - } - return const Center(child: CircularProgressIndicator()); - }, + } + return const Center(child: CircularProgressIndicator()); + }, ), ), ], diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_header.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_header.dart index 3dbf2a38..d39f6c8b 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_header.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_header.dart @@ -54,9 +54,7 @@ class OneTimeOrderHeader extends StatelessWidget { children: [ Text( title, - style: UiTypography.headline3m.copyWith( - color: UiColors.white, - ), + style: UiTypography.headline3m.copyWith(color: UiColors.white), ), Text( subtitle, diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_position_card.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_position_card.dart index 4b24cdfb..ec2797ac 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_position_card.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_position_card.dart @@ -99,8 +99,10 @@ class OneTimeOrderPositionCard extends StatelessWidget { child: DropdownButtonHideUnderline( child: DropdownButton( isExpanded: true, - hint: - Text(roleLabel, style: UiTypography.body2r.textPlaceholder), + hint: Text( + roleLabel, + style: UiTypography.body2r.textPlaceholder, + ), value: position.role.isEmpty ? null : position.role, icon: const Icon( UiIcons.chevronDown, @@ -112,26 +114,27 @@ class OneTimeOrderPositionCard extends StatelessWidget { onUpdated(position.copyWith(role: val)); } }, - items: [ - 'Server', - 'Bartender', - 'Cook', - 'Busser', - 'Host', - 'Barista', - 'Dishwasher', - 'Event Staff' - ].map((String role) { - // Mock rates for UI matching - final int rate = _getMockRate(role); - return DropdownMenuItem( - value: role, - child: Text( - '$role - \$$rate/hr', - style: UiTypography.body2r.textPrimary, - ), - ); - }).toList(), + items: + [ + 'Server', + 'Bartender', + 'Cook', + 'Busser', + 'Host', + 'Barista', + 'Dishwasher', + 'Event Staff', + ].map((String role) { + // Mock rates for UI matching + final int rate = _getMockRate(role); + return DropdownMenuItem( + value: role, + child: Text( + '$role - \$$rate/hr', + style: UiTypography.body2r.textPrimary, + ), + ); + }).toList(), ), ), ), @@ -153,7 +156,8 @@ class OneTimeOrderPositionCard extends StatelessWidget { ); if (picked != null && context.mounted) { onUpdated( - position.copyWith(startTime: picked.format(context))); + position.copyWith(startTime: picked.format(context)), + ); } }, ), @@ -172,7 +176,8 @@ class OneTimeOrderPositionCard extends StatelessWidget { ); if (picked != null && context.mounted) { onUpdated( - position.copyWith(endTime: picked.format(context))); + position.copyWith(endTime: picked.format(context)), + ); } }, ), @@ -198,10 +203,13 @@ class OneTimeOrderPositionCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ GestureDetector( - onTap: () => onUpdated(position.copyWith( + onTap: () => onUpdated( + position.copyWith( count: (position.count > 1) ? position.count - 1 - : 1)), + : 1, + ), + ), child: const Icon(UiIcons.minus, size: 12), ), Text( @@ -210,7 +218,8 @@ class OneTimeOrderPositionCard extends StatelessWidget { ), GestureDetector( onTap: () => onUpdated( - position.copyWith(count: position.count + 1)), + position.copyWith(count: position.count + 1), + ), child: const Icon(UiIcons.add, size: 12), ), ], @@ -249,11 +258,16 @@ class OneTimeOrderPositionCard extends StatelessWidget { children: [ Row( children: [ - const Icon(UiIcons.mapPin, - size: 14, color: UiColors.iconSecondary), + const Icon( + UiIcons.mapPin, + size: 14, + color: UiColors.iconSecondary, + ), const SizedBox(width: UiConstants.space1), Text( - t.client_create_order.one_time + t + .client_create_order + .one_time .different_location_title, style: UiTypography.footnote1m.textSecondary, ), @@ -283,10 +297,7 @@ class OneTimeOrderPositionCard extends StatelessWidget { const SizedBox(height: UiConstants.space3), // Lunch Break - Text( - lunchLabel, - style: UiTypography.footnote2r.textSecondary, - ), + Text(lunchLabel, style: UiTypography.footnote2r.textSecondary), const SizedBox(height: UiConstants.space1), Container( height: 44, @@ -312,38 +323,45 @@ class OneTimeOrderPositionCard extends StatelessWidget { items: >[ DropdownMenuItem( value: 0, - child: Text(t.client_create_order.one_time.no_break, - style: UiTypography.body2r.textPrimary), + child: Text( + t.client_create_order.one_time.no_break, + style: UiTypography.body2r.textPrimary, + ), ), DropdownMenuItem( value: 10, child: Text( - '10 ${t.client_create_order.one_time.paid_break}', - style: UiTypography.body2r.textPrimary), + '10 ${t.client_create_order.one_time.paid_break}', + style: UiTypography.body2r.textPrimary, + ), ), DropdownMenuItem( value: 15, child: Text( - '15 ${t.client_create_order.one_time.paid_break}', - style: UiTypography.body2r.textPrimary), + '15 ${t.client_create_order.one_time.paid_break}', + style: UiTypography.body2r.textPrimary, + ), ), DropdownMenuItem( value: 30, child: Text( - '30 ${t.client_create_order.one_time.unpaid_break}', - style: UiTypography.body2r.textPrimary), + '30 ${t.client_create_order.one_time.unpaid_break}', + style: UiTypography.body2r.textPrimary, + ), ), DropdownMenuItem( value: 45, child: Text( - '45 ${t.client_create_order.one_time.unpaid_break}', - style: UiTypography.body2r.textPrimary), + '45 ${t.client_create_order.one_time.unpaid_break}', + style: UiTypography.body2r.textPrimary, + ), ), DropdownMenuItem( value: 60, child: Text( - '60 ${t.client_create_order.one_time.unpaid_break}', - style: UiTypography.body2r.textPrimary), + '60 ${t.client_create_order.one_time.unpaid_break}', + style: UiTypography.body2r.textPrimary, + ), ), ], ), diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_section_header.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_section_header.dart index 61adb94a..a7bf2b1a 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_section_header.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_section_header.dart @@ -27,14 +27,28 @@ class OneTimeOrderSectionHeader extends StatelessWidget { children: [ Text(title, style: UiTypography.headline4m.textPrimary), if (actionLabel != null && onAction != null) - UiButton.text( + TextButton( onPressed: onAction, - leadingIcon: UiIcons.add, - text: actionLabel!, - iconSize: 16, style: TextButton.styleFrom( - minimumSize: const Size(0, 24), - maximumSize: const Size(0, 24), + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(UiIcons.add, size: 16, color: Color(0xFF0032A0)), + const SizedBox(width: UiConstants.space2), + Text( + actionLabel!, + style: const TextStyle( + color: Color(0xFF0032A0), + fontSize: 14, + fontWeight: + FontWeight.w500, // Added to match typical button text + ), + ), + ], ), ), ], diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart index 404cbb56..b8909ac6 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart @@ -58,8 +58,9 @@ class OneTimeOrderView extends StatelessWidget { ? labels.creating : labels.create_order, isLoading: state.status == OneTimeOrderStatus.loading, - onPressed: () => BlocProvider.of(context) - .add(const OneTimeOrderSubmitted()), + onPressed: () => BlocProvider.of( + context, + ).add(const OneTimeOrderSubmitted()), ), ], ), @@ -90,34 +91,34 @@ class _OneTimeOrderForm extends StatelessWidget { OneTimeOrderDatePicker( label: labels.date_label, value: state.date, - onChanged: (DateTime date) => - BlocProvider.of(context) - .add(OneTimeOrderDateChanged(date)), + onChanged: (DateTime date) => BlocProvider.of( + context, + ).add(OneTimeOrderDateChanged(date)), ), const SizedBox(height: UiConstants.space4), OneTimeOrderLocationInput( label: labels.location_label, value: state.location, - onChanged: (String location) => - BlocProvider.of(context) - .add(OneTimeOrderLocationChanged(location)), + onChanged: (String location) => BlocProvider.of( + context, + ).add(OneTimeOrderLocationChanged(location)), ), const SizedBox(height: UiConstants.space6), OneTimeOrderSectionHeader( title: labels.positions_title, actionLabel: labels.add_position, - onAction: () => BlocProvider.of(context) - .add(const OneTimeOrderPositionAdded()), + onAction: () => BlocProvider.of( + context, + ).add(const OneTimeOrderPositionAdded()), ), const SizedBox(height: UiConstants.space3), // Positions List - ...state.positions - .asMap() - .entries - .map((MapEntry entry) { + ...state.positions.asMap().entries.map(( + MapEntry entry, + ) { final int index = entry.key; final OneTimeOrderPosition position = entry.value; return Padding( @@ -133,13 +134,14 @@ class _OneTimeOrderForm extends StatelessWidget { endLabel: labels.end_label, lunchLabel: labels.lunch_break_label, onUpdated: (OneTimeOrderPosition updated) { - BlocProvider.of(context).add( - OneTimeOrderPositionUpdated(index, updated), - ); + BlocProvider.of( + context, + ).add(OneTimeOrderPositionUpdated(index, updated)); }, onRemoved: () { - BlocProvider.of(context) - .add(OneTimeOrderPositionRemoved(index)); + BlocProvider.of( + context, + ).add(OneTimeOrderPositionRemoved(index)); }, ), ); diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/order_type_card.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/order_type_card.dart index 9a6a4535..f9c92f43 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/order_type_card.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/order_type_card.dart @@ -73,16 +73,14 @@ class OrderTypeCard extends StatelessWidget { ), child: Icon(icon, color: iconColor, size: 24), ), - Text( - title, - style: UiTypography.body2b.copyWith(color: textColor), - ), + Text(title, style: UiTypography.body2b.copyWith(color: textColor)), const SizedBox(height: UiConstants.space1), Expanded( child: Text( description, - style: - UiTypography.footnote1r.copyWith(color: descriptionColor), + style: UiTypography.footnote1r.copyWith( + color: descriptionColor, + ), maxLines: 2, overflow: TextOverflow.ellipsis, ), diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_example_card.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_example_card.dart index c2ce1723..7ffac143 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_example_card.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_example_card.dart @@ -47,10 +47,7 @@ class RapidOrderExampleCard extends StatelessWidget { text: TextSpan( style: UiTypography.body2r.textPrimary, children: [ - TextSpan( - text: label, - style: UiTypography.body2b.textPrimary, - ), + TextSpan(text: label, style: UiTypography.body2b.textPrimary), TextSpan(text: ' $example'), ], ), diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_header.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_header.dart index 2eec2d55..bcb4680e 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_header.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_header.dart @@ -74,11 +74,7 @@ class RapidOrderHeader extends StatelessWidget { children: [ Row( children: [ - const Icon( - UiIcons.zap, - color: UiColors.accent, - size: 18, - ), + const Icon(UiIcons.zap, color: UiColors.accent, size: 18), const SizedBox(width: UiConstants.space2), Text( title, diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_success_view.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_success_view.dart index e99b1bb4..1ad01b09 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_success_view.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_success_view.dart @@ -42,8 +42,9 @@ class RapidOrderSuccessView extends StatelessWidget { child: SafeArea( child: Center( child: Container( - margin: - const EdgeInsets.symmetric(horizontal: UiConstants.space10), + margin: const EdgeInsets.symmetric( + horizontal: UiConstants.space10, + ), padding: const EdgeInsets.all(UiConstants.space8), decoration: BoxDecoration( color: UiColors.white, @@ -75,10 +76,7 @@ class RapidOrderSuccessView extends StatelessWidget { ), ), const SizedBox(height: UiConstants.space6), - Text( - title, - style: UiTypography.headline1m.textPrimary, - ), + Text(title, style: UiTypography.headline1m.textPrimary), const SizedBox(height: UiConstants.space3), Text( message, diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart index fe03182d..1f758d89 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart @@ -153,27 +153,27 @@ class _RapidOrderFormState extends State<_RapidOrderForm> { // Examples if (initialState != null) - ...initialState.examples - .asMap() - .entries - .map((MapEntry entry) { + ...initialState.examples.asMap().entries.map(( + MapEntry entry, + ) { final int index = entry.key; final String example = entry.value; final bool isHighlighted = index == 0; return Padding( padding: const EdgeInsets.only( - bottom: UiConstants.space2), + bottom: UiConstants.space2, + ), child: RapidOrderExampleCard( example: example, isHighlighted: isHighlighted, label: labels.example, onTap: () => BlocProvider.of( - context) - .add( - RapidOrderExampleSelected(example), - ), + context, + ).add( + RapidOrderExampleSelected(example), + ), ), ); }), @@ -184,9 +184,9 @@ class _RapidOrderFormState extends State<_RapidOrderForm> { controller: _messageController, maxLines: 4, onChanged: (String value) { - BlocProvider.of(context).add( - RapidOrderMessageChanged(value), - ); + BlocProvider.of( + context, + ).add(RapidOrderMessageChanged(value)); }, hintText: labels.hint, ), @@ -197,7 +197,8 @@ class _RapidOrderFormState extends State<_RapidOrderForm> { labels: labels, isSubmitting: isSubmitting, isListening: initialState?.isListening ?? false, - isMessageEmpty: initialState != null && + isMessageEmpty: + initialState != null && initialState.message.trim().isEmpty, ), ], @@ -242,11 +243,7 @@ class _AnimatedZapIcon extends StatelessWidget { ), ], ), - child: const Icon( - UiIcons.zap, - color: UiColors.white, - size: 32, - ), + child: const Icon(UiIcons.zap, color: UiColors.white, size: 32), ); } } @@ -271,9 +268,9 @@ class _RapidOrderActions extends StatelessWidget { child: UiButton.secondary( text: isListening ? labels.listening : labels.speak, leadingIcon: UiIcons.bell, // Placeholder for mic - onPressed: () => BlocProvider.of(context).add( - const RapidOrderVoiceToggled(), - ), + onPressed: () => BlocProvider.of( + context, + ).add(const RapidOrderVoiceToggled()), style: OutlinedButton.styleFrom( backgroundColor: isListening ? UiColors.destructive.withValues(alpha: 0.05) @@ -291,9 +288,9 @@ class _RapidOrderActions extends StatelessWidget { trailingIcon: UiIcons.arrowRight, onPressed: isSubmitting || isMessageEmpty ? null - : () => BlocProvider.of(context).add( - const RapidOrderSubmitted(), - ), + : () => BlocProvider.of( + context, + ).add(const RapidOrderSubmitted()), ), ), ], diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart index 93bb7175..b8704f21 100644 --- a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart +++ b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart @@ -715,7 +715,8 @@ class _ViewOrderCardState extends State { } } -/// A bottom sheet for editing an existing order. +/// A sophisticated bottom sheet for editing an existing order, +/// following the Unified Order Flow prototype. class _OrderEditSheet extends StatefulWidget { const _OrderEditSheet({required this.order}); @@ -726,37 +727,94 @@ class _OrderEditSheet extends StatefulWidget { } class _OrderEditSheetState extends State<_OrderEditSheet> { - late TextEditingController _titleController; + bool _showReview = false; + bool _isLoading = false; + late TextEditingController _dateController; - late TextEditingController _locationController; - late TextEditingController _workersNeededController; + late TextEditingController _globalLocationController; + + // Local state for positions (starts with the single position from OrderItem) + late List> _positions; @override void initState() { super.initState(); - _titleController = TextEditingController(text: widget.order.title); _dateController = TextEditingController(text: widget.order.date); - _locationController = TextEditingController( + _globalLocationController = TextEditingController( text: widget.order.locationAddress, ); - _workersNeededController = TextEditingController( - text: widget.order.workersNeeded.toString(), - ); + + _positions = >[ + { + 'role': widget.order.title, + 'count': widget.order.workersNeeded, + 'start_time': widget.order.startTime, + 'end_time': widget.order.endTime, + 'location': '', // Specific location if different from global + }, + ]; } @override void dispose() { - _titleController.dispose(); _dateController.dispose(); - _locationController.dispose(); - _workersNeededController.dispose(); + _globalLocationController.dispose(); super.dispose(); } + void _addPosition() { + setState(() { + _positions.add({ + 'role': '', + 'count': 1, + 'start_time': '09:00', + 'end_time': '17:00', + 'location': '', + }); + }); + } + + void _removePosition(int index) { + if (_positions.length > 1) { + setState(() => _positions.removeAt(index)); + } + } + + void _updatePosition(int index, String key, dynamic value) { + setState(() => _positions[index][key] = value); + } + + double _calculateTotalCost() { + double total = 0; + for (final Map pos in _positions) { + double hours = 8; // Default fallback + try { + final List startParts = pos['start_time'].toString().split(':'); + final List endParts = pos['end_time'].toString().split(':'); + final double startH = + int.parse(startParts[0]) + int.parse(startParts[1]) / 60; + final double endH = + int.parse(endParts[0]) + int.parse(endParts[1]) / 60; + hours = endH - startH; + if (hours < 0) hours += 24; + } catch (_) {} + total += hours * widget.order.hourlyRate * (pos['count'] as int); + } + return total; + } + @override Widget build(BuildContext context) { + if (_isLoading && _showReview) { + return _buildSuccessView(); + } + + return _showReview ? _buildReviewView() : _buildFormView(); + } + + Widget _buildFormView() { return Container( - height: MediaQuery.of(context).size.height * 0.9, + height: MediaQuery.of(context).size.height * 0.95, decoration: const BoxDecoration( color: UiColors.bgSecondary, borderRadius: BorderRadius.vertical( @@ -819,66 +877,289 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { // Content Expanded( child: SingleChildScrollView( - padding: const EdgeInsets.all(UiConstants.space5), + padding: const EdgeInsets.all(20), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSectionLabel('Position Title'), - UiTextField( - controller: _titleController, - hintText: 'e.g. Server, Bartender', - prefixIcon: UiIcons.briefcase, - ), - const SizedBox(height: UiConstants.space4), - - _buildSectionLabel('Date'), + _buildSectionLabel('Date *'), UiTextField( controller: _dateController, - hintText: 'Select Date', + hintText: 'mm/dd/yyyy', prefixIcon: UiIcons.calendar, readOnly: true, onTap: () { - // TODO: Show date picker + // TODO: Date picker }, ), - const SizedBox(height: UiConstants.space4), + const SizedBox(height: 16), - _buildSectionLabel('Location'), + _buildSectionLabel('Location *'), UiTextField( - controller: _locationController, + controller: _globalLocationController, hintText: 'Business address', prefixIcon: UiIcons.mapPin, ), - const SizedBox(height: UiConstants.space4), + const SizedBox(height: 24), - _buildSectionLabel('Workers Needed'), + // Positions Header Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Expanded( - child: UiTextField( - controller: _workersNeededController, - hintText: 'Quantity', - prefixIcon: UiIcons.users, - keyboardType: TextInputType.number, + Text( + 'Positions', + style: UiTypography.title1m.copyWith( + color: UiColors.textPrimary, ), ), + UiButton.text( + leadingIcon: UiIcons.add, + text: 'Add Position', + onPressed: _addPosition, + ), ], ), - const SizedBox(height: UiConstants.space6), + const SizedBox(height: 8), - UiButton.primary( - text: 'Save Changes', - fullWidth: true, - onPressed: () { - // TODO: Implement save logic - Navigator.pop(context); - }, + ..._positions.asMap().entries.map(( + MapEntry> entry, + ) { + return _buildPositionCard(entry.key, entry.value); + }), + + const SizedBox(height: 40), + ], + ), + ), + ), + + // Footer + Container( + padding: const EdgeInsets.all(20), + decoration: const BoxDecoration( + color: UiColors.white, + border: Border(top: BorderSide(color: UiColors.separatorPrimary)), + ), + child: SafeArea( + top: false, + child: UiButton.primary( + text: 'Review ${_positions.length} Positions', + fullWidth: true, + onPressed: () => setState(() => _showReview = true), + ), + ), + ), + ], + ), + ); + } + + Widget _buildReviewView() { + final int totalWorkers = _positions.fold( + 0, + (int sum, Map p) => sum + (p['count'] as int), + ); + final double totalCost = _calculateTotalCost(); + + return Container( + height: MediaQuery.of(context).size.height * 0.95, + decoration: const BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: BorderRadius.vertical( + top: Radius.circular(UiConstants.radiusBase * 2), + ), + ), + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 16), + decoration: const BoxDecoration( + color: UiColors.primary, + borderRadius: BorderRadius.vertical( + top: Radius.circular(UiConstants.radiusBase * 2), + ), + ), + child: Row( + children: [ + GestureDetector( + onTap: () => setState(() => _showReview = false), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Icon( + UiIcons.chevronLeft, + color: UiColors.white, + size: 24, + ), + ), ), - const SizedBox(height: UiConstants.space3), - UiButton.ghost( - text: 'Cancel', - fullWidth: true, - onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Review Order', + style: UiTypography.title1m.copyWith( + color: UiColors.white, + ), + ), + Text( + 'Confirm details before saving', + style: UiTypography.body3r.copyWith( + color: UiColors.white.withValues(alpha: 0.7), + ), + ), + ], + ), + ], + ), + ), + + // Content + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Summary Card + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + UiColors.primary.withValues(alpha: 0.05), + UiColors.primary.withValues(alpha: 0.1), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: UiColors.primary.withValues(alpha: 0.2), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildSummaryItem('${_positions.length}', 'Positions'), + _buildSummaryItem('$totalWorkers', 'Workers'), + _buildSummaryItem( + '\$${totalCost.round()}', + 'Est. Cost', + ), + ], + ), + ), + const SizedBox(height: 20), + + // Order Details + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: UiColors.separatorPrimary), + ), + child: Column( + children: [ + Row( + children: [ + const Icon( + UiIcons.calendar, + size: 16, + color: UiColors.primary, + ), + const SizedBox(width: 8), + Text( + _dateController.text, + style: UiTypography.body2m.copyWith( + color: UiColors.textPrimary, + ), + ), + ], + ), + if (_globalLocationController + .text + .isNotEmpty) ...[ + const SizedBox(height: 12), + Row( + children: [ + const Icon( + UiIcons.mapPin, + size: 16, + color: UiColors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _globalLocationController.text, + style: UiTypography.body2r.copyWith( + color: UiColors.textPrimary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ], + ), + ), + const SizedBox(height: 24), + + Text( + 'Positions Breakdown', + style: UiTypography.body2b.copyWith( + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 12), + + ..._positions.map( + (Map pos) => _buildReviewPositionCard(pos), + ), + + const SizedBox(height: 40), + ], + ), + ), + ), + + // Footer + Container( + padding: const EdgeInsets.all(20), + decoration: const BoxDecoration( + color: UiColors.white, + border: Border(top: BorderSide(color: UiColors.separatorPrimary)), + ), + child: SafeArea( + top: false, + child: Row( + children: [ + Expanded( + child: UiButton.secondary( + text: 'Edit', + onPressed: () => setState(() => _showReview = false), + ), + ), + const SizedBox(width: 12), + Expanded( + child: UiButton.primary( + text: 'Confirm & Save', + onPressed: () async { + setState(() => _isLoading = true); + await Future.delayed(const Duration(seconds: 1)); + if (mounted) { + // TODO: Implement actual save logic + } + }, + ), ), ], ), @@ -889,6 +1170,298 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { ); } + Widget _buildSummaryItem(String value, String label) { + return Column( + children: [ + Text( + value, + style: UiTypography.headline2m.copyWith( + color: UiColors.primary, + fontWeight: FontWeight.bold, + ), + ), + Text( + label.toUpperCase(), + style: UiTypography.titleUppercase4m.copyWith( + color: UiColors.textSecondary, + ), + ), + ], + ); + } + + Widget _buildPositionCard(int index, Map pos) { + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: UiColors.separatorSecondary, width: 2), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 24, + height: 24, + decoration: const BoxDecoration( + color: UiColors.primary, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '${index + 1}', + style: UiTypography.footnote2b.copyWith( + color: UiColors.white, + ), + ), + ), + ), + const SizedBox(width: 8), + Text( + 'Position ${index + 1}', + style: UiTypography.footnote2m.copyWith( + color: UiColors.textSecondary, + ), + ), + ], + ), + if (_positions.length > 1) + GestureDetector( + onTap: () => _removePosition(index), + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: Color(0xFFFEF2F2), + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.close, + size: 14, + color: UiColors.destructive, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + + _buildSectionLabel('Position Title *'), + UiTextField( + controller: TextEditingController(text: pos['role']), + hintText: 'e.g. Server, Bartender', + prefixIcon: UiIcons.briefcase, + onChanged: (String val) => _updatePosition(index, 'role', val), + ), + const SizedBox(height: 12), + + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionLabel('Start Time *'), + UiTextField( + controller: TextEditingController( + text: pos['start_time'], + ), + prefixIcon: UiIcons.clock, + onTap: () {}, // Time picker + ), + ], + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionLabel('End Time *'), + UiTextField( + controller: TextEditingController(text: pos['end_time']), + prefixIcon: UiIcons.clock, + onTap: () {}, // Time picker + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + + _buildSectionLabel('Workers Needed'), + Row( + children: [ + _buildCounterBtn( + icon: UiIcons.minus, + onTap: () { + if ((pos['count'] as int) > 1) { + _updatePosition(index, 'count', (pos['count'] as int) - 1); + } + }, + ), + const SizedBox(width: 16), + Text('${pos['count']}', style: UiTypography.body1b), + const SizedBox(width: 16), + _buildCounterBtn( + icon: UiIcons.add, + onTap: () { + _updatePosition(index, 'count', (pos['count'] as int) + 1); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildReviewPositionCard(Map pos) { + // Simplified cost calculation + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: UiColors.separatorSecondary), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + pos['role'].toString().isEmpty + ? 'Position' + : pos['role'].toString(), + style: UiTypography.body2b.copyWith( + color: UiColors.textPrimary, + ), + ), + Text( + '${pos['count']} worker${pos['count'] > 1 ? 's' : ''}', + style: UiTypography.footnote2r.copyWith( + color: UiColors.textSecondary, + ), + ), + ], + ), + Text( + '\$${widget.order.hourlyRate.round()}/hr', + style: UiTypography.body2b.copyWith(color: UiColors.primary), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + const Icon( + UiIcons.clock, + size: 14, + color: UiColors.iconSecondary, + ), + const SizedBox(width: 6), + Text( + '${pos['start_time']} - ${pos['end_time']}', + style: UiTypography.footnote2r.copyWith( + color: UiColors.textSecondary, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildCounterBtn({ + required IconData icon, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 16, color: UiColors.primary), + ), + ); + } + + Widget _buildSuccessView() { + return Container( + width: double.infinity, + height: MediaQuery.of(context).size.height * 0.95, + decoration: const BoxDecoration( + color: UiColors.primary, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 80, + height: 80, + decoration: const BoxDecoration( + color: UiColors.accent, + shape: BoxShape.circle, + ), + child: const Center( + child: Icon( + UiIcons.success, + size: 40, + color: UiColors.foreground, + ), + ), + ), + const SizedBox(height: 24), + Text( + 'Order Updated!', + style: UiTypography.headline1m.copyWith(color: UiColors.white), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: Text( + 'Your shift has been updated successfully.', + textAlign: TextAlign.center, + style: UiTypography.body1r.copyWith( + color: UiColors.white.withValues(alpha: 0.7), + ), + ), + ), + const SizedBox(height: 40), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: UiButton.secondary( + text: 'Back to Orders', + fullWidth: true, + style: OutlinedButton.styleFrom( + backgroundColor: UiColors.white, + foregroundColor: UiColors.primary, + ), + onPressed: () => Navigator.pop(context), + ), + ), + ], + ), + ); + } + Widget _buildSectionLabel(String label) { return Padding( padding: const EdgeInsets.only(bottom: 8), From 4e8373b2e587428bc43b0203bdb53599085555f1 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 23 Jan 2026 12:23:01 -0500 Subject: [PATCH 4/5] Update view_order_card.dart --- .../presentation/widgets/view_order_card.dart | 1345 ++++++++--------- 1 file changed, 671 insertions(+), 674 deletions(-) diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart index b8704f21..7c6beb78 100644 --- a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart +++ b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart @@ -122,23 +122,20 @@ class _ViewOrderCardState extends State { return Container( decoration: BoxDecoration( color: UiColors.white, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all( - color: UiColors.primary.withValues(alpha: 0.12), - width: 1.5, - ), + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), boxShadow: [ BoxShadow( - color: UiColors.primary.withValues(alpha: 0.08), - blurRadius: 3, - offset: const Offset(0, 1), + color: UiColors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), ), ], ), child: Column( children: [ Padding( - padding: const EdgeInsets.all(UiConstants.space4), + padding: const EdgeInsets.all(UiConstants.space5), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -150,34 +147,43 @@ class _ViewOrderCardState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Status Dot & Label - Row( - children: [ - Container( - width: 6, - height: 6, - decoration: BoxDecoration( - color: statusColor, - shape: BoxShape.circle, + // Status Badge + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: statusColor, + shape: BoxShape.circle, + ), ), - ), - const SizedBox(width: UiConstants.space2), - Text( - statusLabel, - style: UiTypography.footnote2b.copyWith( - color: statusColor, - letterSpacing: 0.5, + const SizedBox(width: 6), + Text( + statusLabel.toUpperCase(), + style: UiTypography.footnote2b.copyWith( + color: statusColor, + letterSpacing: 0.5, + ), ), - ), - ], + ], + ), ), - const SizedBox(height: UiConstants.space1), + const SizedBox(height: UiConstants.space3), // Title Text( order.title, - style: UiTypography.body1b.copyWith( - color: UiColors.textPrimary, - ), + style: UiTypography.headline4m.textPrimary, ), const SizedBox(height: UiConstants.space1), // Client & Date @@ -185,84 +191,54 @@ class _ViewOrderCardState extends State { children: [ Text( order.clientName, - style: UiTypography.body3r.copyWith( - color: UiColors.textSecondary, - ), + style: UiTypography.body3r.textSecondary, ), Padding( padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space1, + horizontal: 6, ), child: Text( '•', - style: UiTypography.body3r.copyWith( - color: UiColors.textInactive, - ), + style: UiTypography.body3r.textInactive, ), ), Text( _formatDate(dateStr: order.date), - style: UiTypography.body3r.copyWith( - fontWeight: FontWeight.w500, - color: UiColors.textSecondary, - ), + style: UiTypography.body3m.textSecondary, ), ], ), - const SizedBox(height: UiConstants.space1), + const SizedBox(height: UiConstants.space2), // Address Row( children: [ const Icon( UiIcons.mapPin, - size: 12, + size: 14, color: UiColors.iconSecondary, ), - const SizedBox(width: UiConstants.space1), + const SizedBox(width: 4), Expanded( child: Text( order.locationAddress, - style: UiTypography.footnote2r.copyWith( - color: UiColors.textSecondary, - ), + style: UiTypography.footnote2r.textSecondary, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), - const SizedBox(width: UiConstants.space1), - GestureDetector( - onTap: () { - // TODO: Handle location - }, - child: Row( - children: [ - const Icon( - UiIcons.navigation, - size: 12, - color: UiColors.primary, - ), - const SizedBox(width: 2), - Text( - t.client_view_orders.card.get_direction, - style: UiTypography.footnote2m.copyWith( - color: UiColors.primary, - ), - ), - ], - ), - ), ], ), ], ), ), + const SizedBox(width: UiConstants.space3), // Actions Row( children: [ _buildHeaderIconButton( icon: UiIcons.edit, color: UiColors.primary, - bgColor: UiColors.tagInProgress, + bgColor: UiColors.primary.withValues(alpha: 0.08), onTap: () => _openEditSheet(order: order), ), const SizedBox(width: UiConstants.space2), @@ -279,70 +255,57 @@ class _ViewOrderCardState extends State { ], ), - const SizedBox(height: UiConstants.space3), - const Divider(height: 1, color: UiColors.separatorSecondary), - const SizedBox(height: UiConstants.space3), + const SizedBox(height: UiConstants.space4), + const Divider(height: 1, color: UiColors.border), + const SizedBox(height: UiConstants.space4), // Stats Row Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Expanded( - child: _buildStatItem( - icon: UiIcons.dollar, - value: '\$${cost.round()}', - label: t.client_view_orders.card.total, - ), + _buildStatItem( + icon: UiIcons.dollar, + value: '\$${cost.round()}', + label: 'Total', ), - Container( - width: 1, - height: 32, - color: UiColors.separatorSecondary, + _buildStatDivider(), + _buildStatItem( + icon: UiIcons.clock, + value: hours.toStringAsFixed(1), + label: 'Hrs', ), - Expanded( - child: _buildStatItem( - icon: UiIcons.clock, - value: hours.toStringAsFixed(1), - label: t.client_view_orders.card.hrs, - ), - ), - Container( - width: 1, - height: 32, - color: UiColors.separatorSecondary, - ), - Expanded( - child: _buildStatItem( - icon: UiIcons.users, - value: - '${order.filled > 0 ? order.filled : order.workersNeeded}', - label: t.client_view_orders.card.workers, - ), + _buildStatDivider(), + _buildStatItem( + icon: UiIcons.users, + value: + '${order.filled > 0 ? order.filled : order.workersNeeded}', + label: 'Workers', ), ], ), - const SizedBox(height: UiConstants.space4), + const SizedBox(height: UiConstants.space5), - // Clock In/Out Boxes + // Times Section Row( children: [ Expanded( - child: _buildTimeBox( - label: t.client_view_orders.card.clock_in, + child: _buildTimeDisplay( + label: 'Clock In', time: _formatTime(timeStr: order.startTime), ), ), const SizedBox(width: UiConstants.space3), Expanded( - child: _buildTimeBox( - label: t.client_view_orders.card.clock_out, + child: _buildTimeDisplay( + label: 'Clock Out', time: _formatTime(timeStr: order.endTime), ), ), ], ), - const SizedBox(height: UiConstants.space3), + const SizedBox(height: UiConstants.space4), // Coverage Section if (order.status != 'completed') ...[ @@ -356,15 +319,10 @@ class _ViewOrderCardState extends State { size: 16, color: UiColors.textSuccess, ), - const SizedBox(width: 6), + const SizedBox(width: 8), Text( - t.client_view_orders.card.workers_label( - filled: order.filled, - needed: order.workersNeeded, - ), - style: UiTypography.body2m.copyWith( - color: UiColors.textPrimary, - ), + '${order.filled}/${order.workersNeeded} Workers Filled', + style: UiTypography.body2m.textPrimary, ), ], ), @@ -376,12 +334,12 @@ class _ViewOrderCardState extends State { ), ], ), - const SizedBox(height: 8), + const SizedBox(height: 10), ClipRRect( - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(100), child: LinearProgressIndicator( value: coveragePercent / 100, - backgroundColor: UiColors.separatorSecondary, + backgroundColor: UiColors.bgSecondary, valueColor: const AlwaysStoppedAnimation( UiColors.primary, ), @@ -391,18 +349,16 @@ class _ViewOrderCardState extends State { // Avatar Stack Preview (if not expanded) if (!_expanded && order.confirmedApps.isNotEmpty) ...[ - const SizedBox(height: UiConstants.space3), + const SizedBox(height: UiConstants.space4), Row( children: [ _buildAvatarStack(order.confirmedApps), if (order.confirmedApps.length > 3) Padding( - padding: const EdgeInsets.only(left: 8), + padding: const EdgeInsets.only(left: 12), child: Text( '+${order.confirmedApps.length - 3} more', - style: UiTypography.footnote2r.copyWith( - color: UiColors.textSecondary, - ), + style: UiTypography.footnote2r.textSecondary, ), ), ], @@ -418,14 +374,12 @@ class _ViewOrderCardState extends State { Container( decoration: const BoxDecoration( color: UiColors.bgSecondary, - border: Border( - top: BorderSide(color: UiColors.separatorSecondary), - ), + border: Border(top: BorderSide(color: UiColors.border)), borderRadius: BorderRadius.vertical( - bottom: Radius.circular(UiConstants.radiusBase), + bottom: Radius.circular(12), ), ), - padding: const EdgeInsets.all(UiConstants.space4), + padding: const EdgeInsets.all(UiConstants.space5), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -433,37 +387,35 @@ class _ViewOrderCardState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - t.client_view_orders.card.confirmed_workers, - style: UiTypography.footnote2b.copyWith( - color: UiColors.textSecondary, - ), + 'CONFIRMED WORKERS', + style: UiTypography.footnote2b.textSecondary, ), - UiButton.primary( - text: 'Message All', - leadingIcon: UiIcons.messageCircle, - size: UiButtonSize.small, - // style: ElevatedButton.styleFrom( - // minimumSize: const Size(0, 32), - // maximumSize: const Size(0, 32), - // ), - onPressed: () { - // TODO: Message all workers - }, + GestureDetector( + onTap: () {}, + child: Text( + 'Message All', + style: UiTypography.footnote2b.copyWith( + color: UiColors.primary, + ), + ), ), ], ), - const SizedBox(height: UiConstants.space3), + const SizedBox(height: UiConstants.space4), ...order.confirmedApps .take(5) .map((Map app) => _buildWorkerRow(app)), if (order.confirmedApps.length > 5) - Center( - child: TextButton( - onPressed: () => setState(() => _expanded = !_expanded), - child: Text( - 'Show ${order.confirmedApps.length - 5} more workers', - style: UiTypography.body3m.copyWith( - color: UiColors.primary, + Padding( + padding: const EdgeInsets.only(top: 8), + child: Center( + child: TextButton( + onPressed: () {}, + child: Text( + 'Show ${order.confirmedApps.length - 5} more workers', + style: UiTypography.body2m.copyWith( + color: UiColors.primary, + ), ), ), ), @@ -477,10 +429,35 @@ class _ViewOrderCardState extends State { ); } + Widget _buildStatDivider() { + return Container(width: 1, height: 24, color: UiColors.border); + } + + Widget _buildTimeDisplay({required String label, required String time}) { + return Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: UiConstants.radiusMd, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label.toUpperCase(), + style: UiTypography.titleUppercase4m.textSecondary, + ), + const SizedBox(height: 4), + Text(time, style: UiTypography.body1b.textPrimary), + ], + ), + ); + } + /// Builds a stacked avatar UI for a list of applications. Widget _buildAvatarStack(List> apps) { - const double size = 28.0; - const double overlap = 20.0; + const double size = 32.0; + const double overlap = 22.0; final int count = apps.length > 3 ? 3 : apps.length; return SizedBox( @@ -497,7 +474,7 @@ class _ViewOrderCardState extends State { decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all(color: UiColors.white, width: 2), - color: UiColors.tagInProgress, + color: UiColors.primary.withValues(alpha: 0.1), ), child: Center( child: Text( @@ -516,104 +493,67 @@ class _ViewOrderCardState extends State { /// Builds a detailed row for a worker. Widget _buildWorkerRow(Map app) { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: Row( + children: [ + CircleAvatar( + radius: 20, + backgroundColor: UiColors.primary.withValues(alpha: 0.1), + child: Text( + (app['worker_name'] as String)[0], + style: UiTypography.body1b.copyWith(color: UiColors.primary), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Avatar - CircleAvatar( - radius: 18, - backgroundColor: UiColors.tagInProgress, - child: Text( - (app['worker_name'] as String)[0], - style: UiTypography.body2b.copyWith( - color: UiColors.primary, - ), - ), + Text( + app['worker_name'] as String, + style: UiTypography.body2m.textPrimary, ), - const SizedBox(width: UiConstants.space3), - Column( - crossAxisAlignment: CrossAxisAlignment.start, + const SizedBox(height: 2), + Row( children: [ - Text( - app['worker_name'] as String, - style: UiTypography.body2m.copyWith( - color: UiColors.textPrimary, - ), - ), - const SizedBox(height: 2), - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 1, - ), - decoration: BoxDecoration( - border: Border.all( - color: UiColors.separatorPrimary, - ), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - '⭐ 4.8', - style: UiTypography.titleUppercase4m.copyWith( - color: UiColors.textSecondary, - ), + const Icon(UiIcons.star, size: 10, color: UiColors.accent), + const SizedBox(width: 2), + Text('4.8', style: UiTypography.footnote2r.textSecondary), + if (app['check_in_time'] != null) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: UiColors.textSuccess.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Checked In', + style: UiTypography.titleUppercase4m.copyWith( + color: UiColors.textSuccess, ), ), - if (app['check_in_time'] != null) ...[ - const SizedBox(width: 4), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 1, - ), - decoration: BoxDecoration( - color: UiColors.tagSuccess, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - 'Checked In', - style: UiTypography.titleUppercase4m.copyWith( - color: UiColors.textSuccess, - ), - ), - ), - ], - ], - ), + ), + ], ], ), ], ), - Row( - children: [ - _buildActionIconButton(icon: UiIcons.phone, onTap: () {}), - const SizedBox(width: 8), - _buildActionIconButton( - icon: UiIcons.messageCircle, - onTap: () {}, - ), - const SizedBox(width: 8), - const Icon( - UiIcons.success, - size: 20, - color: UiColors.textSuccess, - ), - ], - ), - ], - ), + ), + _buildActionIconButton(icon: UiIcons.phone, onTap: () {}), + const SizedBox(width: 8), + _buildActionIconButton(icon: UiIcons.messageCircle, onTap: () {}), + ], ), ); } @@ -626,9 +566,12 @@ class _ViewOrderCardState extends State { return GestureDetector( onTap: onTap, child: Container( - width: 32, - height: 32, - decoration: const BoxDecoration(color: UiColors.transparent), + width: 36, + height: 36, + decoration: BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: BorderRadius.circular(8), + ), child: Icon(icon, size: 16, color: UiColors.primary), ), ); @@ -644,12 +587,12 @@ class _ViewOrderCardState extends State { return GestureDetector( onTap: onTap, child: Container( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: bgColor, - borderRadius: BorderRadius.circular(10), + borderRadius: UiConstants.radiusSm, ), - child: Icon(icon, size: 14, color: color), + child: Icon(icon, size: 16, color: color), ), ); } @@ -665,58 +608,23 @@ class _ViewOrderCardState extends State { Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, size: 10, color: UiColors.iconSecondary), - const SizedBox(width: UiConstants.space1), - Text( - value, - style: UiTypography.body2b.copyWith(color: UiColors.textPrimary), - ), + Icon(icon, size: 14, color: UiColors.iconSecondary), + const SizedBox(width: 6), + Text(value, style: UiTypography.body1b.textPrimary), ], ), + const SizedBox(height: 2), Text( label.toUpperCase(), - style: UiTypography.titleUppercase4m.copyWith( - color: UiColors.textInactive, - fontWeight: FontWeight.bold, - ), + style: UiTypography.titleUppercase4m.textInactive, ), ], ); } - - /// Builds a box displaying a label and a time value. - Widget _buildTimeBox({required String label, required String time}) { - return Container( - padding: const EdgeInsets.symmetric(vertical: 8), - decoration: BoxDecoration( - color: UiColors.bgSecondary, - borderRadius: BorderRadius.circular(8), - ), - child: Column( - children: [ - Text( - label, - style: UiTypography.titleUppercase4m.copyWith( - color: UiColors.textSecondary, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 2), - Text( - time, - style: UiTypography.body2m.copyWith( - color: UiColors.foreground, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ); - } } /// A sophisticated bottom sheet for editing an existing order, -/// following the Unified Order Flow prototype. +/// following the Unified Order Flow prototype and matching OneTimeOrderView. class _OrderEditSheet extends StatefulWidget { const _OrderEditSheet({required this.order}); @@ -733,7 +641,6 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { late TextEditingController _dateController; late TextEditingController _globalLocationController; - // Local state for positions (starts with the single position from OrderItem) late List> _positions; @override @@ -750,7 +657,8 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { 'count': widget.order.workersNeeded, 'start_time': widget.order.startTime, 'end_time': widget.order.endTime, - 'location': '', // Specific location if different from global + 'lunch_break': 0, + 'location': null, }, ]; } @@ -769,7 +677,8 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { 'count': 1, 'start_time': '09:00', 'end_time': '17:00', - 'location': '', + 'lunch_break': 0, + 'location': null, }); }); } @@ -787,7 +696,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { double _calculateTotalCost() { double total = 0; for (final Map pos in _positions) { - double hours = 8; // Default fallback + double hours = 8.0; try { final List startParts = pos['start_time'].toString().split(':'); final List endParts = pos['end_time'].toString().split(':'); @@ -816,142 +725,459 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { return Container( height: MediaQuery.of(context).size.height * 0.95, decoration: const BoxDecoration( - color: UiColors.bgSecondary, - borderRadius: BorderRadius.vertical( - top: Radius.circular(UiConstants.radiusBase * 2), - ), + color: UiColors.bgPrimary, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), child: Column( children: [ - // Header - Container( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 16), - decoration: const BoxDecoration( - color: UiColors.primary, - borderRadius: BorderRadius.vertical( - top: Radius.circular(UiConstants.radiusBase * 2), - ), - ), - child: Row( + _buildHeader(), + Expanded( + child: ListView( + padding: const EdgeInsets.all(UiConstants.space5), children: [ - GestureDetector( - onTap: () => Navigator.pop(context), - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: UiColors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(12), - ), - child: const Center( - child: Icon( - UiIcons.chevronLeft, - color: UiColors.white, - size: 24, - ), - ), - ), + Text( + 'Edit Your Order', + style: UiTypography.headline3m.textPrimary, ), - const SizedBox(width: UiConstants.space3), - Column( - crossAxisAlignment: CrossAxisAlignment.start, + const SizedBox(height: UiConstants.space4), + + _buildSectionHeader('DATE'), + UiTextField( + controller: _dateController, + hintText: 'mm/dd/yyyy', + prefixIcon: UiIcons.calendar, + readOnly: true, + onTap: () {}, + ), + const SizedBox(height: UiConstants.space4), + + _buildSectionHeader('LOCATION'), + UiTextField( + controller: _globalLocationController, + hintText: 'Business address', + prefixIcon: UiIcons.mapPin, + ), + const SizedBox(height: UiConstants.space6), + + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'Edit Order', - style: UiTypography.title1m.copyWith( - color: UiColors.white, - ), + 'POSITIONS', + style: UiTypography.headline4m.textPrimary, ), - Text( - 'Refine your staffing needs', - style: UiTypography.body3r.copyWith( - color: UiColors.white.withValues(alpha: 0.7), + TextButton( + onPressed: _addPosition, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(UiIcons.add, size: 16, color: UiColors.primary), + SizedBox(width: UiConstants.space2), + Text( + 'Add Position', + style: TextStyle( + color: UiColors.primary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], ), ), ], ), + const SizedBox(height: UiConstants.space3), + + ..._positions.asMap().entries.map(( + MapEntry> entry, + ) { + return _buildPositionCard(entry.key, entry.value); + }), + + const SizedBox(height: 40), ], ), ), - - // Content - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column( - children: [ - _buildSectionLabel('Date *'), - UiTextField( - controller: _dateController, - hintText: 'mm/dd/yyyy', - prefixIcon: UiIcons.calendar, - readOnly: true, - onTap: () { - // TODO: Date picker - }, - ), - const SizedBox(height: 16), - - _buildSectionLabel('Location *'), - UiTextField( - controller: _globalLocationController, - hintText: 'Business address', - prefixIcon: UiIcons.mapPin, - ), - const SizedBox(height: 24), - - // Positions Header - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Positions', - style: UiTypography.title1m.copyWith( - color: UiColors.textPrimary, - ), - ), - UiButton.text( - leadingIcon: UiIcons.add, - text: 'Add Position', - onPressed: _addPosition, - ), - ], - ), - const SizedBox(height: 8), - - ..._positions.asMap().entries.map(( - MapEntry> entry, - ) { - return _buildPositionCard(entry.key, entry.value); - }), - - const SizedBox(height: 40), - ], - ), - ), - ), - - // Footer - Container( - padding: const EdgeInsets.all(20), - decoration: const BoxDecoration( - color: UiColors.white, - border: Border(top: BorderSide(color: UiColors.separatorPrimary)), - ), - child: SafeArea( - top: false, - child: UiButton.primary( - text: 'Review ${_positions.length} Positions', - fullWidth: true, - onPressed: () => setState(() => _showReview = true), - ), - ), + _buildBottomAction( + label: 'Review ${_positions.length} Positions', + onPressed: () => setState(() => _showReview = true), ), ], ), ); } + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 20), + decoration: const BoxDecoration( + color: UiColors.primary, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + 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( + 'One-Time Order', + style: UiTypography.headline3m.copyWith(color: UiColors.white), + ), + Text( + 'Refine your staffing needs', + style: UiTypography.footnote2r.copyWith( + color: UiColors.white.withValues(alpha: 0.8), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildSectionHeader(String title) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(title, style: UiTypography.footnote2r.textSecondary), + ); + } + + Widget _buildPositionCard(int index, Map pos) { + return Container( + margin: const EdgeInsets.only(bottom: UiConstants.space3), + 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( + 'POSITION #${index + 1}', + style: UiTypography.footnote1m.textSecondary, + ), + if (_positions.length > 1) + GestureDetector( + onTap: () => _removePosition(index), + child: Text( + 'Remove', + style: UiTypography.footnote1m.copyWith( + color: UiColors.destructive, + ), + ), + ), + ], + ), + const SizedBox(height: UiConstants.space3), + + _buildDropdownField( + hint: 'Select role', + value: pos['role'], + items: [ + 'Server', + 'Bartender', + 'Cook', + 'Busser', + 'Host', + 'Barista', + 'Dishwasher', + 'Event Staff', + if (pos['role'] != null && + pos['role'].toString().isNotEmpty && + ![ + 'Server', + 'Bartender', + 'Cook', + 'Busser', + 'Host', + 'Barista', + 'Dishwasher', + 'Event Staff', + ].contains(pos['role'])) + pos['role'].toString(), + ], + onChanged: (dynamic val) => _updatePosition(index, 'role', val), + ), + + const SizedBox(height: UiConstants.space3), + + Row( + children: [ + Expanded( + child: _buildInlineTimeInput( + label: 'Start', + value: pos['start_time'], + onTap: () {}, + ), + ), + const SizedBox(width: UiConstants.space2), + Expanded( + child: _buildInlineTimeInput( + label: 'End', + value: pos['end_time'], + onTap: () {}, + ), + ), + const SizedBox(width: UiConstants.space2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Workers', + 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 ((pos['count'] as int) > 1) { + _updatePosition( + index, + 'count', + (pos['count'] as int) - 1, + ); + } + }, + child: const Icon(UiIcons.minus, size: 12), + ), + Text( + '${pos['count']}', + style: UiTypography.body2b.textPrimary, + ), + GestureDetector( + onTap: () => _updatePosition( + index, + 'count', + (pos['count'] as int) + 1, + ), + child: const Icon(UiIcons.add, size: 12), + ), + ], + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: UiConstants.space4), + + if (pos['location'] == null) + GestureDetector( + onTap: () => _updatePosition(index, 'location', ''), + child: Row( + children: [ + const Icon(UiIcons.mapPin, size: 14, color: UiColors.primary), + const SizedBox(width: UiConstants.space1), + Text( + 'Use different location for this position', + style: UiTypography.footnote1m.copyWith( + color: UiColors.primary, + ), + ), + ], + ), + ) + else + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon( + UiIcons.mapPin, + size: 14, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Text( + 'Different Location', + style: UiTypography.footnote1m.textSecondary, + ), + ], + ), + GestureDetector( + onTap: () => _updatePosition(index, 'location', null), + child: const Icon( + UiIcons.close, + size: 14, + color: UiColors.destructive, + ), + ), + ], + ), + const SizedBox(height: UiConstants.space2), + UiTextField( + controller: TextEditingController(text: pos['location']), + hintText: 'Enter different address', + onChanged: (String val) => + _updatePosition(index, 'location', val), + ), + ], + ), + + const SizedBox(height: UiConstants.space3), + + _buildSectionHeader('LUNCH BREAK'), + _buildDropdownField( + hint: 'No break', + value: pos['lunch_break'], + items: [0, 15, 30, 45, 60], + itemBuilder: (dynamic val) { + if (val == 0) return 'No break'; + return '$val min'; + }, + onChanged: (dynamic val) => + _updatePosition(index, 'lunch_break', val), + ), + ], + ), + ); + } + + Widget _buildDropdownField({ + required String hint, + required dynamic value, + required List items, + String Function(dynamic)? itemBuilder, + required ValueChanged onChanged, + }) { + return Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + decoration: BoxDecoration( + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + hint: Text(hint, style: UiTypography.body2r.textPlaceholder), + value: value == '' || value == null ? null : value, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: onChanged, + items: items.toSet().map((dynamic item) { + String label = item.toString(); + if (itemBuilder != null) label = itemBuilder(item); + return DropdownMenuItem( + value: item, + child: Text(label, style: UiTypography.body2r.textPrimary), + ); + }).toList(), + ), + ), + ); + } + + Widget _buildInlineTimeInput({ + 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, + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildBottomAction({ + required String label, + required VoidCallback onPressed, + }) { + return Container( + padding: EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space5, + UiConstants.space5, + 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: onPressed, + size: UiButtonSize.large, + ), + ), + ); + } + Widget _buildReviewView() { final int totalWorkers = _positions.fold( 0, @@ -963,64 +1189,11 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { height: MediaQuery.of(context).size.height * 0.95, decoration: const BoxDecoration( color: UiColors.bgSecondary, - borderRadius: BorderRadius.vertical( - top: Radius.circular(UiConstants.radiusBase * 2), - ), + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), child: Column( children: [ - // Header - Container( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 16), - decoration: const BoxDecoration( - color: UiColors.primary, - borderRadius: BorderRadius.vertical( - top: Radius.circular(UiConstants.radiusBase * 2), - ), - ), - child: Row( - children: [ - GestureDetector( - onTap: () => setState(() => _showReview = false), - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: UiColors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(12), - ), - child: const Center( - child: Icon( - UiIcons.chevronLeft, - color: UiColors.white, - size: 24, - ), - ), - ), - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Review Order', - style: UiTypography.title1m.copyWith( - color: UiColors.white, - ), - ), - Text( - 'Confirm details before saving', - style: UiTypography.body3r.copyWith( - color: UiColors.white.withValues(alpha: 0.7), - ), - ), - ], - ), - ], - ), - ), - - // Content + _buildHeader(), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(20), @@ -1078,9 +1251,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { const SizedBox(width: 8), Text( _dateController.text, - style: UiTypography.body2m.copyWith( - color: UiColors.textPrimary, - ), + style: UiTypography.body2m.textPrimary, ), ], ), @@ -1099,9 +1270,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { Expanded( child: Text( _globalLocationController.text, - style: UiTypography.body2r.copyWith( - color: UiColors.textPrimary, - ), + style: UiTypography.body2r.textPrimary, overflow: TextOverflow.ellipsis, ), ), @@ -1115,9 +1284,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { Text( 'Positions Breakdown', - style: UiTypography.body2b.copyWith( - color: UiColors.textPrimary, - ), + style: UiTypography.body2b.textPrimary, ), const SizedBox(height: 12), @@ -1133,36 +1300,36 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { // Footer Container( - padding: const EdgeInsets.all(20), + padding: EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space5, + UiConstants.space5, + MediaQuery.of(context).padding.bottom + UiConstants.space5, + ), decoration: const BoxDecoration( color: UiColors.white, - border: Border(top: BorderSide(color: UiColors.separatorPrimary)), + border: Border(top: BorderSide(color: UiColors.border)), ), - child: SafeArea( - top: false, - child: Row( - children: [ - Expanded( - child: UiButton.secondary( - text: 'Edit', - onPressed: () => setState(() => _showReview = false), - ), + child: Row( + children: [ + Expanded( + child: UiButton.secondary( + text: 'Edit', + onPressed: () => setState(() => _showReview = false), ), - const SizedBox(width: 12), - Expanded( - child: UiButton.primary( - text: 'Confirm & Save', - onPressed: () async { - setState(() => _isLoading = true); - await Future.delayed(const Duration(seconds: 1)); - if (mounted) { - // TODO: Implement actual save logic - } - }, - ), + ), + const SizedBox(width: 12), + Expanded( + child: UiButton.primary( + text: 'Confirm & Save', + onPressed: () async { + setState(() => _isLoading = true); + await Future.delayed(const Duration(seconds: 1)); + if (mounted) Navigator.pop(context); + }, ), - ], - ), + ), + ], ), ), ], @@ -1190,141 +1357,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { ); } - Widget _buildPositionCard(int index, Map pos) { - return Container( - margin: const EdgeInsets.only(bottom: 16), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: UiColors.separatorSecondary, width: 2), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Container( - width: 24, - height: 24, - decoration: const BoxDecoration( - color: UiColors.primary, - shape: BoxShape.circle, - ), - child: Center( - child: Text( - '${index + 1}', - style: UiTypography.footnote2b.copyWith( - color: UiColors.white, - ), - ), - ), - ), - const SizedBox(width: 8), - Text( - 'Position ${index + 1}', - style: UiTypography.footnote2m.copyWith( - color: UiColors.textSecondary, - ), - ), - ], - ), - if (_positions.length > 1) - GestureDetector( - onTap: () => _removePosition(index), - child: Container( - padding: const EdgeInsets.all(4), - decoration: const BoxDecoration( - color: Color(0xFFFEF2F2), - shape: BoxShape.circle, - ), - child: const Icon( - UiIcons.close, - size: 14, - color: UiColors.destructive, - ), - ), - ), - ], - ), - const SizedBox(height: 16), - - _buildSectionLabel('Position Title *'), - UiTextField( - controller: TextEditingController(text: pos['role']), - hintText: 'e.g. Server, Bartender', - prefixIcon: UiIcons.briefcase, - onChanged: (String val) => _updatePosition(index, 'role', val), - ), - const SizedBox(height: 12), - - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSectionLabel('Start Time *'), - UiTextField( - controller: TextEditingController( - text: pos['start_time'], - ), - prefixIcon: UiIcons.clock, - onTap: () {}, // Time picker - ), - ], - ), - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSectionLabel('End Time *'), - UiTextField( - controller: TextEditingController(text: pos['end_time']), - prefixIcon: UiIcons.clock, - onTap: () {}, // Time picker - ), - ], - ), - ), - ], - ), - const SizedBox(height: 12), - - _buildSectionLabel('Workers Needed'), - Row( - children: [ - _buildCounterBtn( - icon: UiIcons.minus, - onTap: () { - if ((pos['count'] as int) > 1) { - _updatePosition(index, 'count', (pos['count'] as int) - 1); - } - }, - ), - const SizedBox(width: 16), - Text('${pos['count']}', style: UiTypography.body1b), - const SizedBox(width: 16), - _buildCounterBtn( - icon: UiIcons.add, - onTap: () { - _updatePosition(index, 'count', (pos['count'] as int) + 1); - }, - ), - ], - ), - ], - ), - ); - } - Widget _buildReviewPositionCard(Map pos) { - // Simplified cost calculation return Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(16), @@ -1345,15 +1378,11 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { pos['role'].toString().isEmpty ? 'Position' : pos['role'].toString(), - style: UiTypography.body2b.copyWith( - color: UiColors.textPrimary, - ), + style: UiTypography.body2b.textPrimary, ), Text( '${pos['count']} worker${pos['count'] > 1 ? 's' : ''}', - style: UiTypography.footnote2r.copyWith( - color: UiColors.textSecondary, - ), + style: UiTypography.footnote2r.textSecondary, ), ], ), @@ -1374,9 +1403,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { const SizedBox(width: 6), Text( '${pos['start_time']} - ${pos['end_time']}', - style: UiTypography.footnote2r.copyWith( - color: UiColors.textSecondary, - ), + style: UiTypography.footnote2r.textSecondary, ), ], ), @@ -1385,23 +1412,6 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { ); } - Widget _buildCounterBtn({ - required IconData icon, - required VoidCallback onTap, - }) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: UiColors.bgSecondary, - borderRadius: BorderRadius.circular(8), - ), - child: Icon(icon, size: 16, color: UiColors.primary), - ), - ); - } - Widget _buildSuccessView() { return Container( width: double.infinity, @@ -1461,17 +1471,4 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { ), ); } - - Widget _buildSectionLabel(String label) { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - label.toUpperCase(), - style: UiTypography.titleUppercase4m.copyWith( - color: UiColors.textSecondary, - fontWeight: FontWeight.bold, - ), - ), - ); - } } From 45d67101832f3263889c10df7c8f2cea7c47c9c4 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 23 Jan 2026 12:42:26 -0500 Subject: [PATCH 5/5] Add vendor selection and rates to order creation flow Introduces a Vendor entity and integrates vendor selection into the one-time order creation and order editing flows. Vendor-specific rates are now displayed and used for position roles, and UI components have been updated to allow users to select a vendor and see corresponding rates. Mock vendor data is used for demonstration purposes. --- .../packages/domain/lib/krow_domain.dart | 1 + .../lib/src/entities/business/vendor.dart | 15 + .../blocs/one_time_order_bloc.dart | 60 +++- .../blocs/one_time_order_event.dart | 16 ++ .../blocs/one_time_order_state.dart | 11 + .../one_time_order_position_card.dart | 272 +++++------------- .../one_time_order/one_time_order_view.dart | 83 ++++++ .../presentation/widgets/view_order_card.dart | 108 ++++++- 8 files changed, 344 insertions(+), 222 deletions(-) create mode 100644 apps/mobile/packages/domain/lib/src/entities/business/vendor.dart diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index f4d6110b..aead3421 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -19,6 +19,7 @@ export 'src/entities/business/business_setting.dart'; export 'src/entities/business/hub.dart'; export 'src/entities/business/hub_department.dart'; export 'src/entities/business/biz_contract.dart'; +export 'src/entities/business/vendor.dart'; // Events & Shifts export 'src/entities/events/event.dart'; diff --git a/apps/mobile/packages/domain/lib/src/entities/business/vendor.dart b/apps/mobile/packages/domain/lib/src/entities/business/vendor.dart new file mode 100644 index 00000000..19d8bf98 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/business/vendor.dart @@ -0,0 +1,15 @@ +import 'package:equatable/equatable.dart'; + +/// Represents a staffing vendor. +class Vendor extends Equatable { + const Vendor({required this.id, required this.name, required this.rates}); + + final String id; + final String name; + + /// A map of role names to hourly rates. + final Map rates; + + @override + List get props => [id, name, rates]; +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_bloc.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_bloc.dart index 8ea45002..a4a0d193 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_bloc.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_bloc.dart @@ -9,15 +9,70 @@ import 'one_time_order_state.dart'; class OneTimeOrderBloc extends Bloc { OneTimeOrderBloc(this._createOneTimeOrderUseCase) : super(OneTimeOrderState.initial()) { + on(_onVendorsLoaded); + on(_onVendorChanged); on(_onDateChanged); on(_onLocationChanged); on(_onPositionAdded); on(_onPositionRemoved); on(_onPositionUpdated); on(_onSubmitted); + + // Initial load of mock vendors + add( + const OneTimeOrderVendorsLoaded([ + Vendor( + id: 'v1', + name: 'Elite Staffing', + rates: { + 'Server': 25.0, + 'Bartender': 30.0, + 'Cook': 28.0, + 'Busser': 18.0, + 'Host': 20.0, + 'Barista': 22.0, + 'Dishwasher': 17.0, + 'Event Staff': 19.0, + }, + ), + Vendor( + id: 'v2', + name: 'Premier Workforce', + rates: { + 'Server': 22.0, + 'Bartender': 28.0, + 'Cook': 25.0, + 'Busser': 16.0, + 'Host': 18.0, + 'Barista': 20.0, + 'Dishwasher': 15.0, + 'Event Staff': 18.0, + }, + ), + ]), + ); } final CreateOneTimeOrderUseCase _createOneTimeOrderUseCase; + void _onVendorsLoaded( + OneTimeOrderVendorsLoaded event, + Emitter emit, + ) { + emit( + state.copyWith( + vendors: event.vendors, + selectedVendor: event.vendors.isNotEmpty ? event.vendors.first : null, + ), + ); + } + + void _onVendorChanged( + OneTimeOrderVendorChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedVendor: event.vendor)); + } + void _onDateChanged( OneTimeOrderDateChanged event, Emitter emit, @@ -41,8 +96,8 @@ class OneTimeOrderBloc extends Bloc { const OneTimeOrderPosition( role: '', count: 1, - startTime: '', - endTime: '', + startTime: '09:00', + endTime: '17:00', ), ); emit(state.copyWith(positions: newPositions)); @@ -80,6 +135,7 @@ class OneTimeOrderBloc extends Bloc { date: state.date, location: state.location, positions: state.positions, + // In a real app, we'd pass the vendorId here ); await _createOneTimeOrderUseCase(OneTimeOrderArguments(order: order)); emit(state.copyWith(status: OneTimeOrderStatus.success)); diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_event.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_event.dart index 749bbb2e..ec9d4fcd 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_event.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_event.dart @@ -8,6 +8,22 @@ abstract class OneTimeOrderEvent extends Equatable { List get props => []; } +class OneTimeOrderVendorsLoaded extends OneTimeOrderEvent { + const OneTimeOrderVendorsLoaded(this.vendors); + final List vendors; + + @override + List get props => [vendors]; +} + +class OneTimeOrderVendorChanged extends OneTimeOrderEvent { + const OneTimeOrderVendorChanged(this.vendor); + final Vendor vendor; + + @override + List get props => [vendor]; +} + class OneTimeOrderDateChanged extends OneTimeOrderEvent { const OneTimeOrderDateChanged(this.date); final DateTime date; diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_state.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_state.dart index 2f286262..03aee2fa 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_state.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_state.dart @@ -10,6 +10,8 @@ class OneTimeOrderState extends Equatable { required this.positions, this.status = OneTimeOrderStatus.initial, this.errorMessage, + this.vendors = const [], + this.selectedVendor, }); factory OneTimeOrderState.initial() { @@ -19,6 +21,7 @@ class OneTimeOrderState extends Equatable { positions: const [ OneTimeOrderPosition(role: '', count: 1, startTime: '', endTime: ''), ], + vendors: const [], ); } final DateTime date; @@ -26,6 +29,8 @@ class OneTimeOrderState extends Equatable { final List positions; final OneTimeOrderStatus status; final String? errorMessage; + final List vendors; + final Vendor? selectedVendor; OneTimeOrderState copyWith({ DateTime? date, @@ -33,6 +38,8 @@ class OneTimeOrderState extends Equatable { List? positions, OneTimeOrderStatus? status, String? errorMessage, + List? vendors, + Vendor? selectedVendor, }) { return OneTimeOrderState( date: date ?? this.date, @@ -40,6 +47,8 @@ class OneTimeOrderState extends Equatable { positions: positions ?? this.positions, status: status ?? this.status, errorMessage: errorMessage ?? this.errorMessage, + vendors: vendors ?? this.vendors, + selectedVendor: selectedVendor ?? this.selectedVendor, ); } @@ -50,5 +59,7 @@ class OneTimeOrderState extends Equatable { positions, status, errorMessage, + vendors, + selectedVendor, ]; } diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_position_card.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_position_card.dart index ec2797ac..4af5d168 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_position_card.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_position_card.dart @@ -19,6 +19,7 @@ class OneTimeOrderPositionCard extends StatelessWidget { required this.startLabel, required this.endLabel, required this.lunchLabel, + this.vendor, super.key, }); @@ -55,6 +56,9 @@ class OneTimeOrderPositionCard extends StatelessWidget { /// Label for the lunch break. final String lunchLabel; + /// The current selected vendor to determine rates. + final Vendor? vendor; + @override Widget build(BuildContext context) { return Container( @@ -115,22 +119,21 @@ class OneTimeOrderPositionCard extends StatelessWidget { } }, items: - [ - 'Server', - 'Bartender', - 'Cook', - 'Busser', - 'Host', - 'Barista', - 'Dishwasher', - 'Event Staff', - ].map((String role) { - // Mock rates for UI matching - final int rate = _getMockRate(role); + { + ...(vendor?.rates.keys ?? []), + if (position.role.isNotEmpty && + !(vendor?.rates.keys.contains(position.role) ?? + false)) + position.role, + }.map((String role) { + final double? rate = vendor?.rates[role]; + final String label = rate == null + ? role + : '$role - \$${rate.toStringAsFixed(0)}/hr'; return DropdownMenuItem( value: role, child: Text( - '$role - \$$rate/hr', + label, style: UiTypography.body2r.textPrimary, ), ); @@ -203,13 +206,13 @@ class OneTimeOrderPositionCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ GestureDetector( - onTap: () => onUpdated( - position.copyWith( - count: (position.count > 1) - ? position.count - 1 - : 1, - ), - ), + onTap: () { + if (position.count > 1) { + onUpdated( + position.copyWith(count: position.count - 1), + ); + } + }, child: const Icon(UiIcons.minus, size: 12), ), Text( @@ -217,9 +220,11 @@ class OneTimeOrderPositionCard extends StatelessWidget { style: UiTypography.body2b.textPrimary, ), GestureDetector( - onTap: () => onUpdated( - position.copyWith(count: position.count + 1), - ), + onTap: () { + onUpdated( + position.copyWith(count: position.count + 1), + ); + }, child: const Icon(UiIcons.add, size: 12), ), ], @@ -232,76 +237,12 @@ class OneTimeOrderPositionCard extends StatelessWidget { ), const SizedBox(height: UiConstants.space4), - // Optional Location Override - if (position.location == null) - GestureDetector( - onTap: () => onUpdated(position.copyWith(location: '')), - child: Row( - children: [ - const Icon(UiIcons.mapPin, size: 14, color: UiColors.primary), - const SizedBox(width: UiConstants.space1), - Text( - t.client_create_order.one_time.different_location, - style: UiTypography.footnote1m.copyWith( - color: UiColors.primary, - ), - ), - ], - ), - ) - else - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - const Icon( - UiIcons.mapPin, - size: 14, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space1), - Text( - t - .client_create_order - .one_time - .different_location_title, - style: UiTypography.footnote1m.textSecondary, - ), - ], - ), - GestureDetector( - onTap: () => onUpdated(position.copyWith(location: null)), - child: const Icon( - UiIcons.close, - size: 14, - color: UiColors.destructive, - ), - ), - ], - ), - const SizedBox(height: UiConstants.space2), - _PositionLocationInput( - value: position.location ?? '', - onChanged: (String val) => - onUpdated(position.copyWith(location: val)), - hintText: - t.client_create_order.one_time.different_location_hint, - ), - ], - ), - - const SizedBox(height: UiConstants.space3), - // Lunch Break Text(lunchLabel, style: UiTypography.footnote2r.textSecondary), const SizedBox(height: UiConstants.space1), Container( - height: 44, padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 44, decoration: BoxDecoration( borderRadius: UiConstants.radiusMd, border: Border.all(color: UiColors.border), @@ -320,50 +261,15 @@ class OneTimeOrderPositionCard extends StatelessWidget { onUpdated(position.copyWith(lunchBreak: val)); } }, - items: >[ - DropdownMenuItem( - value: 0, + items: [0, 15, 30, 45, 60].map((int mins) { + return DropdownMenuItem( + value: mins, child: Text( - t.client_create_order.one_time.no_break, + mins == 0 ? 'No Break' : '$mins mins', style: UiTypography.body2r.textPrimary, ), - ), - DropdownMenuItem( - value: 10, - child: Text( - '10 ${t.client_create_order.one_time.paid_break}', - style: UiTypography.body2r.textPrimary, - ), - ), - DropdownMenuItem( - value: 15, - child: Text( - '15 ${t.client_create_order.one_time.paid_break}', - style: UiTypography.body2r.textPrimary, - ), - ), - DropdownMenuItem( - value: 30, - child: Text( - '30 ${t.client_create_order.one_time.unpaid_break}', - style: UiTypography.body2r.textPrimary, - ), - ), - DropdownMenuItem( - value: 45, - child: Text( - '45 ${t.client_create_order.one_time.unpaid_break}', - style: UiTypography.body2r.textPrimary, - ), - ), - DropdownMenuItem( - value: 60, - child: Text( - '60 ${t.client_create_order.one_time.unpaid_break}', - style: UiTypography.body2r.textPrimary, - ), - ), - ], + ); + }).toList(), ), ), ), @@ -378,83 +284,37 @@ class OneTimeOrderPositionCard extends StatelessWidget { required String value, required VoidCallback onTap, }) { - return UiTextField( - label: label, - controller: TextEditingController(text: value), - readOnly: true, - onTap: onTap, - hintText: '--:--', - ); - } - - int _getMockRate(String role) { - switch (role) { - case 'Server': - return 18; - case 'Bartender': - return 22; - case 'Cook': - return 20; - case 'Busser': - return 16; - case 'Host': - return 17; - case 'Barista': - return 16; - case 'Dishwasher': - return 15; - case 'Event Staff': - return 20; - default: - return 15; - } - } -} - -class _PositionLocationInput extends StatefulWidget { - const _PositionLocationInput({ - required this.value, - required this.hintText, - required this.onChanged, - }); - - final String value; - final String hintText; - final ValueChanged onChanged; - - @override - State<_PositionLocationInput> createState() => _PositionLocationInputState(); -} - -class _PositionLocationInputState extends State<_PositionLocationInput> { - late final TextEditingController _controller; - - @override - void initState() { - super.initState(); - _controller = TextEditingController(text: widget.value); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - void didUpdateWidget(_PositionLocationInput oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.value != _controller.text) { - _controller.text = widget.value; - } - } - - @override - Widget build(BuildContext context) { - return UiTextField( - controller: _controller, - onChanged: widget.onChanged, - hintText: widget.hintText, + 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, + ), + ], + ), + ), + ), + ], ); } } diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart index b8909ac6..19a27567 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart @@ -35,6 +35,47 @@ class OneTimeOrderView extends StatelessWidget { ); } + if (state.vendors.isEmpty && + state.status != OneTimeOrderStatus.loading) { + return Scaffold( + backgroundColor: UiColors.bgPrimary, + body: Column( + children: [ + OneTimeOrderHeader( + title: labels.title, + subtitle: labels.subtitle, + onBack: () => Modular.to.pop(), + ), + 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( backgroundColor: UiColors.bgPrimary, body: Column( @@ -88,6 +129,47 @@ class _OneTimeOrderForm extends StatelessWidget { ), const SizedBox(height: UiConstants.space4), + // Vendor Selection + Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: 8), + 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: state.selectedVendor, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (Vendor? vendor) { + if (vendor != null) { + BlocProvider.of( + context, + ).add(OneTimeOrderVendorChanged(vendor)); + } + }, + items: state.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: state.date, @@ -133,6 +215,7 @@ class _OneTimeOrderForm extends StatelessWidget { startLabel: labels.start_label, endLabel: labels.end_label, lunchLabel: labels.lunch_break_label, + vendor: state.selectedVendor, onUpdated: (OneTimeOrderPosition updated) { BlocProvider.of( context, diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart index 7c6beb78..8753ecd0 100644 --- a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart +++ b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart @@ -643,6 +643,9 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { late List> _positions; + List _vendors = const []; + Vendor? _selectedVendor; + @override void initState() { super.initState(); @@ -661,6 +664,39 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { 'location': null, }, ]; + + // Mock vendors initialization + _vendors = const [ + Vendor( + id: 'v1', + name: 'Elite Staffing', + rates: { + 'Server': 25.0, + 'Bartender': 30.0, + 'Cook': 28.0, + 'Busser': 18.0, + 'Host': 20.0, + 'Barista': 22.0, + 'Dishwasher': 17.0, + 'Event Staff': 19.0, + }, + ), + Vendor( + id: 'v2', + name: 'Premier Workforce', + rates: { + 'Server': 22.0, + 'Bartender': 28.0, + 'Cook': 25.0, + 'Busser': 16.0, + 'Host': 18.0, + 'Barista': 20.0, + 'Dishwasher': 15.0, + 'Event Staff': 18.0, + }, + ), + ]; + _selectedVendor = _vendors.first; } @override @@ -707,7 +743,10 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { hours = endH - startH; if (hours < 0) hours += 24; } catch (_) {} - total += hours * widget.order.hourlyRate * (pos['count'] as int); + + final double rate = + _selectedVendor?.rates[pos['role']] ?? widget.order.hourlyRate; + total += hours * rate * (pos['count'] as int); } return total; } @@ -741,6 +780,45 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { ), const SizedBox(height: UiConstants.space4), + _buildSectionHeader('VENDOR'), + 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) { + setState(() => _selectedVendor = vendor); + } + }, + items: _vendors.map((Vendor vendor) { + return DropdownMenuItem( + value: vendor, + child: Text( + vendor.name, + style: UiTypography.body2m.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space4), + _buildSectionHeader('DATE'), UiTextField( controller: _dateController, @@ -902,17 +980,8 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { hint: 'Select role', value: pos['role'], items: [ - 'Server', - 'Bartender', - 'Cook', - 'Busser', - 'Host', - 'Barista', - 'Dishwasher', - 'Event Staff', - if (pos['role'] != null && - pos['role'].toString().isNotEmpty && - ![ + ...(_selectedVendor?.rates.keys.toList() ?? + [ 'Server', 'Bartender', 'Cook', @@ -921,9 +990,17 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { 'Barista', 'Dishwasher', 'Event Staff', - ].contains(pos['role'])) + ]), + if (pos['role'] != null && + pos['role'].toString().isNotEmpty && + !(_selectedVendor?.rates.keys.contains(pos['role']) ?? false)) pos['role'].toString(), ], + itemBuilder: (dynamic role) { + final double? rate = _selectedVendor?.rates[role]; + if (rate == null) return role.toString(); + return '$role - \$${rate.toStringAsFixed(0)}/hr'; + }, onChanged: (dynamic val) => _updatePosition(index, 'role', val), ), @@ -1358,6 +1435,9 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { } Widget _buildReviewPositionCard(Map pos) { + final double rate = + _selectedVendor?.rates[pos['role']] ?? widget.order.hourlyRate; + return Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(16), @@ -1387,7 +1467,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { ], ), Text( - '\$${widget.order.hourlyRate.round()}/hr', + '\$${rate.round()}/hr', style: UiTypography.body2b.copyWith(color: UiColors.primary), ), ],