diff --git a/.claude/agents/architecture-reviewer.md b/.claude/agents/architecture-reviewer.md index 8918f26d..887a4f0b 100644 --- a/.claude/agents/architecture-reviewer.md +++ b/.claude/agents/architecture-reviewer.md @@ -58,6 +58,7 @@ and load any additional skills as needed for specific review challenges. 6. Missing tests for use cases or repositories 7. Complex BLoC without bloc_test coverage 8. Test coverage below 70% for business logic +9. Hardcoded user-facing strings — must use `core_localization` (Slang) via `t.
.`. All `Text('...')` with literal English/Spanish strings in presentation layer must be replaced with localized keys ### MODERATE (Request Fix, can be deferred with justification): 1. Missing doc comments on public APIs @@ -103,7 +104,7 @@ Verify: - Business logic resides exclusively in use cases - Entities are in domain, models in data, widgets in presentation -### Step 3: Design System Compliance +### Step 3: Design System & Localization Compliance ```bash # Hardcoded colors @@ -117,9 +118,22 @@ grep -rn -E "EdgeInsets\.(all|symmetric|only)\(" apps/mobile/apps/*/lib/features # Direct icon imports grep -rn "^import.*icons" apps/mobile/apps/*/lib/features/ + +# Hardcoded user-facing strings (look for Text('...') with literal strings) +grep -rn "Text(['\"]" apps/mobile/packages/features/ +# Also check for hardcoded strings in SnackBar, AlertDialog, AppBar title, etc. +grep -rn "title: ['\"]" apps/mobile/packages/features/ +grep -rn "label: ['\"]" apps/mobile/packages/features/ +grep -rn "hintText: ['\"]" apps/mobile/packages/features/ ``` -All styling must come from the design system. No exceptions. +All styling must come from the design system. All user-facing strings must come from `core_localization` via Slang (`t.
.`). No exceptions. + +**Localization rules:** +- Strings defined in `packages/core_localization/lib/src/l10n/en.i18n.json` and `es.i18n.json` +- Accessed via `t.
.` (e.g., `t.client_create_order.review.invalid_arguments`) +- Both `en` and `es` JSON files must be updated together +- Regenerate with `dart run slang` from `packages/core_localization/` directory ### Step 4: State Management Review For every BLoC in changed files, verify: diff --git a/.claude/agents/mobile-feature-builder.md b/.claude/agents/mobile-builder.md similarity index 97% rename from .claude/agents/mobile-feature-builder.md rename to .claude/agents/mobile-builder.md index 2923b110..a04180ec 100644 --- a/.claude/agents/mobile-feature-builder.md +++ b/.claude/agents/mobile-builder.md @@ -1,12 +1,12 @@ --- -name: mobile-feature-builder +name: mobile-builder description: "Use this agent when implementing new mobile features or modifying existing features in the KROW Workforce staff or client mobile apps. This includes creating new feature modules, adding screens, implementing BLoCs, writing use cases, building repository implementations, integrating Firebase Data Connect, and writing tests for mobile features. Examples:\\n\\n- User: \"Add a shift swap feature to the staff app\"\\n Assistant: \"I'll use the mobile-feature-builder agent to implement the shift swap feature following Clean Architecture principles.\"\\n Since the user is requesting a new mobile feature, use the Agent tool to launch the mobile-feature-builder agent to plan and implement the feature with proper domain/data/presentation layers.\\n\\n- User: \"Create a new notifications screen in the client app with real-time updates\"\\n Assistant: \"Let me launch the mobile-feature-builder agent to implement the notifications feature with proper BLoC state management and Firebase integration.\"\\n Since the user wants a new mobile screen with state management, use the Agent tool to launch the mobile-feature-builder agent to build it with correct architecture.\\n\\n- User: \"The timesheet feature needs a new use case for calculating overtime\"\\n Assistant: \"I'll use the mobile-feature-builder agent to add the overtime calculation use case to the timesheet feature's domain layer.\"\\n Since the user is requesting business logic additions to a mobile feature, use the Agent tool to launch the mobile-feature-builder agent to implement it in the correct layer.\\n\\n- User: \"Write tests for the job listing BLoC in the staff app\"\\n Assistant: \"Let me use the mobile-feature-builder agent to write comprehensive BLoC tests using bloc_test and mocktail.\"\\n Since the user wants mobile feature tests written, use the Agent tool to launch the mobile-feature-builder agent which knows the testing patterns and conventions." model: opus color: blue memory: project --- -You are the **Mobile Feature Agent**, an elite Flutter/Dart engineer specializing in Clean Architecture mobile development for the KROW Workforce platform. You have deep expertise in BLoC state management, feature-first packaging, and design system compliance. You enforce **zero tolerance for architectural violations**. +You are the **Mobile Development Agent**, an elite Flutter/Dart engineer specializing in Clean Architecture mobile development for the KROW Workforce platform. You have deep expertise in BLoC state management, feature-first packaging, and design system compliance. You enforce **zero tolerance for architectural violations**. ## Initial Setup diff --git a/.claude/skills/krow-mobile-architecture/SKILL.md b/.claude/skills/krow-mobile-architecture/SKILL.md index eccc0bb2..2ba4d4cf 100644 --- a/.claude/skills/krow-mobile-architecture/SKILL.md +++ b/.claude/skills/krow-mobile-architecture/SKILL.md @@ -266,11 +266,29 @@ design_system/ - Export `TranslationProvider` for `context.strings` access - Map domain failures to localized error messages via `ErrorTranslator` +**String Definition:** +- Strings are defined in `packages/core_localization/lib/src/l10n/en.i18n.json` (English) and `es.i18n.json` (Spanish) +- Both files MUST be updated together when adding/modifying strings +- Generated output: `strings.g.dart`, `strings_en.g.dart`, `strings_es.g.dart` +- Regenerate with: `cd packages/core_localization && dart run slang` + **Feature Integration:** ```dart -// Features access strings -Text(context.strings.loginButton) +// ✅ CORRECT: Access via Slang's global `t` accessor +import 'package:core_localization/core_localization.dart'; +Text(t.client_create_order.review.invalid_arguments) +Text(t.errors.order.creation_failed) + +// ❌ FORBIDDEN: Hardcoded user-facing strings +Text('Invalid review arguments') // Must use localized key +Text('Order created!') // Must use localized key +``` + +**RESTRICTION:** ALL user-facing strings in the presentation layer (Text widgets, SnackBars, AppBar titles, hints, labels, error messages, dialogs) MUST use localized keys via `t.
.`. No hardcoded English or Spanish strings. + +**BLoC Error Flow:** +```dart // BLoCs emit domain failures (not strings) emit(AuthError(InvalidCredentialsFailure())); @@ -879,6 +897,11 @@ Navigator.push(context, MaterialPageRoute(...)); // ← Use Modular Modular.to.navigate('/profile'); // ← Use safe extensions ``` +❌ **Hardcoded user-facing strings** +```dart +Text('Order created successfully!'); // ← Use t.section.key from core_localization +``` + ## Summary The architecture enforces: 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 a69e7984..bfbf59ef 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 @@ -399,7 +399,31 @@ "placeholder": "Permanent Order Flow (Work in Progress)" }, "review": { - "invalid_arguments": "Unable to load order review. Please go back and try again." + "invalid_arguments": "Unable to load order review. Please go back and try again.", + "title": "Review & Submit", + "subtitle": "Confirm details before posting", + "edit": "Edit", + "basics": "Basics", + "order_name": "Order Name", + "hub": "Hub", + "shift_contact": "Shift Contact", + "schedule": "Schedule", + "date": "Date", + "time": "Time", + "duration": "Duration", + "start_date": "Start Date", + "end_date": "End Date", + "repeat": "Repeat", + "positions": "POSITIONS", + "total": "Total", + "estimated_total": "Estimated Total", + "estimated_weekly_total": "Estimated Weekly Total", + "post_order": "Post Order", + "hours_suffix": "hrs" + }, + "rapid_draft": { + "title": "Rapid Order", + "subtitle": "Verify the order details" } }, "client_main": { 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 f4b30b63..1d8e5bc7 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 @@ -399,7 +399,31 @@ "placeholder": "Flujo de Orden Permanente (Trabajo en Progreso)" }, "review": { - "invalid_arguments": "No se pudo cargar la revisi\u00f3n de la orden. Por favor, regresa e intenta de nuevo." + "invalid_arguments": "No se pudo cargar la revisi\u00f3n de la orden. Por favor, regresa e intenta de nuevo.", + "title": "Revisar y Enviar", + "subtitle": "Confirma los detalles antes de publicar", + "edit": "Editar", + "basics": "Datos B\u00e1sicos", + "order_name": "Nombre de la Orden", + "hub": "Hub", + "shift_contact": "Contacto del Turno", + "schedule": "Horario", + "date": "Fecha", + "time": "Hora", + "duration": "Duraci\u00f3n", + "start_date": "Fecha de Inicio", + "end_date": "Fecha de Fin", + "repeat": "Repetir", + "positions": "POSICIONES", + "total": "Total", + "estimated_total": "Total Estimado", + "estimated_weekly_total": "Total Semanal Estimado", + "post_order": "Publicar Orden", + "hours_suffix": "hrs" + }, + "rapid_draft": { + "title": "Orden R\u00e1pida", + "subtitle": "Verifica los detalles de la orden" } }, "client_main": { diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart index 96fb40f3..3a504e25 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; + import '../../utils/time_parsing_utils.dart'; enum OneTimeOrderStatus { initial, loading, success, failure } @@ -162,9 +163,8 @@ class OneTimeOrderState extends Equatable { buffer.write('$minutes min'); } - if (first.lunchBreak != null && - first.lunchBreak != 'NO_BREAK' && - first.lunchBreak!.isNotEmpty) { + if (first.lunchBreak != 'NO_BREAK' && + first.lunchBreak.isNotEmpty) { buffer.write(' (${first.lunchBreak} break)'); } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart index 5c0c34af..4862958d 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart @@ -170,10 +170,10 @@ class PermanentOrderBloc extends Bloc await _loadRolesForVendor(event.vendor.id, emit); } - void _onHubsLoaded( + Future _onHubsLoaded( PermanentOrderHubsLoaded event, Emitter emit, - ) { + ) async { final PermanentOrderHubOption? selectedHub = event.hubs.isNotEmpty ? event.hubs.first : null; @@ -186,16 +186,16 @@ class PermanentOrderBloc extends Bloc ); if (selectedHub != null) { - _loadManagersForHub(selectedHub.id, emit); + await _loadManagersForHub(selectedHub.id, emit); } } - void _onHubChanged( + Future _onHubChanged( PermanentOrderHubChanged event, Emitter emit, - ) { + ) async { emit(state.copyWith(selectedHub: event.hub, location: event.hub.name)); - _loadManagersForHub(event.hub.id, emit); + await _loadManagersForHub(event.hub.id, emit); } void _onHubManagerChanged( diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart index 229ff05d..c024994b 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart @@ -148,8 +148,8 @@ class PermanentOrderState extends Equatable { sum + (p.count * roleCostById(p.role)), ); - /// Estimated total cost: sum of (count * costPerHour * hours) per position. - double get estimatedTotal { + /// Daily cost: sum of (count * costPerHour * hours) per position. + double get dailyCost { double total = 0; for (final PermanentOrderPosition p in positions) { final double hours = parseHoursFromTimes(p.startTime, p.endTime); @@ -158,6 +158,12 @@ class PermanentOrderState extends Equatable { return total; } + /// Estimated weekly total cost for the permanent order. + /// + /// Calculated as [dailyCost] multiplied by the number of selected + /// [permanentDays] per week. + double get estimatedTotal => dailyCost * permanentDays.length; + /// Formatted repeat days (e.g. "Mon, Tue, Wed"). String get formattedRepeatDays => permanentDays.map( (String day) => day[0] + day.substring(1).toLowerCase(), diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart index eaa5d0b4..522a9c35 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +import '../../utils/schedule_utils.dart'; import '../../utils/time_parsing_utils.dart'; enum RecurringOrderStatus { initial, loading, success, failure } @@ -155,8 +156,8 @@ class RecurringOrderState extends Equatable { sum + (p.count * roleCostById(p.role)), ); - /// Estimated total cost: sum of (count * costPerHour * hours) per position. - double get estimatedTotal { + /// Daily cost: sum of (count * costPerHour * hours) per position. + double get dailyCost { double total = 0; for (final RecurringOrderPosition p in positions) { final double hours = parseHoursFromTimes(p.startTime, p.endTime); @@ -165,6 +166,31 @@ class RecurringOrderState extends Equatable { return total; } + /// Total number of working days between [startDate] and [endDate] + /// (inclusive) that match the selected [recurringDays]. + /// + /// Iterates day-by-day and counts each date whose weekday label + /// (e.g. "MON", "TUE") appears in [recurringDays]. + int get totalWorkingDays { + final Set selectedSet = recurringDays.toSet(); + int count = 0; + for ( + DateTime day = startDate; + !day.isAfter(endDate); + day = day.add(const Duration(days: 1)) + ) { + if (selectedSet.contains(weekdayLabel(day))) { + count++; + } + } + return count; + } + + /// Estimated total cost for the entire recurring order period. + /// + /// Calculated as [dailyCost] multiplied by [totalWorkingDays]. + double get estimatedTotal => dailyCost * totalWorkingDays; + /// Formatted repeat days (e.g. "Mon, Tue, Wed"). String get formattedRepeatDays => recurringDays.map( (String day) => day[0] + day.substring(1).toLowerCase(), diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/models/review_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/models/review_order_arguments.dart index f833ca8b..c00a1e78 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/models/review_order_arguments.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/models/review_order_arguments.dart @@ -25,6 +25,7 @@ class ReviewOrderArguments { this.scheduleStartDate, this.scheduleEndDate, this.scheduleRepeatDays, + this.totalLabel, }); final ReviewOrderType orderType; @@ -45,4 +46,7 @@ class ReviewOrderArguments { final String? scheduleStartDate; final String? scheduleEndDate; final String? scheduleRepeatDays; + + /// Optional label override for the total banner (e.g. "Estimated Weekly Total"). + final String? totalLabel; } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart index 2dfe92ef..8e272bb9 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart @@ -1,7 +1,8 @@ +import 'package:client_orders_common/client_orders_common.dart'; +import 'package:core_localization/core_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:client_orders_common/client_orders_common.dart'; import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -9,6 +10,7 @@ import '../blocs/one_time_order/one_time_order_bloc.dart'; import '../blocs/one_time_order/one_time_order_event.dart'; import '../blocs/one_time_order/one_time_order_state.dart'; import '../models/review_order_arguments.dart'; +import '../utils/time_parsing_utils.dart'; import '../widgets/review_order/review_order_positions_card.dart'; /// Page for creating a one-time staffing order. @@ -59,8 +61,8 @@ class OneTimeOrderPage extends StatelessWidget { : null, hubManagers: state.managers.map(_mapManager).toList(), isValid: state.isValid, - title: state.isRapidDraft ? 'Rapid Order' : null, - subtitle: state.isRapidDraft ? 'Verify the order details' : null, + title: state.isRapidDraft ? t.client_create_order.rapid_draft.title : null, + subtitle: state.isRapidDraft ? t.client_create_order.rapid_draft.subtitle : null, onEventNameChanged: (String val) => bloc.add(OneTimeOrderEventNameChanged(val)), onVendorChanged: (Vendor val) => @@ -116,6 +118,9 @@ class OneTimeOrderPage extends StatelessWidget { roleName: state.roleNameById(p.role) ?? p.role, workerCount: p.count, costPerHour: state.roleCostById(p.role), + hours: parseHoursFromTimes(p.startTime, p.endTime), + startTime: p.startTime, + endTime: p.endTime, ), ).toList(); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart index d1220dd2..331c76b6 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart @@ -1,3 +1,4 @@ +import 'package:core_localization/core_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; @@ -10,6 +11,7 @@ import '../blocs/permanent_order/permanent_order_event.dart'; import '../blocs/permanent_order/permanent_order_state.dart'; import '../models/review_order_arguments.dart'; import '../utils/schedule_utils.dart'; +import '../utils/time_parsing_utils.dart'; import '../widgets/review_order/review_order_positions_card.dart'; /// Page for creating a permanent staffing order. @@ -128,6 +130,9 @@ class PermanentOrderPage extends StatelessWidget { roleName: state.roleNameById(p.role) ?? p.role, workerCount: p.count, costPerHour: state.roleCostById(p.role), + hours: parseHoursFromTimes(p.startTime, p.endTime), + startTime: p.startTime, + endTime: p.endTime, ), ).toList(); @@ -143,6 +148,7 @@ class PermanentOrderPage extends StatelessWidget { estimatedTotal: state.estimatedTotal, scheduleStartDate: DateFormat.yMMMd().format(state.startDate), scheduleRepeatDays: state.formattedRepeatDays, + totalLabel: t.client_create_order.review.estimated_weekly_total, ), ); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart index c7fe4979..c092b12e 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart @@ -10,6 +10,7 @@ import '../blocs/recurring_order/recurring_order_event.dart'; import '../blocs/recurring_order/recurring_order_state.dart'; import '../models/review_order_arguments.dart'; import '../utils/schedule_utils.dart'; +import '../utils/time_parsing_utils.dart'; import '../widgets/review_order/review_order_positions_card.dart'; /// Page for creating a recurring staffing order. @@ -138,6 +139,9 @@ class RecurringOrderPage extends StatelessWidget { roleName: state.roleNameById(p.role) ?? p.role, workerCount: p.count, costPerHour: state.roleCostById(p.role), + hours: parseHoursFromTimes(p.startTime, p.endTime), + startTime: p.startTime, + endTime: p.endTime, ), ).toList(); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/review_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/review_order_page.dart index 44500629..c92ef85d 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/review_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/review_order_page.dart @@ -52,6 +52,7 @@ class ReviewOrderPage extends StatelessWidget { totalWorkers: args.totalWorkers, totalCostPerHour: args.totalCostPerHour, estimatedTotal: args.estimatedTotal, + totalLabel: args.totalLabel, showEditButtons: showEdit, onEditBasics: showEdit ? () => Modular.to.popSafe() : null, onEditSchedule: showEdit ? () => Modular.to.popSafe() : null, diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/one_time_schedule_section.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/one_time_schedule_section.dart index 190b1215..b7cef302 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/one_time_schedule_section.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/one_time_schedule_section.dart @@ -1,3 +1,4 @@ +import 'package:core_localization/core_localization.dart'; import 'package:flutter/material.dart'; import 'review_order_info_row.dart'; import 'review_order_section_card.dart'; @@ -20,11 +21,11 @@ class OneTimeScheduleSection extends StatelessWidget { @override Widget build(BuildContext context) { return ReviewOrderSectionCard( - title: 'Schedule', + title: t.client_create_order.review.schedule, children: [ - ReviewOrderInfoRow(label: 'Date', value: date), - ReviewOrderInfoRow(label: 'Time', value: time), - ReviewOrderInfoRow(label: 'Duration', value: duration), + ReviewOrderInfoRow(label: t.client_create_order.review.date, value: date), + ReviewOrderInfoRow(label: t.client_create_order.review.time, value: time), + ReviewOrderInfoRow(label: t.client_create_order.review.duration, value: duration), ], ); } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/permanent_schedule_section.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/permanent_schedule_section.dart index e656bb17..3fe2bff6 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/permanent_schedule_section.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/permanent_schedule_section.dart @@ -1,3 +1,4 @@ +import 'package:core_localization/core_localization.dart'; import 'package:flutter/material.dart'; import 'review_order_info_row.dart'; import 'review_order_section_card.dart'; @@ -20,11 +21,11 @@ class PermanentScheduleSection extends StatelessWidget { @override Widget build(BuildContext context) { return ReviewOrderSectionCard( - title: 'Schedule', + title: t.client_create_order.review.schedule, onEdit: onEdit, children: [ - ReviewOrderInfoRow(label: 'Start Date', value: startDate), - ReviewOrderInfoRow(label: 'Repeat', value: repeatDays), + ReviewOrderInfoRow(label: t.client_create_order.review.start_date, value: startDate), + ReviewOrderInfoRow(label: t.client_create_order.review.repeat, value: repeatDays), ], ); } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/recurring_schedule_section.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/recurring_schedule_section.dart index d1bbcab3..5e9ac4d8 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/recurring_schedule_section.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/recurring_schedule_section.dart @@ -1,3 +1,4 @@ +import 'package:core_localization/core_localization.dart'; import 'package:flutter/material.dart'; import 'review_order_info_row.dart'; import 'review_order_section_card.dart'; @@ -22,12 +23,12 @@ class RecurringScheduleSection extends StatelessWidget { @override Widget build(BuildContext context) { return ReviewOrderSectionCard( - title: 'Schedule', + title: t.client_create_order.review.schedule, onEdit: onEdit, children: [ - ReviewOrderInfoRow(label: 'Start Date', value: startDate), - ReviewOrderInfoRow(label: 'End Date', value: endDate), - ReviewOrderInfoRow(label: 'Repeat', value: repeatDays), + ReviewOrderInfoRow(label: t.client_create_order.review.start_date, value: startDate), + ReviewOrderInfoRow(label: t.client_create_order.review.end_date, value: endDate), + ReviewOrderInfoRow(label: t.client_create_order.review.repeat, value: repeatDays), ], ); } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_action_bar.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_action_bar.dart index 12f01da7..0f000a61 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_action_bar.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_action_bar.dart @@ -1,3 +1,4 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; @@ -9,14 +10,14 @@ class ReviewOrderActionBar extends StatelessWidget { const ReviewOrderActionBar({ required this.onBack, required this.onSubmit, - this.submitLabel = 'Post Order', + this.submitLabel, this.isLoading = false, super.key, }); final VoidCallback onBack; final VoidCallback? onSubmit; - final String submitLabel; + final String? submitLabel; final bool isLoading; @override @@ -32,38 +33,19 @@ class ReviewOrderActionBar extends StatelessWidget { ), child: Row( children: [ - SizedBox( - width: 80, - height: 52, - child: OutlinedButton( - onPressed: onBack, - style: OutlinedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: UiConstants.radiusXl, - ), - side: const BorderSide( - color: UiColors.border, - width: 1.5, - ), - ), - child: const Icon( - UiIcons.chevronLeft, - size: UiConstants.iconMd, - color: UiColors.iconPrimary, - ), - ), + UiButton.secondary( + leadingIcon: UiIcons.chevronLeft, + onPressed: onBack, + size: UiButtonSize.large, + text: '', ), const SizedBox(width: UiConstants.space3), Expanded( - child: SizedBox( - height: 52, - child: UiButton.primary( - text: submitLabel, - onPressed: onSubmit, - isLoading: isLoading, - size: UiButtonSize.large, - fullWidth: true, - ), + child: UiButton.primary( + text: submitLabel ?? t.client_create_order.review.post_order, + onPressed: onSubmit, + isLoading: isLoading, + size: UiButtonSize.large, ), ), ], diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_basics_card.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_basics_card.dart index 26655b13..d17305ff 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_basics_card.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_basics_card.dart @@ -1,3 +1,4 @@ +import 'package:core_localization/core_localization.dart'; import 'package:flutter/material.dart'; import 'review_order_info_row.dart'; import 'review_order_section_card.dart'; @@ -21,12 +22,12 @@ class ReviewOrderBasicsCard extends StatelessWidget { @override Widget build(BuildContext context) { return ReviewOrderSectionCard( - title: 'Basics', + title: t.client_create_order.review.basics, onEdit: onEdit, children: [ - ReviewOrderInfoRow(label: 'Order Name', value: orderName), - ReviewOrderInfoRow(label: 'Hub', value: hubName), - ReviewOrderInfoRow(label: 'Shift Contact', value: shiftContactName), + ReviewOrderInfoRow(label: t.client_create_order.review.order_name, value: orderName), + ReviewOrderInfoRow(label: t.client_create_order.review.hub, value: hubName), + ReviewOrderInfoRow(label: t.client_create_order.review.shift_contact, value: shiftContactName), ], ); } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_header.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_header.dart deleted file mode 100644 index 75c05c80..00000000 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_header.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; - -/// Displays the "Review & Submit" title and subtitle at the top of the -/// review order page. -class ReviewOrderHeader extends StatelessWidget { - const ReviewOrderHeader({super.key}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only( - left: UiConstants.space6, - right: UiConstants.space6, - top: UiConstants.space4, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Review & Submit', - style: UiTypography.headline2m, - ), - const SizedBox(height: UiConstants.space1), - Text( - 'Confirm details before posting', - style: UiTypography.body2r.textSecondary, - ), - ], - ), - ); - } -} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_info_row.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_info_row.dart index 9add76e5..3946c1a8 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_info_row.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_info_row.dart @@ -19,18 +19,18 @@ class ReviewOrderInfoRow extends StatelessWidget { Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: UiConstants.space2, children: [ Flexible( child: Text( label, - style: UiTypography.body3r.textSecondary, + style: UiTypography.body2r.textSecondary, ), ), - const SizedBox(width: UiConstants.space3), Flexible( child: Text( value, - style: UiTypography.body3m, + style: UiTypography.body2m, textAlign: TextAlign.end, ), ), diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_positions_card.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_positions_card.dart index 18812630..73b0b09d 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_positions_card.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_positions_card.dart @@ -1,12 +1,16 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'review_order_info_row.dart'; /// Displays a summary of all positions with a divider and total row. /// -/// Each position shows the role name and "N workers . $X/hr". +/// Each position is rendered as a two-line layout: +/// - Line 1: role name (left) and worker count with cost/hr (right). +/// - Line 2: time range and shift hours (right-aligned, muted style). +/// /// A divider separates the individual positions from the total. class ReviewOrderPositionsCard extends StatelessWidget { + /// Creates a [ReviewOrderPositionsCard]. const ReviewOrderPositionsCard({ required this.positions, required this.totalWorkers, @@ -15,9 +19,16 @@ class ReviewOrderPositionsCard extends StatelessWidget { super.key, }); + /// The list of position items to display. final List positions; + + /// The total number of workers across all positions. final int totalWorkers; + + /// The combined cost per hour across all positions. final double totalCostPerHour; + + /// Optional callback invoked when the user taps "Edit". final VoidCallback? onEdit; @override @@ -26,7 +37,7 @@ class ReviewOrderPositionsCard extends StatelessWidget { decoration: BoxDecoration( color: UiColors.white, borderRadius: UiConstants.radiusXl, - border: Border.all(color: UiColors.border), + border: Border.all(color: UiColors.border, width: 0.5), ), padding: const EdgeInsets.all(UiConstants.space4), child: Column( @@ -36,29 +47,20 @@ class ReviewOrderPositionsCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'POSITIONS', + t.client_create_order.review.positions, style: UiTypography.titleUppercase4b.textSecondary, ), if (onEdit != null) GestureDetector( onTap: onEdit, child: Text( - 'Edit', + t.client_create_order.review.edit, style: UiTypography.body3m.primary, ), ), ], ), - ...positions.map( - (ReviewPositionItem position) => Padding( - padding: const EdgeInsets.only(top: UiConstants.space3), - child: ReviewOrderInfoRow( - label: position.roleName, - value: - '${position.workerCount} workers \u00B7 \$${position.costPerHour.toStringAsFixed(0)}/hr', - ), - ), - ), + ...positions.map(_buildPositionItem), Padding( padding: const EdgeInsets.only(top: UiConstants.space3), child: Container( @@ -72,12 +74,13 @@ class ReviewOrderPositionsCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'Total', - style: UiTypography.body3m, + t.client_create_order.review.total, + style: UiTypography.body2m, ), Text( - '$totalWorkers workers \u00B7 \$${totalCostPerHour.toStringAsFixed(0)}/hr', - style: UiTypography.body3b.primary, + '$totalWorkers workers \u00B7 ' + '\$${totalCostPerHour.toStringAsFixed(0)}/hr', + style: UiTypography.body2b.primary, ), ], ), @@ -86,17 +89,83 @@ class ReviewOrderPositionsCard extends StatelessWidget { ), ); } + + /// Builds a two-line widget for a single position. + /// + /// Line 1 shows the role name on the left and worker count with cost on + /// the right. Line 2 shows the time range and shift hours, right-aligned + /// in a secondary/muted style. + Widget _buildPositionItem(ReviewPositionItem position) { + final String formattedHours = position.hours % 1 == 0 + ? position.hours.toInt().toString() + : position.hours.toStringAsFixed(1); + + return Padding( + padding: const EdgeInsets.only(top: UiConstants.space3), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + position.roleName, + style: UiTypography.body2m.textSecondary, + ), + ), + Text( + '${position.workerCount} workers \u00B7 ' + '\$${position.costPerHour.toStringAsFixed(0)}/hr', + style: UiTypography.body2m, + ), + ], + ), + const SizedBox(height: UiConstants.space1), + Align( + alignment: Alignment.centerRight, + child: Text( + '${position.startTime} - ${position.endTime} \u00B7 ' + '$formattedHours ' + '${t.client_create_order.review.hours_suffix}', + style: UiTypography.body3r.textTertiary, + ), + ), + ], + ), + ); + } } /// A single position item for the positions card. +/// +/// Contains the role name, worker count, shift hours, hourly cost, +/// and the start/end times for one position in the review summary. class ReviewPositionItem { + /// Creates a [ReviewPositionItem]. const ReviewPositionItem({ required this.roleName, required this.workerCount, required this.costPerHour, + required this.hours, + required this.startTime, + required this.endTime, }); + /// The display name of the role for this position. final String roleName; + + /// The number of workers requested for this position. final int workerCount; + + /// The cost per hour for this role. final double costPerHour; + + /// The number of shift hours (derived from start/end time). + final double hours; + + /// The formatted start time of the shift (e.g. "08:00 AM"). + final String startTime; + + /// The formatted end time of the shift (e.g. "04:00 PM"). + final String endTime; } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_section_card.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_section_card.dart index 33f8b5e8..2b926c53 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_section_card.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_section_card.dart @@ -1,3 +1,4 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; @@ -23,7 +24,7 @@ class ReviewOrderSectionCard extends StatelessWidget { decoration: BoxDecoration( color: UiColors.white, borderRadius: UiConstants.radiusXl, - border: Border.all(color: UiColors.border), + border: Border.all(color: UiColors.border, width: 0.5), ), padding: const EdgeInsets.all(UiConstants.space4), child: Column( @@ -39,10 +40,7 @@ class ReviewOrderSectionCard extends StatelessWidget { if (onEdit != null) GestureDetector( onTap: onEdit, - child: Text( - 'Edit', - style: UiTypography.body3m.primary, - ), + child: Text(t.client_create_order.review.edit, style: UiTypography.body3m.primary), ), ], ), diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_total_banner.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_total_banner.dart index 0b34924b..82430fd9 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_total_banner.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_total_banner.dart @@ -1,17 +1,24 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; /// A highlighted banner displaying the estimated total cost. /// /// Uses the primary inverse background color with a bold price display. +/// An optional [label] can override the default "Estimated Total" text. class ReviewOrderTotalBanner extends StatelessWidget { const ReviewOrderTotalBanner({ required this.totalAmount, + this.label, super.key, }); + /// The total monetary amount to display. final double totalAmount; + /// Optional label override. Defaults to the localized "Estimated Total". + final String? label; + @override Widget build(BuildContext context) { return Container( @@ -27,7 +34,7 @@ class ReviewOrderTotalBanner extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'Estimated Total', + label ?? t.client_create_order.review.estimated_total, style: UiTypography.body2m, ), Text( diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_view.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_view.dart index 46fb7453..6e8ef48c 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_view.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/review_order/review_order_view.dart @@ -1,8 +1,8 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'review_order_action_bar.dart'; import 'review_order_basics_card.dart'; -import 'review_order_header.dart'; import 'review_order_positions_card.dart'; import 'review_order_total_banner.dart'; @@ -31,7 +31,8 @@ class ReviewOrderView extends StatelessWidget { this.onEditBasics, this.onEditSchedule, this.onEditPositions, - this.submitLabel = 'Post Order', + this.submitLabel, + this.totalLabel, this.isLoading = false, super.key, }); @@ -50,16 +51,21 @@ class ReviewOrderView extends StatelessWidget { final VoidCallback? onEditBasics; final VoidCallback? onEditSchedule; final VoidCallback? onEditPositions; - final String submitLabel; + final String? submitLabel; + + /// Optional label override for the total banner. When `null`, the default + /// localized "Estimated Total" text is used. + final String? totalLabel; final bool isLoading; @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: UiColors.bgMenu, appBar: UiAppBar( showBackButton: true, onLeadingPressed: onBack, + title: t.client_create_order.review.title, + subtitle: t.client_create_order.review.subtitle, ), body: Column( children: [ @@ -68,7 +74,6 @@ class ReviewOrderView extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const ReviewOrderHeader(), Padding( padding: const EdgeInsets.symmetric( horizontal: UiConstants.space6, @@ -92,7 +97,10 @@ class ReviewOrderView extends StatelessWidget { onEdit: showEditButtons ? onEditPositions : null, ), const SizedBox(height: UiConstants.space3), - ReviewOrderTotalBanner(totalAmount: estimatedTotal), + ReviewOrderTotalBanner( + totalAmount: estimatedTotal, + label: totalLabel, + ), const SizedBox(height: UiConstants.space4), ], ), @@ -104,7 +112,7 @@ class ReviewOrderView extends StatelessWidget { ReviewOrderActionBar( onBack: onBack, onSubmit: onSubmit, - submitLabel: submitLabel, + submitLabel: submitLabel ?? t.client_create_order.review.post_order, isLoading: isLoading, ), ], diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/client_orders_common.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/client_orders_common.dart index 410be326..cec30ce5 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/client_orders_common.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/client_orders_common.dart @@ -1,10 +1,13 @@ // UI Models export 'src/presentation/widgets/order_ui_models.dart'; +// Shared Widgets +export 'src/presentation/widgets/order_bottom_action_button.dart'; + // One Time Order Widgets export 'src/presentation/widgets/one_time_order/one_time_order_date_picker.dart'; export 'src/presentation/widgets/one_time_order/one_time_order_event_name_input.dart'; -export 'src/presentation/widgets/one_time_order/one_time_order_header.dart'; +export 'src/presentation/widgets/one_time_order/one_time_order_form.dart'; export 'src/presentation/widgets/one_time_order/one_time_order_location_input.dart'; export 'src/presentation/widgets/one_time_order/one_time_order_position_card.dart'; export 'src/presentation/widgets/one_time_order/one_time_order_section_header.dart'; @@ -13,8 +16,9 @@ export 'src/presentation/widgets/one_time_order/one_time_order_view.dart'; // Permanent Order Widgets export 'src/presentation/widgets/permanent_order/permanent_order_date_picker.dart'; +export 'src/presentation/widgets/permanent_order/permanent_order_days_selector.dart'; export 'src/presentation/widgets/permanent_order/permanent_order_event_name_input.dart'; -export 'src/presentation/widgets/permanent_order/permanent_order_header.dart'; +export 'src/presentation/widgets/permanent_order/permanent_order_form.dart'; export 'src/presentation/widgets/permanent_order/permanent_order_position_card.dart'; export 'src/presentation/widgets/permanent_order/permanent_order_section_header.dart'; export 'src/presentation/widgets/permanent_order/permanent_order_success_view.dart'; @@ -22,8 +26,9 @@ export 'src/presentation/widgets/permanent_order/permanent_order_view.dart'; // Recurring Order Widgets export 'src/presentation/widgets/recurring_order/recurring_order_date_picker.dart'; +export 'src/presentation/widgets/recurring_order/recurring_order_days_selector.dart'; export 'src/presentation/widgets/recurring_order/recurring_order_event_name_input.dart'; -export 'src/presentation/widgets/recurring_order/recurring_order_header.dart'; +export 'src/presentation/widgets/recurring_order/recurring_order_form.dart'; export 'src/presentation/widgets/recurring_order/recurring_order_position_card.dart'; export 'src/presentation/widgets/recurring_order/recurring_order_section_header.dart'; export 'src/presentation/widgets/recurring_order/recurring_order_success_view.dart'; diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_form.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_form.dart new file mode 100644 index 00000000..a21092a0 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_form.dart @@ -0,0 +1,242 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../hub_manager_selector.dart'; +import '../order_ui_models.dart'; +import 'one_time_order_date_picker.dart'; +import 'one_time_order_event_name_input.dart'; +import 'one_time_order_position_card.dart'; +import 'one_time_order_section_header.dart'; + +/// The scrollable form body for the one-time order creation flow. +/// +/// Displays fields for event name, vendor selection, date, hub, hub manager, +/// and a dynamic list of position cards. +class OneTimeOrderForm extends StatelessWidget { + /// Creates a [OneTimeOrderForm]. + const OneTimeOrderForm({ + required this.eventName, + required this.selectedVendor, + required this.vendors, + required this.date, + required this.selectedHub, + required this.hubs, + required this.selectedHubManager, + required this.hubManagers, + required this.positions, + required this.roles, + required this.onEventNameChanged, + required this.onVendorChanged, + required this.onDateChanged, + required this.onHubChanged, + required this.onHubManagerChanged, + required this.onPositionAdded, + required this.onPositionUpdated, + required this.onPositionRemoved, + super.key, + }); + + /// The current event name value. + final String eventName; + + /// The currently selected vendor, if any. + final Vendor? selectedVendor; + + /// The list of available vendors to choose from. + final List vendors; + + /// The selected date for the one-time order. + final DateTime date; + + /// The currently selected hub, if any. + final OrderHubUiModel? selectedHub; + + /// The list of available hubs to choose from. + final List hubs; + + /// The currently selected hub manager, if any. + final OrderManagerUiModel? selectedHubManager; + + /// The list of available hub managers for the selected hub. + final List hubManagers; + + /// The list of position entries in the order. + final List positions; + + /// The list of available roles for position assignment. + final List roles; + + /// Called when the event name text changes. + final ValueChanged onEventNameChanged; + + /// Called when a vendor is selected. + final ValueChanged onVendorChanged; + + /// Called when the date is changed. + final ValueChanged onDateChanged; + + /// Called when a hub is selected. + final ValueChanged onHubChanged; + + /// Called when a hub manager is selected or cleared. + final ValueChanged onHubManagerChanged; + + /// Called when the user requests adding a new position. + final VoidCallback onPositionAdded; + + /// Called when a position at [index] is updated with new values. + final void Function(int index, OrderPositionUiModel position) + onPositionUpdated; + + /// Called when a position at [index] is removed. + final void Function(int index) onPositionRemoved; + + @override + Widget build(BuildContext context) { + final TranslationsClientCreateOrderOneTimeEn labels = + t.client_create_order.one_time; + + return ListView( + padding: const EdgeInsets.all(UiConstants.space5), + children: [ + OneTimeOrderEventNameInput( + label: 'ORDER NAME', + value: eventName, + onChanged: onEventNameChanged, + ), + const SizedBox(height: UiConstants.space4), + + // Vendor Selection + Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 48, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: selectedVendor, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (Vendor? vendor) { + if (vendor != null) { + onVendorChanged(vendor); + } + }, + items: vendors.map((Vendor vendor) { + return DropdownMenuItem( + value: vendor, + child: Text( + vendor.name, + style: UiTypography.body2m.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space4), + + OneTimeOrderDatePicker( + label: labels.date_label, + value: date, + onChanged: onDateChanged, + ), + const SizedBox(height: UiConstants.space4), + + Text('HUB', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 48, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: selectedHub, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (OrderHubUiModel? hub) { + if (hub != null) { + onHubChanged(hub); + } + }, + items: hubs.map((OrderHubUiModel hub) { + return DropdownMenuItem( + value: hub, + child: Text(hub.name, style: UiTypography.body2m.textPrimary), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space4), + + HubManagerSelector( + label: labels.hub_manager_label, + description: labels.hub_manager_desc, + hintText: labels.hub_manager_hint, + noManagersText: labels.hub_manager_empty, + noneText: labels.hub_manager_none, + managers: hubManagers, + selectedManager: selectedHubManager, + onChanged: onHubManagerChanged, + ), + const SizedBox(height: UiConstants.space6), + + OneTimeOrderSectionHeader( + title: labels.positions_title, + actionLabel: labels.add_position, + onAction: onPositionAdded, + ), + const SizedBox(height: UiConstants.space3), + + // Positions List + ...positions.asMap().entries.map(( + MapEntry entry, + ) { + final int index = entry.key; + final OrderPositionUiModel position = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: OneTimeOrderPositionCard( + index: index, + position: position, + isRemovable: positions.length > 1, + positionLabel: labels.positions_title, + roleLabel: labels.select_role, + workersLabel: labels.workers_label, + startLabel: labels.start_label, + endLabel: labels.end_label, + lunchLabel: labels.lunch_break_label, + roles: roles, + onUpdated: (OrderPositionUiModel updated) { + onPositionUpdated(index, updated); + }, + onRemoved: () { + onPositionRemoved(index); + }, + ), + ); + }), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_header.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_header.dart deleted file mode 100644 index d39f6c8b..00000000 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_header.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; - -/// A header widget for the one-time order flow with a colored background. -class OneTimeOrderHeader extends StatelessWidget { - /// Creates a [OneTimeOrderHeader]. - const OneTimeOrderHeader({ - required this.title, - required this.subtitle, - required this.onBack, - super.key, - }); - - /// The title of the page. - final String title; - - /// The subtitle or description. - final String subtitle; - - /// Callback when the back button is pressed. - final VoidCallback onBack; - - @override - Widget build(BuildContext context) { - return Container( - padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top + UiConstants.space5, - bottom: UiConstants.space5, - left: UiConstants.space5, - right: UiConstants.space5, - ), - color: UiColors.primary, - child: Row( - children: [ - GestureDetector( - onTap: onBack, - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: UiColors.white.withValues(alpha: 0.2), - borderRadius: UiConstants.radiusMd, - ), - child: const Icon( - UiIcons.chevronLeft, - color: UiColors.white, - size: 24, - ), - ), - ), - const SizedBox(width: UiConstants.space3), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: UiTypography.headline3m.copyWith(color: UiColors.white), - ), - Text( - subtitle, - style: UiTypography.footnote2r.copyWith( - color: UiColors.white.withValues(alpha: 0.8), - ), - ), - ], - ), - ], - ), - ); - } -} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart index 97d0bb68..3f2050f5 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart @@ -2,13 +2,10 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart'; + +import '../order_bottom_action_button.dart'; import '../order_ui_models.dart'; -import '../hub_manager_selector.dart'; -import 'one_time_order_date_picker.dart'; -import 'one_time_order_event_name_input.dart'; -import 'one_time_order_header.dart'; -import 'one_time_order_position_card.dart'; -import 'one_time_order_section_header.dart'; +import 'one_time_order_form.dart'; import 'one_time_order_success_view.dart'; /// The main content of the One-Time Order page as a dumb widget. @@ -98,322 +95,92 @@ class OneTimeOrderView extends StatelessWidget { ); } + return Scaffold( + appBar: UiAppBar( + showBackButton: true, + onLeadingPressed: onBack, + title: title ?? labels.title, + subtitle: subtitle ?? labels.subtitle, + ), + body: _buildBody(context, labels), + ); + } + + /// Builds the main body of the One-Time Order page, showing either the form or a loading indicator. + Widget _buildBody( + BuildContext context, + TranslationsClientCreateOrderOneTimeEn labels, + ) { if (vendors.isEmpty && status != OrderFormStatus.loading) { - return Scaffold( - body: Column( - children: [ - OneTimeOrderHeader( - title: title ?? labels.title, - subtitle: subtitle ?? labels.subtitle, - onBack: onBack, - ), - Expanded( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - UiIcons.search, - size: 64, - color: UiColors.iconInactive, - ), - const SizedBox(height: UiConstants.space4), - Text( - 'No Vendors Available', - style: UiTypography.headline3m.textPrimary, - ), - const SizedBox(height: UiConstants.space2), - Text( - 'There are no staffing vendors associated with your account.', - style: UiTypography.body2r.textSecondary, - textAlign: TextAlign.center, - ), - ], - ), + return Column( + children: [ + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + UiIcons.search, + size: 64, + color: UiColors.iconInactive, + ), + const SizedBox(height: UiConstants.space4), + Text( + 'No Vendors Available', + style: UiTypography.headline3m.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + Text( + 'There are no staffing vendors associated with your account.', + style: UiTypography.body2r.textSecondary, + textAlign: TextAlign.center, + ), + ], ), ), - ], - ), + ), + ], ); } - return Scaffold( - body: Column( - children: [ - OneTimeOrderHeader( - title: title ?? labels.title, - subtitle: subtitle ?? labels.subtitle, - onBack: onBack, - ), - Expanded( - child: Stack( - children: [ - _OneTimeOrderForm( - eventName: eventName, - selectedVendor: selectedVendor, - vendors: vendors, - date: date, - selectedHub: selectedHub, - hubs: hubs, - selectedHubManager: selectedHubManager, - hubManagers: hubManagers, - positions: positions, - roles: roles, - onEventNameChanged: onEventNameChanged, - onVendorChanged: onVendorChanged, - onDateChanged: onDateChanged, - onHubChanged: onHubChanged, - onHubManagerChanged: onHubManagerChanged, - onPositionAdded: onPositionAdded, - onPositionUpdated: onPositionUpdated, - onPositionRemoved: onPositionRemoved, - ), - if (status == OrderFormStatus.loading) - const Center(child: CircularProgressIndicator()), - ], - ), - ), - _BottomActionButton( - label: status == OrderFormStatus.loading - ? labels.creating - : labels.create_order, - isLoading: status == OrderFormStatus.loading, - onPressed: isValid ? onSubmit : null, - ), - ], - ), - ); - } -} - -class _OneTimeOrderForm extends StatelessWidget { - const _OneTimeOrderForm({ - required this.eventName, - required this.selectedVendor, - required this.vendors, - required this.date, - required this.selectedHub, - required this.hubs, - required this.selectedHubManager, - required this.hubManagers, - required this.positions, - required this.roles, - required this.onEventNameChanged, - required this.onVendorChanged, - required this.onDateChanged, - required this.onHubChanged, - required this.onHubManagerChanged, - required this.onPositionAdded, - required this.onPositionUpdated, - required this.onPositionRemoved, - }); - - final String eventName; - final Vendor? selectedVendor; - final List vendors; - final DateTime date; - final OrderHubUiModel? selectedHub; - final List hubs; - final OrderManagerUiModel? selectedHubManager; - final List hubManagers; - final List positions; - final List roles; - - final ValueChanged onEventNameChanged; - final ValueChanged onVendorChanged; - final ValueChanged onDateChanged; - final ValueChanged onHubChanged; - final ValueChanged onHubManagerChanged; - final VoidCallback onPositionAdded; - final void Function(int index, OrderPositionUiModel position) - onPositionUpdated; - final void Function(int index) onPositionRemoved; - - @override - Widget build(BuildContext context) { - final TranslationsClientCreateOrderOneTimeEn labels = - t.client_create_order.one_time; - - return ListView( - padding: const EdgeInsets.all(UiConstants.space5), + return Column( children: [ - Text( - labels.create_your_order, - style: UiTypography.headline3m.textPrimary, - ), - const SizedBox(height: UiConstants.space4), - - OneTimeOrderEventNameInput( - label: 'ORDER NAME', - value: eventName, - onChanged: onEventNameChanged, - ), - const SizedBox(height: UiConstants.space4), - - // Vendor Selection - Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary), - const SizedBox(height: UiConstants.space2), - Container( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), - height: 48, - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusMd, - border: Border.all(color: UiColors.border), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - isExpanded: true, - value: selectedVendor, - icon: const Icon( - UiIcons.chevronDown, - size: 18, - color: UiColors.iconSecondary, + Expanded( + child: Stack( + children: [ + OneTimeOrderForm( + eventName: eventName, + selectedVendor: selectedVendor, + vendors: vendors, + date: date, + selectedHub: selectedHub, + hubs: hubs, + selectedHubManager: selectedHubManager, + hubManagers: hubManagers, + positions: positions, + roles: roles, + onEventNameChanged: onEventNameChanged, + onVendorChanged: onVendorChanged, + onDateChanged: onDateChanged, + onHubChanged: onHubChanged, + onHubManagerChanged: onHubManagerChanged, + onPositionAdded: onPositionAdded, + onPositionUpdated: onPositionUpdated, + onPositionRemoved: onPositionRemoved, ), - onChanged: (Vendor? vendor) { - if (vendor != null) { - onVendorChanged(vendor); - } - }, - items: vendors.map((Vendor vendor) { - return DropdownMenuItem( - value: vendor, - child: Text( - vendor.name, - style: UiTypography.body2m.textPrimary, - ), - ); - }).toList(), - ), + if (status == OrderFormStatus.loading) + const Center(child: CircularProgressIndicator()), + ], ), ), - const SizedBox(height: UiConstants.space4), - - OneTimeOrderDatePicker( - label: labels.date_label, - value: date, - onChanged: onDateChanged, + OrderBottomActionButton( + label: status == OrderFormStatus.loading + ? labels.creating + : labels.create_order, + isLoading: status == OrderFormStatus.loading, + onPressed: isValid ? onSubmit : null, ), - const SizedBox(height: UiConstants.space4), - - Text('HUB', style: UiTypography.footnote2r.textSecondary), - const SizedBox(height: UiConstants.space2), - Container( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), - height: 48, - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusMd, - border: Border.all(color: UiColors.border), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - isExpanded: true, - value: selectedHub, - icon: const Icon( - UiIcons.chevronDown, - size: 18, - color: UiColors.iconSecondary, - ), - onChanged: (OrderHubUiModel? hub) { - if (hub != null) { - onHubChanged(hub); - } - }, - items: hubs.map((OrderHubUiModel hub) { - return DropdownMenuItem( - value: hub, - child: Text(hub.name, style: UiTypography.body2m.textPrimary), - ); - }).toList(), - ), - ), - ), - const SizedBox(height: UiConstants.space4), - - HubManagerSelector( - label: labels.hub_manager_label, - description: labels.hub_manager_desc, - hintText: labels.hub_manager_hint, - noManagersText: labels.hub_manager_empty, - noneText: labels.hub_manager_none, - managers: hubManagers, - selectedManager: selectedHubManager, - onChanged: onHubManagerChanged, - ), - const SizedBox(height: UiConstants.space6), - - OneTimeOrderSectionHeader( - title: labels.positions_title, - actionLabel: labels.add_position, - onAction: onPositionAdded, - ), - const SizedBox(height: UiConstants.space3), - - // Positions List - ...positions.asMap().entries.map(( - MapEntry entry, - ) { - final int index = entry.key; - final OrderPositionUiModel position = entry.value; - return Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space3), - child: OneTimeOrderPositionCard( - index: index, - position: position, - isRemovable: positions.length > 1, - positionLabel: labels.positions_title, - roleLabel: labels.select_role, - workersLabel: labels.workers_label, - startLabel: labels.start_label, - endLabel: labels.end_label, - lunchLabel: labels.lunch_break_label, - roles: roles, - onUpdated: (OrderPositionUiModel updated) { - onPositionUpdated(index, updated); - }, - onRemoved: () { - onPositionRemoved(index); - }, - ), - ); - }), ], ); } } - -class _BottomActionButton extends StatelessWidget { - const _BottomActionButton({ - required this.label, - required this.onPressed, - this.isLoading = false, - }); - final String label; - final VoidCallback? onPressed; - final bool isLoading; - - @override - Widget build(BuildContext context) { - return Container( - padding: EdgeInsets.only( - left: UiConstants.space5, - right: UiConstants.space5, - top: UiConstants.space5, - bottom: MediaQuery.of(context).padding.bottom + UiConstants.space5, - ), - decoration: const BoxDecoration( - color: UiColors.white, - border: Border(top: BorderSide(color: UiColors.border)), - ), - child: SizedBox( - width: double.infinity, - child: UiButton.primary( - text: label, - onPressed: isLoading ? null : onPressed, - size: UiButtonSize.large, - ), - ), - ); - } -} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_bottom_action_button.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_bottom_action_button.dart new file mode 100644 index 00000000..03f7ffd8 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_bottom_action_button.dart @@ -0,0 +1,49 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A bottom-pinned action button used across all order type views. +/// +/// Renders a full-width primary button with safe-area padding at the bottom +/// and a top border separator. Disables the button while [isLoading] is true. +class OrderBottomActionButton extends StatelessWidget { + /// Creates an [OrderBottomActionButton]. + const OrderBottomActionButton({ + required this.label, + required this.onPressed, + this.isLoading = false, + super.key, + }); + + /// The text displayed on the button. + final String label; + + /// Callback invoked when the button is pressed. Pass `null` to disable. + final VoidCallback? onPressed; + + /// Whether the form is currently submitting. Disables the button when true. + final bool isLoading; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.only( + left: UiConstants.space5, + right: UiConstants.space5, + top: UiConstants.space5, + bottom: MediaQuery.of(context).padding.bottom + UiConstants.space5, + ), + decoration: const BoxDecoration( + color: UiColors.white, + border: Border(top: BorderSide(color: UiColors.border, width: 0.5)), + ), + child: SizedBox( + width: double.infinity, + child: UiButton.primary( + text: label, + onPressed: isLoading ? null : onPressed, + size: UiButtonSize.large, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_days_selector.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_days_selector.dart new file mode 100644 index 00000000..37fbd915 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_days_selector.dart @@ -0,0 +1,68 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A horizontal row of circular day-of-week toggle buttons for permanent orders. +/// +/// Displays seven circles labeled S, M, T, W, T, F, S representing the days +/// of the week. Selected days are highlighted with the primary color. +class PermanentOrderDaysSelector extends StatelessWidget { + /// Creates a [PermanentOrderDaysSelector]. + const PermanentOrderDaysSelector({ + required this.selectedDays, + required this.onToggle, + super.key, + }); + + /// The list of currently selected day abbreviations (e.g. 'MON', 'TUE'). + final List selectedDays; + + /// Called when a day circle is tapped, with the day index (0 = Sunday). + final ValueChanged onToggle; + + @override + Widget build(BuildContext context) { + const List labelsShort = [ + 'S', + 'M', + 'T', + 'W', + 'T', + 'F', + 'S', + ]; + const List labelsLong = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + return Wrap( + spacing: UiConstants.space2, + children: List.generate(labelsShort.length, (int index) { + final bool isSelected = selectedDays.contains(labelsLong[index]); + return GestureDetector( + onTap: () => onToggle(index), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: isSelected ? UiColors.primary : UiColors.white, + shape: BoxShape.circle, + border: Border.all(color: UiColors.border), + ), + alignment: Alignment.center, + child: Text( + labelsShort[index], + style: UiTypography.body2m.copyWith( + color: isSelected ? UiColors.white : UiColors.textSecondary, + ), + ), + ), + ); + }), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_form.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_form.dart new file mode 100644 index 00000000..a9185ce3 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_form.dart @@ -0,0 +1,271 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart' show Vendor; + +import '../hub_manager_selector.dart'; +import '../order_ui_models.dart'; +import 'permanent_order_date_picker.dart'; +import 'permanent_order_days_selector.dart'; +import 'permanent_order_event_name_input.dart'; +import 'permanent_order_position_card.dart'; +import 'permanent_order_section_header.dart'; + +/// The scrollable form body for the permanent order creation flow. +/// +/// Displays fields for event name, vendor selection, start date, +/// permanent day toggles, hub, hub manager, and a dynamic list of +/// position cards. +class PermanentOrderForm extends StatelessWidget { + /// Creates a [PermanentOrderForm]. + const PermanentOrderForm({ + required this.eventName, + required this.selectedVendor, + required this.vendors, + required this.startDate, + required this.permanentDays, + required this.selectedHub, + required this.hubs, + required this.positions, + required this.roles, + required this.onEventNameChanged, + required this.onVendorChanged, + required this.onStartDateChanged, + required this.onDayToggled, + required this.onHubChanged, + required this.onHubManagerChanged, + required this.onPositionAdded, + required this.onPositionUpdated, + required this.onPositionRemoved, + required this.hubManagers, + required this.selectedHubManager, + super.key, + }); + + /// The current event name value. + final String eventName; + + /// The currently selected vendor, if any. + final Vendor? selectedVendor; + + /// The list of available vendors to choose from. + final List vendors; + + /// The start date for the permanent order. + final DateTime startDate; + + /// The list of selected permanent day abbreviations (e.g. 'MON', 'TUE'). + final List permanentDays; + + /// The currently selected hub, if any. + final OrderHubUiModel? selectedHub; + + /// The list of available hubs to choose from. + final List hubs; + + /// The list of position entries in the order. + final List positions; + + /// The list of available roles for position assignment. + final List roles; + + /// Called when the event name text changes. + final ValueChanged onEventNameChanged; + + /// Called when a vendor is selected. + final ValueChanged onVendorChanged; + + /// Called when the start date is changed. + final ValueChanged onStartDateChanged; + + /// Called when a day-of-week toggle is tapped, with the day index (0=Sun). + final ValueChanged onDayToggled; + + /// Called when a hub is selected. + final ValueChanged onHubChanged; + + /// Called when a hub manager is selected or cleared. + final ValueChanged onHubManagerChanged; + + /// Called when the user requests adding a new position. + final VoidCallback onPositionAdded; + + /// Called when a position at [index] is updated with new values. + final void Function(int index, OrderPositionUiModel position) + onPositionUpdated; + + /// Called when a position at [index] is removed. + final void Function(int index) onPositionRemoved; + + /// The list of available hub managers for the selected hub. + final List hubManagers; + + /// The currently selected hub manager, if any. + final OrderManagerUiModel? selectedHubManager; + + @override + Widget build(BuildContext context) { + final TranslationsClientCreateOrderPermanentEn labels = + t.client_create_order.permanent; + final TranslationsClientCreateOrderOneTimeEn oneTimeLabels = + t.client_create_order.one_time; + + return ListView( + padding: const EdgeInsets.all(UiConstants.space5), + children: [ + Text( + labels.title, + style: UiTypography.headline3m.textPrimary, + ), + const SizedBox(height: UiConstants.space4), + + PermanentOrderEventNameInput( + label: 'ORDER NAME', + value: eventName, + onChanged: onEventNameChanged, + ), + const SizedBox(height: UiConstants.space4), + + // Vendor Selection + Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 48, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: selectedVendor, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (Vendor? vendor) { + if (vendor != null) { + onVendorChanged(vendor); + } + }, + items: vendors.map((Vendor vendor) { + return DropdownMenuItem( + value: vendor, + child: Text( + vendor.name, + style: UiTypography.body2m.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space4), + + PermanentOrderDatePicker( + label: 'Start Date', + value: startDate, + onChanged: onStartDateChanged, + ), + const SizedBox(height: UiConstants.space4), + + Text('Permanent Days', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + PermanentOrderDaysSelector( + selectedDays: permanentDays, + onToggle: onDayToggled, + ), + const SizedBox(height: UiConstants.space4), + + Text('HUB', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 48, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: selectedHub, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (OrderHubUiModel? hub) { + if (hub != null) { + onHubChanged(hub); + } + }, + items: hubs.map((OrderHubUiModel hub) { + return DropdownMenuItem( + value: hub, + child: Text( + hub.name, + style: UiTypography.body2m.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space4), + + HubManagerSelector( + label: oneTimeLabels.hub_manager_label, + description: oneTimeLabels.hub_manager_desc, + hintText: oneTimeLabels.hub_manager_hint, + noManagersText: oneTimeLabels.hub_manager_empty, + noneText: oneTimeLabels.hub_manager_none, + managers: hubManagers, + selectedManager: selectedHubManager, + onChanged: onHubManagerChanged, + ), + const SizedBox(height: UiConstants.space6), + + PermanentOrderSectionHeader( + title: oneTimeLabels.positions_title, + actionLabel: oneTimeLabels.add_position, + onAction: onPositionAdded, + ), + const SizedBox(height: UiConstants.space3), + + // Positions List + ...positions.asMap().entries.map(( + MapEntry entry, + ) { + final int index = entry.key; + final OrderPositionUiModel position = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: PermanentOrderPositionCard( + index: index, + position: position, + isRemovable: positions.length > 1, + positionLabel: oneTimeLabels.positions_title, + roleLabel: oneTimeLabels.select_role, + workersLabel: oneTimeLabels.workers_label, + startLabel: oneTimeLabels.start_label, + endLabel: oneTimeLabels.end_label, + lunchLabel: oneTimeLabels.lunch_break_label, + roles: roles, + onUpdated: (OrderPositionUiModel updated) { + onPositionUpdated(index, updated); + }, + onRemoved: () { + onPositionRemoved(index); + }, + ), + ); + }), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_header.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_header.dart deleted file mode 100644 index 8943f5f1..00000000 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_header.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; - -/// A header widget for the permanent order flow with a colored background. -class PermanentOrderHeader extends StatelessWidget { - /// Creates a [PermanentOrderHeader]. - const PermanentOrderHeader({ - required this.title, - required this.subtitle, - required this.onBack, - super.key, - }); - - /// The title of the page. - final String title; - - /// The subtitle or description. - final String subtitle; - - /// Callback when the back button is pressed. - final VoidCallback onBack; - - @override - Widget build(BuildContext context) { - return Container( - padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top + UiConstants.space5, - bottom: UiConstants.space5, - left: UiConstants.space5, - right: UiConstants.space5, - ), - color: UiColors.primary, - child: Row( - children: [ - GestureDetector( - onTap: onBack, - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: UiColors.white.withValues(alpha: 0.2), - borderRadius: UiConstants.radiusMd, - ), - child: const Icon( - UiIcons.chevronLeft, - color: UiColors.white, - size: 24, - ), - ), - ), - const SizedBox(width: UiConstants.space3), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: UiTypography.headline3m.copyWith(color: UiColors.white), - ), - Text( - subtitle, - style: UiTypography.footnote2r.copyWith( - color: UiColors.white.withValues(alpha: 0.8), - ), - ), - ], - ), - ], - ), - ); - } -} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart index abcf7a20..8c1bbf80 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart @@ -2,13 +2,10 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart' show Vendor; + +import '../order_bottom_action_button.dart'; import '../order_ui_models.dart'; -import '../hub_manager_selector.dart'; -import 'permanent_order_date_picker.dart'; -import 'permanent_order_event_name_input.dart'; -import 'permanent_order_header.dart'; -import 'permanent_order_position_card.dart'; -import 'permanent_order_section_header.dart'; +import 'permanent_order_form.dart'; import 'permanent_order_success_view.dart'; /// The main content of the Permanent Order page. @@ -65,7 +62,8 @@ class PermanentOrderView extends StatelessWidget { final ValueChanged onHubChanged; final ValueChanged onHubManagerChanged; final VoidCallback onPositionAdded; - final void Function(int index, OrderPositionUiModel position) onPositionUpdated; + final void Function(int index, OrderPositionUiModel position) + onPositionUpdated; final void Function(int index) onPositionRemoved; final VoidCallback onSubmit; final VoidCallback onDone; @@ -98,398 +96,95 @@ class PermanentOrderView extends StatelessWidget { ); } + return Scaffold( + appBar: UiAppBar( + showBackButton: true, + onLeadingPressed: onBack, + title: labels.title, + subtitle: labels.subtitle, + ), + body: _buildBody(context, labels, oneTimeLabels), + ); + } + + /// Builds the main body of the Permanent Order page based on the current state. + Widget _buildBody( + BuildContext context, + TranslationsClientCreateOrderPermanentEn labels, + TranslationsClientCreateOrderOneTimeEn oneTimeLabels, + ) { if (vendors.isEmpty && status != OrderFormStatus.loading) { - return Scaffold( - body: Column( - children: [ - PermanentOrderHeader( - title: labels.title, - subtitle: labels.subtitle, - onBack: onBack, - ), - Expanded( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - UiIcons.search, - size: 64, - color: UiColors.iconInactive, - ), - const SizedBox(height: UiConstants.space4), - Text( - 'No Vendors Available', - style: UiTypography.headline3m.textPrimary, - ), - const SizedBox(height: UiConstants.space2), - Text( - 'There are no staffing vendors associated with your account.', - style: UiTypography.body2r.textSecondary, - textAlign: TextAlign.center, - ), - ], - ), + return Column( + children: [ + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + UiIcons.search, + size: 64, + color: UiColors.iconInactive, + ), + const SizedBox(height: UiConstants.space4), + Text( + 'No Vendors Available', + style: UiTypography.headline3m.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + Text( + 'There are no staffing vendors associated with your account.', + style: UiTypography.body2r.textSecondary, + textAlign: TextAlign.center, + ), + ], ), ), - ], - ), + ), + ], ); } - return Scaffold( - body: Column( - children: [ - PermanentOrderHeader( - title: labels.title, - subtitle: labels.subtitle, - onBack: onBack, - ), - Expanded( - child: Stack( - children: [ - _PermanentOrderForm( - eventName: eventName, - selectedVendor: selectedVendor, - vendors: vendors, - startDate: startDate, - permanentDays: permanentDays, - selectedHub: selectedHub, - hubs: hubs, - positions: positions, - roles: roles, - onEventNameChanged: onEventNameChanged, - onVendorChanged: onVendorChanged, - onStartDateChanged: onStartDateChanged, - onDayToggled: onDayToggled, - onHubChanged: onHubChanged, - onHubManagerChanged: onHubManagerChanged, - onPositionAdded: onPositionAdded, - onPositionUpdated: onPositionUpdated, - onPositionRemoved: onPositionRemoved, - hubManagers: hubManagers, - selectedHubManager: selectedHubManager, - ), - if (status == OrderFormStatus.loading) - const Center(child: CircularProgressIndicator()), - ], - ), - ), - _BottomActionButton( - label: status == OrderFormStatus.loading - ? oneTimeLabels.creating - : oneTimeLabels.create_order, - isLoading: status == OrderFormStatus.loading, - onPressed: isValid ? onSubmit : null, - ), - ], - ), - ); - } -} - -class _PermanentOrderForm extends StatelessWidget { - const _PermanentOrderForm({ - required this.eventName, - required this.selectedVendor, - required this.vendors, - required this.startDate, - required this.permanentDays, - required this.selectedHub, - required this.hubs, - required this.positions, - required this.roles, - required this.onEventNameChanged, - required this.onVendorChanged, - required this.onStartDateChanged, - required this.onDayToggled, - required this.onHubChanged, - required this.onHubManagerChanged, - required this.onPositionAdded, - required this.onPositionUpdated, - required this.onPositionRemoved, - required this.hubManagers, - required this.selectedHubManager, - }); - - final String eventName; - final Vendor? selectedVendor; - final List vendors; - final DateTime startDate; - final List permanentDays; - final OrderHubUiModel? selectedHub; - final List hubs; - final List positions; - final List roles; - - final ValueChanged onEventNameChanged; - final ValueChanged onVendorChanged; - final ValueChanged onStartDateChanged; - final ValueChanged onDayToggled; - final ValueChanged onHubChanged; - final ValueChanged onHubManagerChanged; - final VoidCallback onPositionAdded; - final void Function(int index, OrderPositionUiModel position) onPositionUpdated; - final void Function(int index) onPositionRemoved; - - final List hubManagers; - final OrderManagerUiModel? selectedHubManager; - - @override - Widget build(BuildContext context) { - final TranslationsClientCreateOrderPermanentEn labels = - t.client_create_order.permanent; - final TranslationsClientCreateOrderOneTimeEn oneTimeLabels = - t.client_create_order.one_time; - - return ListView( - padding: const EdgeInsets.all(UiConstants.space5), + return Column( children: [ - Text( - labels.title, - style: UiTypography.headline3m.textPrimary, - ), - const SizedBox(height: UiConstants.space4), - - PermanentOrderEventNameInput( - label: 'ORDER NAME', - value: eventName, - onChanged: onEventNameChanged, - ), - const SizedBox(height: UiConstants.space4), - - // Vendor Selection - Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary), - const SizedBox(height: UiConstants.space2), - Container( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), - height: 48, - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusMd, - border: Border.all(color: UiColors.border), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - isExpanded: true, - value: selectedVendor, - icon: const Icon( - UiIcons.chevronDown, - size: 18, - color: UiColors.iconSecondary, + Expanded( + child: Stack( + children: [ + PermanentOrderForm( + eventName: eventName, + selectedVendor: selectedVendor, + vendors: vendors, + startDate: startDate, + permanentDays: permanentDays, + selectedHub: selectedHub, + hubs: hubs, + positions: positions, + roles: roles, + onEventNameChanged: onEventNameChanged, + onVendorChanged: onVendorChanged, + onStartDateChanged: onStartDateChanged, + onDayToggled: onDayToggled, + onHubChanged: onHubChanged, + onHubManagerChanged: onHubManagerChanged, + onPositionAdded: onPositionAdded, + onPositionUpdated: onPositionUpdated, + onPositionRemoved: onPositionRemoved, + hubManagers: hubManagers, + selectedHubManager: selectedHubManager, ), - onChanged: (Vendor? vendor) { - if (vendor != null) { - onVendorChanged(vendor); - } - }, - items: vendors.map((Vendor vendor) { - return DropdownMenuItem( - value: vendor, - child: Text( - vendor.name, - style: UiTypography.body2m.textPrimary, - ), - ); - }).toList(), - ), + if (status == OrderFormStatus.loading) + const Center(child: CircularProgressIndicator()), + ], ), ), - const SizedBox(height: UiConstants.space4), - - PermanentOrderDatePicker( - label: 'Start Date', - value: startDate, - onChanged: onStartDateChanged, + OrderBottomActionButton( + label: status == OrderFormStatus.loading + ? oneTimeLabels.creating + : oneTimeLabels.create_order, + isLoading: status == OrderFormStatus.loading, + onPressed: isValid ? onSubmit : null, ), - const SizedBox(height: UiConstants.space4), - - Text('Permanent Days', style: UiTypography.footnote2r.textSecondary), - const SizedBox(height: UiConstants.space2), - _PermanentDaysSelector( - selectedDays: permanentDays, - onToggle: onDayToggled, - ), - const SizedBox(height: UiConstants.space4), - - Text('HUB', style: UiTypography.footnote2r.textSecondary), - const SizedBox(height: UiConstants.space2), - Container( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), - height: 48, - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusMd, - border: Border.all(color: UiColors.border), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - isExpanded: true, - value: selectedHub, - icon: const Icon( - UiIcons.chevronDown, - size: 18, - color: UiColors.iconSecondary, - ), - onChanged: (OrderHubUiModel? hub) { - if (hub != null) { - onHubChanged(hub); - } - }, - items: hubs.map((OrderHubUiModel hub) { - return DropdownMenuItem( - value: hub, - child: Text( - hub.name, - style: UiTypography.body2m.textPrimary, - ), - ); - }).toList(), - ), - ), - ), - const SizedBox(height: UiConstants.space4), - - HubManagerSelector( - label: oneTimeLabels.hub_manager_label, - description: oneTimeLabels.hub_manager_desc, - hintText: oneTimeLabels.hub_manager_hint, - noManagersText: oneTimeLabels.hub_manager_empty, - noneText: oneTimeLabels.hub_manager_none, - managers: hubManagers, - selectedManager: selectedHubManager, - onChanged: onHubManagerChanged, - ), - const SizedBox(height: UiConstants.space6), - - PermanentOrderSectionHeader( - title: oneTimeLabels.positions_title, - actionLabel: oneTimeLabels.add_position, - onAction: onPositionAdded, - ), - const SizedBox(height: UiConstants.space3), - - // Positions List - ...positions.asMap().entries.map(( - MapEntry entry, - ) { - final int index = entry.key; - final OrderPositionUiModel position = entry.value; - return Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space3), - child: PermanentOrderPositionCard( - index: index, - position: position, - isRemovable: positions.length > 1, - positionLabel: oneTimeLabels.positions_title, - roleLabel: oneTimeLabels.select_role, - workersLabel: oneTimeLabels.workers_label, - startLabel: oneTimeLabels.start_label, - endLabel: oneTimeLabels.end_label, - lunchLabel: oneTimeLabels.lunch_break_label, - roles: roles, - onUpdated: (OrderPositionUiModel updated) { - onPositionUpdated(index, updated); - }, - onRemoved: () { - onPositionRemoved(index); - }, - ), - ); - }), ], ); } } - -class _PermanentDaysSelector extends StatelessWidget { - const _PermanentDaysSelector({ - required this.selectedDays, - required this.onToggle, - }); - - final List selectedDays; - final ValueChanged onToggle; - - @override - Widget build(BuildContext context) { - const List labelsShort = [ - 'S', - 'M', - 'T', - 'W', - 'T', - 'F', - 'S', - ]; - const List labelsLong = [ - 'SUN', - 'MON', - 'TUE', - 'WED', - 'THU', - 'FRI', - 'SAT', - ]; - return Wrap( - spacing: UiConstants.space2, - children: List.generate(labelsShort.length, (int index) { - final bool isSelected = selectedDays.contains(labelsLong[index]); - return GestureDetector( - onTap: () => onToggle(index), - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: isSelected ? UiColors.primary : UiColors.white, - shape: BoxShape.circle, - border: Border.all(color: UiColors.border), - ), - alignment: Alignment.center, - child: Text( - labelsShort[index], - style: UiTypography.body2m.copyWith( - color: isSelected ? UiColors.white : UiColors.textSecondary, - ), - ), - ), - ); - }), - ); - } -} - -class _BottomActionButton extends StatelessWidget { - const _BottomActionButton({ - required this.label, - required this.onPressed, - this.isLoading = false, - }); - final String label; - final VoidCallback? onPressed; - final bool isLoading; - - @override - Widget build(BuildContext context) { - return Container( - padding: EdgeInsets.only( - left: UiConstants.space5, - right: UiConstants.space5, - top: UiConstants.space5, - bottom: MediaQuery.of(context).padding.bottom + UiConstants.space5, - ), - decoration: const BoxDecoration( - color: UiColors.white, - border: Border(top: BorderSide(color: UiColors.border)), - ), - child: SizedBox( - width: double.infinity, - child: UiButton.primary( - text: label, - onPressed: isLoading ? null : onPressed, - size: UiButtonSize.large, - ), - ), - ); - } -} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_days_selector.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_days_selector.dart new file mode 100644 index 00000000..08ce04c4 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_days_selector.dart @@ -0,0 +1,68 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A horizontal row of circular day-of-week toggle buttons for recurring orders. +/// +/// Displays seven circles labeled S, M, T, W, T, F, S representing the days +/// of the week. Selected days are highlighted with the primary color. +class RecurringOrderDaysSelector extends StatelessWidget { + /// Creates a [RecurringOrderDaysSelector]. + const RecurringOrderDaysSelector({ + required this.selectedDays, + required this.onToggle, + super.key, + }); + + /// The list of currently selected day abbreviations (e.g. 'MON', 'TUE'). + final List selectedDays; + + /// Called when a day circle is tapped, with the day index (0 = Sunday). + final ValueChanged onToggle; + + @override + Widget build(BuildContext context) { + const List labelsShort = [ + 'S', + 'M', + 'T', + 'W', + 'T', + 'F', + 'S', + ]; + const List labelsLong = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + return Wrap( + spacing: UiConstants.space2, + children: List.generate(labelsShort.length, (int index) { + final bool isSelected = selectedDays.contains(labelsLong[index]); + return GestureDetector( + onTap: () => onToggle(index), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: isSelected ? UiColors.primary : UiColors.white, + shape: BoxShape.circle, + border: Border.all(color: UiColors.border), + ), + alignment: Alignment.center, + child: Text( + labelsShort[index], + style: UiTypography.body2m.copyWith( + color: isSelected ? UiColors.white : UiColors.textSecondary, + ), + ), + ), + ); + }), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_form.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_form.dart new file mode 100644 index 00000000..7a0421d9 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_form.dart @@ -0,0 +1,286 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart' show Vendor; + +import '../hub_manager_selector.dart'; +import '../order_ui_models.dart'; +import 'recurring_order_date_picker.dart'; +import 'recurring_order_days_selector.dart'; +import 'recurring_order_event_name_input.dart'; +import 'recurring_order_position_card.dart'; +import 'recurring_order_section_header.dart'; + +/// The scrollable form body for the recurring order creation flow. +/// +/// Displays fields for event name, vendor selection, start/end dates, +/// recurring day toggles, hub, hub manager, and a dynamic list of +/// position cards. +class RecurringOrderForm extends StatelessWidget { + /// Creates a [RecurringOrderForm]. + const RecurringOrderForm({ + required this.eventName, + required this.selectedVendor, + required this.vendors, + required this.startDate, + required this.endDate, + required this.recurringDays, + required this.selectedHub, + required this.hubs, + required this.positions, + required this.roles, + required this.onEventNameChanged, + required this.onVendorChanged, + required this.onStartDateChanged, + required this.onEndDateChanged, + required this.onDayToggled, + required this.onHubChanged, + required this.onHubManagerChanged, + required this.onPositionAdded, + required this.onPositionUpdated, + required this.onPositionRemoved, + required this.hubManagers, + required this.selectedHubManager, + super.key, + }); + + /// The current event name value. + final String eventName; + + /// The currently selected vendor, if any. + final Vendor? selectedVendor; + + /// The list of available vendors to choose from. + final List vendors; + + /// The start date for the recurring period. + final DateTime startDate; + + /// The end date for the recurring period. + final DateTime endDate; + + /// The list of selected recurring day abbreviations (e.g. 'MON', 'TUE'). + final List recurringDays; + + /// The currently selected hub, if any. + final OrderHubUiModel? selectedHub; + + /// The list of available hubs to choose from. + final List hubs; + + /// The list of position entries in the order. + final List positions; + + /// The list of available roles for position assignment. + final List roles; + + /// Called when the event name text changes. + final ValueChanged onEventNameChanged; + + /// Called when a vendor is selected. + final ValueChanged onVendorChanged; + + /// Called when the start date is changed. + final ValueChanged onStartDateChanged; + + /// Called when the end date is changed. + final ValueChanged onEndDateChanged; + + /// Called when a day-of-week toggle is tapped, with the day index (0=Sun). + final ValueChanged onDayToggled; + + /// Called when a hub is selected. + final ValueChanged onHubChanged; + + /// Called when a hub manager is selected or cleared. + final ValueChanged onHubManagerChanged; + + /// Called when the user requests adding a new position. + final VoidCallback onPositionAdded; + + /// Called when a position at [index] is updated with new values. + final void Function(int index, OrderPositionUiModel position) + onPositionUpdated; + + /// Called when a position at [index] is removed. + final void Function(int index) onPositionRemoved; + + /// The list of available hub managers for the selected hub. + final List hubManagers; + + /// The currently selected hub manager, if any. + final OrderManagerUiModel? selectedHubManager; + + @override + Widget build(BuildContext context) { + final TranslationsClientCreateOrderRecurringEn labels = + t.client_create_order.recurring; + final TranslationsClientCreateOrderOneTimeEn oneTimeLabels = + t.client_create_order.one_time; + + return ListView( + padding: const EdgeInsets.all(UiConstants.space5), + children: [ + Text( + labels.title, + style: UiTypography.headline3m.textPrimary, + ), + const SizedBox(height: UiConstants.space4), + + RecurringOrderEventNameInput( + label: 'ORDER NAME', + value: eventName, + onChanged: onEventNameChanged, + ), + const SizedBox(height: UiConstants.space4), + + // Vendor Selection + Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 48, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: selectedVendor, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (Vendor? vendor) { + if (vendor != null) { + onVendorChanged(vendor); + } + }, + items: vendors.map((Vendor vendor) { + return DropdownMenuItem( + value: vendor, + child: Text( + vendor.name, + style: UiTypography.body2m.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space4), + + RecurringOrderDatePicker( + label: 'Start Date', + value: startDate, + onChanged: onStartDateChanged, + ), + const SizedBox(height: UiConstants.space4), + + RecurringOrderDatePicker( + label: 'End Date', + value: endDate, + onChanged: onEndDateChanged, + ), + const SizedBox(height: UiConstants.space4), + + Text('Recurring Days', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + RecurringOrderDaysSelector( + selectedDays: recurringDays, + onToggle: onDayToggled, + ), + const SizedBox(height: UiConstants.space4), + + Text('HUB', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 48, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: selectedHub, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (OrderHubUiModel? hub) { + if (hub != null) { + onHubChanged(hub); + } + }, + items: hubs.map((OrderHubUiModel hub) { + return DropdownMenuItem( + value: hub, + child: Text( + hub.name, + style: UiTypography.body2m.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space4), + + HubManagerSelector( + label: oneTimeLabels.hub_manager_label, + description: oneTimeLabels.hub_manager_desc, + hintText: oneTimeLabels.hub_manager_hint, + noManagersText: oneTimeLabels.hub_manager_empty, + noneText: oneTimeLabels.hub_manager_none, + managers: hubManagers, + selectedManager: selectedHubManager, + onChanged: onHubManagerChanged, + ), + const SizedBox(height: UiConstants.space6), + + RecurringOrderSectionHeader( + title: oneTimeLabels.positions_title, + actionLabel: oneTimeLabels.add_position, + onAction: onPositionAdded, + ), + const SizedBox(height: UiConstants.space3), + + // Positions List + ...positions.asMap().entries.map(( + MapEntry entry, + ) { + final int index = entry.key; + final OrderPositionUiModel position = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: RecurringOrderPositionCard( + index: index, + position: position, + isRemovable: positions.length > 1, + positionLabel: oneTimeLabels.positions_title, + roleLabel: oneTimeLabels.select_role, + workersLabel: oneTimeLabels.workers_label, + startLabel: oneTimeLabels.start_label, + endLabel: oneTimeLabels.end_label, + lunchLabel: oneTimeLabels.lunch_break_label, + roles: roles, + onUpdated: (OrderPositionUiModel updated) { + onPositionUpdated(index, updated); + }, + onRemoved: () { + onPositionRemoved(index); + }, + ), + ); + }), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_header.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_header.dart deleted file mode 100644 index 5913b205..00000000 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_header.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; - -/// A header widget for the recurring order flow with a colored background. -class RecurringOrderHeader extends StatelessWidget { - /// Creates a [RecurringOrderHeader]. - const RecurringOrderHeader({ - required this.title, - required this.subtitle, - required this.onBack, - super.key, - }); - - /// The title of the page. - final String title; - - /// The subtitle or description. - final String subtitle; - - /// Callback when the back button is pressed. - final VoidCallback onBack; - - @override - Widget build(BuildContext context) { - return Container( - padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top + UiConstants.space5, - bottom: UiConstants.space5, - left: UiConstants.space5, - right: UiConstants.space5, - ), - color: UiColors.primary, - child: Row( - children: [ - GestureDetector( - onTap: onBack, - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: UiColors.white.withValues(alpha: 0.2), - borderRadius: UiConstants.radiusMd, - ), - child: const Icon( - UiIcons.chevronLeft, - color: UiColors.white, - size: 24, - ), - ), - ), - const SizedBox(width: UiConstants.space3), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: UiTypography.headline3m.copyWith(color: UiColors.white), - ), - Text( - subtitle, - style: UiTypography.footnote2r.copyWith( - color: UiColors.white.withValues(alpha: 0.8), - ), - ), - ], - ), - ], - ), - ); - } -} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart index fbc00c07..ffd3ad51 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart @@ -1,14 +1,11 @@ import 'package:core_localization/core_localization.dart'; -import 'package:krow_domain/krow_domain.dart' show Vendor; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart' show Vendor; + +import '../order_bottom_action_button.dart'; import '../order_ui_models.dart'; -import '../hub_manager_selector.dart'; -import 'recurring_order_date_picker.dart'; -import 'recurring_order_event_name_input.dart'; -import 'recurring_order_header.dart'; -import 'recurring_order_position_card.dart'; -import 'recurring_order_section_header.dart'; +import 'recurring_order_form.dart'; import 'recurring_order_success_view.dart'; /// The main content of the Recurring Order page. @@ -69,7 +66,8 @@ class RecurringOrderView extends StatelessWidget { final ValueChanged onHubChanged; final ValueChanged onHubManagerChanged; final VoidCallback onPositionAdded; - final void Function(int index, OrderPositionUiModel position) onPositionUpdated; + final void Function(int index, OrderPositionUiModel position) + onPositionUpdated; final void Function(int index) onPositionRemoved; final VoidCallback onSubmit; final VoidCallback onDone; @@ -105,412 +103,97 @@ class RecurringOrderView extends StatelessWidget { ); } + return Scaffold( + appBar: UiAppBar( + showBackButton: true, + onLeadingPressed: onBack, + title: labels.title, + subtitle: labels.subtitle, + ), + body: _buildBody(context, labels, oneTimeLabels), + ); + } + + /// Builds the main body of the Recurring Order page, including the form and handling empty vendor state. + Widget _buildBody( + BuildContext context, + TranslationsClientCreateOrderRecurringEn labels, + TranslationsClientCreateOrderOneTimeEn oneTimeLabels, + ) { if (vendors.isEmpty && status != OrderFormStatus.loading) { - return Scaffold( - body: Column( - children: [ - RecurringOrderHeader( - title: labels.title, - subtitle: labels.subtitle, - onBack: onBack, - ), - Expanded( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - UiIcons.search, - size: 64, - color: UiColors.iconInactive, - ), - const SizedBox(height: UiConstants.space4), - Text( - 'No Vendors Available', - style: UiTypography.headline3m.textPrimary, - ), - const SizedBox(height: UiConstants.space2), - Text( - 'There are no staffing vendors associated with your account.', - style: UiTypography.body2r.textSecondary, - textAlign: TextAlign.center, - ), - ], - ), + return Column( + children: [ + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + UiIcons.search, + size: 64, + color: UiColors.iconInactive, + ), + const SizedBox(height: UiConstants.space4), + Text( + 'No Vendors Available', + style: UiTypography.headline3m.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + Text( + 'There are no staffing vendors associated with your account.', + style: UiTypography.body2r.textSecondary, + textAlign: TextAlign.center, + ), + ], ), ), - ], - ), + ), + ], ); } - return Scaffold( - body: Column( - children: [ - RecurringOrderHeader( - title: labels.title, - subtitle: labels.subtitle, - onBack: onBack, - ), - Expanded( - child: Stack( - children: [ - _RecurringOrderForm( - eventName: eventName, - selectedVendor: selectedVendor, - vendors: vendors, - startDate: startDate, - endDate: endDate, - recurringDays: recurringDays, - selectedHub: selectedHub, - hubs: hubs, - positions: positions, - roles: roles, - onEventNameChanged: onEventNameChanged, - onVendorChanged: onVendorChanged, - onStartDateChanged: onStartDateChanged, - onEndDateChanged: onEndDateChanged, - onDayToggled: onDayToggled, - onHubChanged: onHubChanged, - onHubManagerChanged: onHubManagerChanged, - onPositionAdded: onPositionAdded, - onPositionUpdated: onPositionUpdated, - onPositionRemoved: onPositionRemoved, - hubManagers: hubManagers, - selectedHubManager: selectedHubManager, - ), - if (status == OrderFormStatus.loading) - const Center(child: CircularProgressIndicator()), - ], - ), - ), - _BottomActionButton( - label: status == OrderFormStatus.loading - ? oneTimeLabels.creating - : oneTimeLabels.create_order, - isLoading: status == OrderFormStatus.loading, - onPressed: isValid ? onSubmit : null, - ), - ], - ), - ); - } -} - -class _RecurringOrderForm extends StatelessWidget { - const _RecurringOrderForm({ - required this.eventName, - required this.selectedVendor, - required this.vendors, - required this.startDate, - required this.endDate, - required this.recurringDays, - required this.selectedHub, - required this.hubs, - required this.positions, - required this.roles, - required this.onEventNameChanged, - required this.onVendorChanged, - required this.onStartDateChanged, - required this.onEndDateChanged, - required this.onDayToggled, - required this.onHubChanged, - required this.onHubManagerChanged, - required this.onPositionAdded, - required this.onPositionUpdated, - required this.onPositionRemoved, - required this.hubManagers, - required this.selectedHubManager, - }); - - final String eventName; - final Vendor? selectedVendor; - final List vendors; - final DateTime startDate; - final DateTime endDate; - final List recurringDays; - final OrderHubUiModel? selectedHub; - final List hubs; - final List positions; - final List roles; - - final ValueChanged onEventNameChanged; - final ValueChanged onVendorChanged; - final ValueChanged onStartDateChanged; - final ValueChanged onEndDateChanged; - final ValueChanged onDayToggled; - final ValueChanged onHubChanged; - final ValueChanged onHubManagerChanged; - final VoidCallback onPositionAdded; - final void Function(int index, OrderPositionUiModel position) onPositionUpdated; - final void Function(int index) onPositionRemoved; - - final List hubManagers; - final OrderManagerUiModel? selectedHubManager; - - - @override - Widget build(BuildContext context) { - final TranslationsClientCreateOrderRecurringEn labels = - t.client_create_order.recurring; - final TranslationsClientCreateOrderOneTimeEn oneTimeLabels = - t.client_create_order.one_time; - - return ListView( - padding: const EdgeInsets.all(UiConstants.space5), + return Column( children: [ - Text( - labels.title, - style: UiTypography.headline3m.textPrimary, - ), - const SizedBox(height: UiConstants.space4), - - RecurringOrderEventNameInput( - label: 'ORDER NAME', - value: eventName, - onChanged: onEventNameChanged, - ), - const SizedBox(height: UiConstants.space4), - - // Vendor Selection - Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary), - const SizedBox(height: UiConstants.space2), - Container( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), - height: 48, - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusMd, - border: Border.all(color: UiColors.border), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - isExpanded: true, - value: selectedVendor, - icon: const Icon( - UiIcons.chevronDown, - size: 18, - color: UiColors.iconSecondary, + Expanded( + child: Stack( + children: [ + RecurringOrderForm( + eventName: eventName, + selectedVendor: selectedVendor, + vendors: vendors, + startDate: startDate, + endDate: endDate, + recurringDays: recurringDays, + selectedHub: selectedHub, + hubs: hubs, + positions: positions, + roles: roles, + onEventNameChanged: onEventNameChanged, + onVendorChanged: onVendorChanged, + onStartDateChanged: onStartDateChanged, + onEndDateChanged: onEndDateChanged, + onDayToggled: onDayToggled, + onHubChanged: onHubChanged, + onHubManagerChanged: onHubManagerChanged, + onPositionAdded: onPositionAdded, + onPositionUpdated: onPositionUpdated, + onPositionRemoved: onPositionRemoved, + hubManagers: hubManagers, + selectedHubManager: selectedHubManager, ), - onChanged: (Vendor? vendor) { - if (vendor != null) { - onVendorChanged(vendor); - } - }, - items: vendors.map((Vendor vendor) { - return DropdownMenuItem( - value: vendor, - child: Text( - vendor.name, - style: UiTypography.body2m.textPrimary, - ), - ); - }).toList(), - ), + if (status == OrderFormStatus.loading) + const Center(child: CircularProgressIndicator()), + ], ), ), - const SizedBox(height: UiConstants.space4), - - RecurringOrderDatePicker( - label: 'Start Date', - value: startDate, - onChanged: onStartDateChanged, + OrderBottomActionButton( + label: status == OrderFormStatus.loading + ? oneTimeLabels.creating + : oneTimeLabels.create_order, + isLoading: status == OrderFormStatus.loading, + onPressed: isValid ? onSubmit : null, ), - const SizedBox(height: UiConstants.space4), - - RecurringOrderDatePicker( - label: 'End Date', - value: endDate, - onChanged: onEndDateChanged, - ), - const SizedBox(height: UiConstants.space4), - - Text('Recurring Days', style: UiTypography.footnote2r.textSecondary), - const SizedBox(height: UiConstants.space2), - _RecurringDaysSelector( - selectedDays: recurringDays, - onToggle: onDayToggled, - ), - const SizedBox(height: UiConstants.space4), - - Text('HUB', style: UiTypography.footnote2r.textSecondary), - const SizedBox(height: UiConstants.space2), - Container( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), - height: 48, - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusMd, - border: Border.all(color: UiColors.border), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - isExpanded: true, - value: selectedHub, - icon: const Icon( - UiIcons.chevronDown, - size: 18, - color: UiColors.iconSecondary, - ), - onChanged: (OrderHubUiModel? hub) { - if (hub != null) { - onHubChanged(hub); - } - }, - items: hubs.map((OrderHubUiModel hub) { - return DropdownMenuItem( - value: hub, - child: Text( - hub.name, - style: UiTypography.body2m.textPrimary, - ), - ); - }).toList(), - ), - ), - ), - const SizedBox(height: UiConstants.space4), - - HubManagerSelector( - label: oneTimeLabels.hub_manager_label, - description: oneTimeLabels.hub_manager_desc, - hintText: oneTimeLabels.hub_manager_hint, - noManagersText: oneTimeLabels.hub_manager_empty, - noneText: oneTimeLabels.hub_manager_none, - managers: hubManagers, - selectedManager: selectedHubManager, - onChanged: onHubManagerChanged, - ), - const SizedBox(height: UiConstants.space6), - - RecurringOrderSectionHeader( - title: oneTimeLabels.positions_title, - actionLabel: oneTimeLabels.add_position, - onAction: onPositionAdded, - ), - const SizedBox(height: UiConstants.space3), - - // Positions List - ...positions.asMap().entries.map(( - MapEntry entry, - ) { - final int index = entry.key; - final OrderPositionUiModel position = entry.value; - return Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space3), - child: RecurringOrderPositionCard( - index: index, - position: position, - isRemovable: positions.length > 1, - positionLabel: oneTimeLabels.positions_title, - roleLabel: oneTimeLabels.select_role, - workersLabel: oneTimeLabels.workers_label, - startLabel: oneTimeLabels.start_label, - endLabel: oneTimeLabels.end_label, - lunchLabel: oneTimeLabels.lunch_break_label, - roles: roles, - onUpdated: (OrderPositionUiModel updated) { - onPositionUpdated(index, updated); - }, - onRemoved: () { - onPositionRemoved(index); - }, - ), - ); - }), ], ); } } - -class _RecurringDaysSelector extends StatelessWidget { - const _RecurringDaysSelector({ - required this.selectedDays, - required this.onToggle, - }); - - final List selectedDays; - final ValueChanged onToggle; - - @override - Widget build(BuildContext context) { - const List labelsShort = [ - 'S', - 'M', - 'T', - 'W', - 'T', - 'F', - 'S', - ]; - const List labelsLong = [ - 'SUN', - 'MON', - 'TUE', - 'WED', - 'THU', - 'FRI', - 'SAT', - ]; - return Wrap( - spacing: UiConstants.space2, - children: List.generate(labelsShort.length, (int index) { - final bool isSelected = selectedDays.contains(labelsLong[index]); - return GestureDetector( - onTap: () => onToggle(index), - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: isSelected ? UiColors.primary : UiColors.white, - shape: BoxShape.circle, - border: Border.all(color: UiColors.border), - ), - alignment: Alignment.center, - child: Text( - labelsShort[index], - style: UiTypography.body2m.copyWith( - color: isSelected ? UiColors.white : UiColors.textSecondary, - ), - ), - ), - ); - }), - ); - } -} - -class _BottomActionButton extends StatelessWidget { - const _BottomActionButton({ - required this.label, - required this.onPressed, - this.isLoading = false, - }); - final String label; - final VoidCallback? onPressed; - final bool isLoading; - - @override - Widget build(BuildContext context) { - return Container( - padding: EdgeInsets.only( - left: UiConstants.space5, - right: UiConstants.space5, - top: UiConstants.space5, - bottom: MediaQuery.of(context).padding.bottom + UiConstants.space5, - ), - decoration: const BoxDecoration( - color: UiColors.white, - border: Border(top: BorderSide(color: UiColors.border)), - ), - child: SizedBox( - width: double.infinity, - child: UiButton.primary( - text: label, - onPressed: isLoading ? null : onPressed, - size: UiButtonSize.large, - ), - ), - ); - } -} diff --git a/backend/dataconnect/dataconnect.yaml b/backend/dataconnect/dataconnect.yaml index 9e1775d6..39e01fdb 100644 --- a/backend/dataconnect/dataconnect.yaml +++ b/backend/dataconnect/dataconnect.yaml @@ -1,5 +1,5 @@ specVersion: "v1" -serviceId: "krow-workforce-db-validation" +serviceId: "krow-workforce-db" location: "us-central1" schema: source: "./schema" @@ -7,7 +7,7 @@ schema: postgresql: database: "krow_db" cloudSql: - instanceId: "krow-sql-validation" + instanceId: "krow-sql" # schemaValidation: "STRICT" # STRICT mode makes Postgres schema match Data Connect exactly. # schemaValidation: "COMPATIBLE" # COMPATIBLE mode makes Postgres schema compatible with Data Connect. connectorDirs: ["./connector"]