Merge branch 'dev' into 503-build-dedicated-interface-to-display-hub-details

This commit is contained in:
Achintha Isuru
2026-02-25 13:07:59 -05:00
73 changed files with 2680 additions and 381 deletions

View File

@@ -1,3 +1,5 @@
import 'dart:io' show Platform;
import 'package:client_authentication/client_authentication.dart'
as client_authentication;
import 'package:client_create_order/client_create_order.dart'
@@ -10,6 +12,7 @@ import 'package:design_system/design_system.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:marionette_flutter/marionette_flutter.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_modular/flutter_modular.dart';
@@ -20,7 +23,23 @@ import 'firebase_options.dart';
import 'src/widgets/session_listener.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final bool isFlutterTest =
!kIsWeb ? Platform.environment.containsKey('FLUTTER_TEST') : false;
if (kDebugMode && !isFlutterTest) {
MarionetteBinding.ensureInitialized(
MarionetteConfiguration(
isInteractiveWidget: (Type type) =>
type == UiButton || type == UiTextField,
extractText: (Widget widget) {
if (widget is UiTextField) return widget.label;
if (widget is UiButton) return widget.text;
return null;
},
),
);
} else {
WidgetsFlutterBinding.ensureInitialized();
}
await Firebase.initializeApp(
options: kIsWeb ? DefaultFirebaseOptions.currentPlatform : null,
);

View File

@@ -0,0 +1,42 @@
# Maestro Integration Tests — Client App
Login and signup flows for the KROW Client app.
See [docs/research/flutter-testing-tools.md](/docs/research/flutter-testing-tools.md) for the evaluation report.
**Full run instructions:** [docs/research/maestro-test-run-instructions.md](/docs/research/maestro-test-run-instructions.md)
## Prerequisites
- [Maestro CLI](https://maestro.dev/docs/getting-started/installation) installed
- Client app built and installed on device/emulator:
```bash
cd apps/mobile && flutter build apk
adb install build/app/outputs/flutter-apk/app-debug.apk
```
## Credentials
| Flow | Credentials |
|------|-------------|
| **Client login** | legendary@krowd.com / Demo2026! |
| **Staff login** | 5557654321 / OTP 123456 |
| **Client signup** | Env vars: `MAESTRO_CLIENT_EMAIL`, `MAESTRO_CLIENT_PASSWORD`, `MAESTRO_CLIENT_COMPANY` |
| **Staff signup** | Env var: `MAESTRO_STAFF_SIGNUP_PHONE` (must be new Firebase test phone) |
## Run
From the project root:
```bash
# Login
maestro test apps/mobile/apps/client/maestro/login.yaml
# Signup
maestro test apps/mobile/apps/client/maestro/signup.yaml
```
## Flows
| File | Flow | Description |
|------------|-------------|--------------------------------------------|
| login.yaml | Client Login| Get Started → Sign In → Home |
| signup.yaml| Client Signup| Get Started → Create Account → Home |

View File

@@ -0,0 +1,18 @@
# Client App - Login Flow
# Prerequisites: App built and installed (debug or release)
# Run: maestro test apps/mobile/apps/client/maestro/login.yaml
# Test credentials: legendary@krowd.com / Demo2026!
# Note: Auth uses Firebase/Data Connect
appId: com.krowwithus.client
---
- launchApp
- assertVisible: "Sign In"
- tapOn: "Sign In"
- assertVisible: "Email"
- tapOn: "Email"
- inputText: "legendary@krowd.com"
- tapOn: "Password"
- inputText: "Demo2026!"
- tapOn: "Sign In"
- assertVisible: "Home"

View File

@@ -0,0 +1,23 @@
# Client App - Sign Up Flow
# Prerequisites: App built and installed
# Run: maestro test apps/mobile/apps/client/maestro/signup.yaml
# Use NEW credentials for signup (creates new account)
# Env: MAESTRO_CLIENT_EMAIL, MAESTRO_CLIENT_PASSWORD, MAESTRO_CLIENT_COMPANY
appId: com.krowwithus.client
---
- launchApp
- assertVisible: "Create Account"
- tapOn: "Create Account"
- assertVisible: "Company"
- tapOn: "Company"
- inputText: "${MAESTRO_CLIENT_COMPANY}"
- tapOn: "Email"
- inputText: "${MAESTRO_CLIENT_EMAIL}"
- tapOn: "Password"
- inputText: "${MAESTRO_CLIENT_PASSWORD}"
- tapOn:
text: "Confirm Password"
- inputText: "${MAESTRO_CLIENT_PASSWORD}"
- tapOn: "Create Account"
- assertVisible: "Home"

View File

@@ -42,6 +42,7 @@ dependencies:
sdk: flutter
firebase_core: ^4.4.0
krow_data_connect: ^0.0.1
marionette_flutter: ^0.3.0
dev_dependencies:
flutter_test:

View File

@@ -1,7 +1,11 @@
import 'dart:io' show Platform;
import 'package:core_localization/core_localization.dart' as core_localization;
import 'package:design_system/design_system.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:marionette_flutter/marionette_flutter.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_modular/flutter_modular.dart';
@@ -16,7 +20,23 @@ import 'package:image_picker/image_picker.dart';
import 'src/widgets/session_listener.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final bool isFlutterTest =
!kIsWeb ? Platform.environment.containsKey('FLUTTER_TEST') : false;
if (kDebugMode && !isFlutterTest) {
MarionetteBinding.ensureInitialized(
MarionetteConfiguration(
isInteractiveWidget: (Type type) =>
type == UiButton || type == UiTextField,
extractText: (Widget widget) {
if (widget is UiTextField) return widget.label;
if (widget is UiButton) return widget.text;
return null;
},
),
);
} else {
WidgetsFlutterBinding.ensureInitialized();
}
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// Register global BLoC observer for centralized error logging

View File

@@ -0,0 +1,41 @@
# Maestro Integration Tests — Staff App
Login and signup flows for the KROW Staff app.
See [docs/research/flutter-testing-tools.md](/docs/research/flutter-testing-tools.md) for the evaluation report.
**Full run instructions:** [docs/research/maestro-test-run-instructions.md](/docs/research/maestro-test-run-instructions.md)
## Prerequisites
- [Maestro CLI](https://maestro.dev/docs/getting-started/installation) installed
- Staff app built and installed
- **Firebase test phone** in Firebase Console (Auth > Sign-in method > Phone):
- Login: +1 555-765-4321 / OTP 123456
- Signup: add a different test number for new accounts
## Credentials
| Flow | Credentials |
|------|-------------|
| **Client login** | legendary@krowd.com / Demo2026! |
| **Staff login** | 5557654321 / OTP 123456 |
| **Client signup** | Env vars: `MAESTRO_CLIENT_EMAIL`, `MAESTRO_CLIENT_PASSWORD`, `MAESTRO_CLIENT_COMPANY` |
| **Staff signup** | Env var: `MAESTRO_STAFF_SIGNUP_PHONE` (must be new Firebase test phone) |
## Run
From the project root:
```bash
# Login
maestro test apps/mobile/apps/staff/maestro/login.yaml
# Signup
maestro test apps/mobile/apps/staff/maestro/signup.yaml
```
## Flows
| File | Flow | Description |
|------------|------------|-------------------------------------|
| login.yaml | Staff Login| Get Started → Log In → Phone → OTP → Home |
| signup.yaml| Staff Signup| Get Started → Sign Up → Phone → OTP → Profile Setup |

View File

@@ -0,0 +1,18 @@
# Staff App - Login Flow (Phone + OTP)
# Prerequisites: App built and installed; Firebase test phone configured
# Firebase test phone: +1 555-765-4321 / OTP 123456
# Run: maestro test apps/mobile/apps/staff/maestro/login.yaml
appId: com.krowwithus.staff
---
- launchApp
- assertVisible: "Log In"
- tapOn: "Log In"
- assertVisible: "Send Code"
- inputText: "5557654321"
- tapOn: "Send Code"
# Wait for OTP screen
- assertVisible: "Continue"
- inputText: "123456"
- tapOn: "Continue"
# On success: staff main. Adjust final assertion to match staff home screen.

View File

@@ -0,0 +1,18 @@
# Staff App - Sign Up Flow (Phone + OTP)
# Prerequisites: App built and installed; Firebase test phone for NEW number
# Use a NEW phone number for signup (creates new account)
# Firebase: add test phone in Auth > Phone; e.g. +1 555-555-0000 / 123456
# Run: maestro test apps/mobile/apps/staff/maestro/signup.yaml
appId: com.krowwithus.staff
---
- launchApp
- assertVisible: "Sign Up"
- tapOn: "Sign Up"
- assertVisible: "Send Code"
- inputText: "${MAESTRO_STAFF_SIGNUP_PHONE}"
- tapOn: "Send Code"
- assertVisible: "Continue"
- inputText: "123456"
- tapOn: "Continue"
# On success: Profile Setup. Adjust assertion to match destination.

View File

@@ -30,6 +30,7 @@ dependencies:
path: ../../packages/core
krow_data_connect:
path: ../../packages/data_connect
marionette_flutter: ^0.3.0
cupertino_icons: ^1.0.8
flutter_modular: ^6.3.0
firebase_core: ^4.4.0

View File

@@ -135,6 +135,11 @@ extension ClientNavigator on IModularNavigator {
pushNamed(ClientPaths.settings);
}
/// Pushes the edit profile page.
void toClientEditProfile() {
pushNamed('${ClientPaths.settings}/edit-profile');
}
// ==========================================================================
// HUBS MANAGEMENT
// ==========================================================================
@@ -159,6 +164,9 @@ extension ClientNavigator on IModularNavigator {
return pushNamed<bool?>(
ClientPaths.editHub,
arguments: <String, dynamic>{'hub': hub},
// Some versions of Modular allow passing opaque here, but if not
// we'll handle transparency in the page itself which we already do.
// To ensure it's not opaque, we'll use push with a PageRouteBuilder if needed.
);
}

View File

@@ -208,6 +208,7 @@
"edit_profile": "Edit Profile",
"hubs": "Hubs",
"log_out": "Log Out",
"log_out_confirmation": "Are you sure you want to log out?",
"quick_links": "Quick Links",
"clock_in_hubs": "Clock-In Hubs",
"billing_payments": "Billing & Payments"
@@ -252,6 +253,11 @@
"location_hint": "e.g., Downtown Restaurant",
"address_label": "Address",
"address_hint": "Full address",
"cost_center_label": "Cost Center",
"cost_center_hint": "eg: 1001, 1002",
"cost_centers_empty": "No cost centers available",
"name_required": "Name is required",
"address_required": "Address is required",
"create_button": "Create Hub"
},
"edit_hub": {
@@ -261,8 +267,14 @@
"name_hint": "e.g., Main Kitchen, Front Desk",
"address_label": "Address",
"address_hint": "Full address",
"cost_center_label": "Cost Center",
"cost_center_hint": "eg: 1001, 1002",
"cost_centers_empty": "No cost centers available",
"name_required": "Name is required",
"save_button": "Save Changes",
"success": "Hub updated successfully!"
"success": "Hub updated successfully!",
"created_success": "Hub created successfully",
"updated_success": "Hub updated successfully"
},
"hub_details": {
"title": "Hub Details",
@@ -270,7 +282,10 @@
"address_label": "Address",
"nfc_label": "NFC Tag",
"nfc_not_assigned": "Not Assigned",
"edit_button": "Edit Hub"
"cost_center_label": "Cost Center",
"cost_center_none": "Not Assigned",
"edit_button": "Edit Hub",
"deleted_success": "Hub deleted successfully"
},
"nfc_dialog": {
"title": "Identify NFC Tag",
@@ -326,6 +341,11 @@
"date_hint": "Select date",
"location_label": "Location",
"location_hint": "Enter address",
"hub_manager_label": "Shift Contact",
"hub_manager_desc": "On-site manager or supervisor for this shift",
"hub_manager_hint": "Select Contact",
"hub_manager_empty": "No hub managers available",
"hub_manager_none": "None",
"positions_title": "Positions",
"add_position": "Add Position",
"position_number": "Position $number",
@@ -377,6 +397,41 @@
"active": "Active",
"completed": "Completed"
},
"order_edit_sheet": {
"title": "Edit Your Order",
"vendor_section": "VENDOR",
"location_section": "LOCATION",
"shift_contact_section": "SHIFT CONTACT",
"shift_contact_desc": "On-site manager or supervisor for this shift",
"select_contact": "Select Contact",
"no_hub_managers": "No hub managers available",
"none": "None",
"positions_section": "POSITIONS",
"add_position": "Add Position",
"review_positions": "Review $count Positions",
"order_name_hint": "Order name",
"remove": "Remove",
"select_role_hint": "Select role",
"start_label": "Start",
"end_label": "End",
"workers_label": "Workers",
"different_location": "Use different location for this position",
"different_location_title": "Different Location",
"enter_address_hint": "Enter different address",
"no_break": "No Break",
"positions": "Positions",
"workers": "Workers",
"est_cost": "Est. Cost",
"positions_breakdown": "Positions Breakdown",
"edit_button": "Edit",
"confirm_save": "Confirm & Save",
"position_singular": "Position",
"order_updated_title": "Order Updated!",
"order_updated_message": "Your shift has been updated successfully.",
"back_to_orders": "Back to Orders",
"one_time_order_title": "One-Time Order",
"refine_subtitle": "Refine your staffing needs"
},
"card": {
"open": "OPEN",
"filled": "FILLED",

View File

@@ -208,6 +208,7 @@
"edit_profile": "Editar Perfil",
"hubs": "Hubs",
"log_out": "Cerrar sesi\u00f3n",
"log_out_confirmation": "\u00bfEst\u00e1 seguro de que desea cerrar sesi\u00f3n?",
"quick_links": "Enlaces r\u00e1pidos",
"clock_in_hubs": "Hubs de Marcaje",
"billing_payments": "Facturaci\u00f3n y Pagos"
@@ -252,6 +253,11 @@
"location_hint": "ej., Restaurante Centro",
"address_label": "Direcci\u00f3n",
"address_hint": "Direcci\u00f3n completa",
"cost_center_label": "Centro de Costos",
"cost_center_hint": "ej: 1001, 1002",
"cost_centers_empty": "No hay centros de costos disponibles",
"name_required": "Nombre es obligatorio",
"address_required": "La direcci\u00f3n es obligatoria",
"create_button": "Crear Hub"
},
"nfc_dialog": {
@@ -276,8 +282,14 @@
"name_hint": "Ingresar nombre del hub",
"address_label": "Direcci\u00f3n",
"address_hint": "Ingresar direcci\u00f3n",
"cost_center_label": "Centro de Costos",
"cost_center_hint": "ej: 1001, 1002",
"cost_centers_empty": "No hay centros de costos disponibles",
"name_required": "El nombre es obligatorio",
"save_button": "Guardar Cambios",
"success": "\u00a1Hub actualizado exitosamente!"
"success": "\u00a1Hub actualizado exitosamente!",
"created_success": "Hub creado exitosamente",
"updated_success": "Hub actualizado exitosamente"
},
"hub_details": {
"title": "Detalles del Hub",
@@ -285,7 +297,10 @@
"name_label": "Nombre del Hub",
"address_label": "Direcci\u00f3n",
"nfc_label": "Etiqueta NFC",
"nfc_not_assigned": "No asignada"
"nfc_not_assigned": "No asignada",
"cost_center_label": "Centro de Costos",
"cost_center_none": "No asignado",
"deleted_success": "Hub eliminado exitosamente"
}
},
"client_create_order": {
@@ -326,6 +341,11 @@
"date_hint": "Seleccionar fecha",
"location_label": "Ubicaci\u00f3n",
"location_hint": "Ingresar direcci\u00f3n",
"hub_manager_label": "Contacto del Turno",
"hub_manager_desc": "Gerente o supervisor en el sitio para este turno",
"hub_manager_hint": "Seleccionar Contacto",
"hub_manager_empty": "No hay contactos de turno disponibles",
"hub_manager_none": "Ninguno",
"positions_title": "Posiciones",
"add_position": "A\u00f1adir Posici\u00f3n",
"position_number": "Posici\u00f3n $number",
@@ -377,6 +397,41 @@
"active": "Activos",
"completed": "Completados"
},
"order_edit_sheet": {
"title": "Editar Tu Orden",
"vendor_section": "PROVEEDOR",
"location_section": "UBICACI\u00d3N",
"shift_contact_section": "CONTACTO DEL TURNO",
"shift_contact_desc": "Gerente o supervisor en el sitio para este turno",
"select_contact": "Seleccionar Contacto",
"no_hub_managers": "No hay contactos de turno disponibles",
"none": "Ninguno",
"positions_section": "POSICIONES",
"add_position": "A\u00f1adir Posici\u00f3n",
"review_positions": "Revisar $count Posiciones",
"order_name_hint": "Nombre de la orden",
"remove": "Eliminar",
"select_role_hint": "Seleccionar rol",
"start_label": "Inicio",
"end_label": "Fin",
"workers_label": "Trabajadores",
"different_location": "Usar ubicaci\u00f3n diferente para esta posici\u00f3n",
"different_location_title": "Ubicaci\u00f3n Diferente",
"enter_address_hint": "Ingresar direcci\u00f3n diferente",
"no_break": "Sin Descanso",
"positions": "Posiciones",
"workers": "Trabajadores",
"est_cost": "Costo Est.",
"positions_breakdown": "Desglose de Posiciones",
"edit_button": "Editar",
"confirm_save": "Confirmar y Guardar",
"position_singular": "Posici\u00f3n",
"order_updated_title": "\u00a1Orden Actualizada!",
"order_updated_message": "Tu turno ha sido actualizado exitosamente.",
"back_to_orders": "Volver a \u00d3rdenes",
"one_time_order_title": "Orden \u00danica Vez",
"refine_subtitle": "Ajusta tus necesidades de personal"
},
"card": {
"open": "ABIERTO",
"filled": "LLENO",

View File

@@ -1,4 +1,4 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
import 'dart:convert';
import 'package:firebase_data_connect/src/core/ref.dart';
import 'package:http/http.dart' as http;
@@ -23,7 +23,25 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository {
.getTeamHubsByTeamId(teamId: teamId)
.execute();
final QueryResult<
dc.ListTeamHudDepartmentsData,
dc.ListTeamHudDepartmentsVariables
>
deptsResult = await _service.connector.listTeamHudDepartments().execute();
final Map<String, dc.ListTeamHudDepartmentsTeamHudDepartments> hubToDept =
<String, dc.ListTeamHudDepartmentsTeamHudDepartments>{};
for (final dc.ListTeamHudDepartmentsTeamHudDepartments dep
in deptsResult.data.teamHudDepartments) {
if (dep.costCenter != null &&
dep.costCenter!.isNotEmpty &&
!hubToDept.containsKey(dep.teamHubId)) {
hubToDept[dep.teamHubId] = dep;
}
}
return response.data.teamHubs.map((dc.GetTeamHubsByTeamIdTeamHubs h) {
final dc.ListTeamHudDepartmentsTeamHudDepartments? dept =
hubToDept[h.id];
return Hub(
id: h.id,
businessId: businessId,
@@ -31,6 +49,13 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository {
address: h.address,
nfcTagId: null,
status: h.isActive ? HubStatus.active : HubStatus.inactive,
costCenter: dept != null
? CostCenter(
id: dept.id,
name: dept.name,
code: dept.costCenter ?? dept.name,
)
: null,
);
}).toList();
});
@@ -49,6 +74,7 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository {
String? street,
String? country,
String? zipCode,
String? costCenterId,
}) async {
return _service.run(() async {
final String teamId = await _getOrCreateTeamId(businessId);
@@ -72,13 +98,27 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository {
.zipCode(zipCode ?? placeAddress?.zipCode)
.execute();
final String hubId = result.data.teamHub_insert.id;
CostCenter? costCenter;
if (costCenterId != null && costCenterId.isNotEmpty) {
await _service.connector
.createTeamHudDepartment(
name: costCenterId,
teamHubId: hubId,
)
.costCenter(costCenterId)
.execute();
costCenter = CostCenter(id: costCenterId, name: costCenterId, code: costCenterId);
}
return Hub(
id: result.data.teamHub_insert.id,
id: hubId,
businessId: businessId,
name: name,
address: address,
nfcTagId: null,
status: HubStatus.active,
costCenter: costCenter,
);
});
}
@@ -97,6 +137,7 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository {
String? street,
String? country,
String? zipCode,
String? costCenterId,
}) async {
return _service.run(() async {
final _PlaceAddress? placeAddress = (placeId != null && placeId.isNotEmpty)
@@ -128,7 +169,43 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository {
await builder.execute();
// Return a basic hub object reflecting changes (or we could re-fetch)
CostCenter? costCenter;
final QueryResult<
dc.ListTeamHudDepartmentsByTeamHubIdData,
dc.ListTeamHudDepartmentsByTeamHubIdVariables
>
deptsResult = await _service.connector
.listTeamHudDepartmentsByTeamHubId(teamHubId: id)
.execute();
final List<dc.ListTeamHudDepartmentsByTeamHubIdTeamHudDepartments> depts =
deptsResult.data.teamHudDepartments;
if (costCenterId == null || costCenterId.isEmpty) {
if (depts.isNotEmpty) {
await _service.connector
.updateTeamHudDepartment(id: depts.first.id)
.costCenter(null)
.execute();
}
} else {
if (depts.isNotEmpty) {
await _service.connector
.updateTeamHudDepartment(id: depts.first.id)
.costCenter(costCenterId)
.execute();
costCenter = CostCenter(id: costCenterId, name: costCenterId, code: costCenterId);
} else {
await _service.connector
.createTeamHudDepartment(
name: costCenterId,
teamHubId: id,
)
.costCenter(costCenterId)
.execute();
costCenter = CostCenter(id: costCenterId, name: costCenterId, code: costCenterId);
}
}
return Hub(
id: id,
businessId: businessId,
@@ -136,6 +213,7 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository {
address: address ?? '',
nfcTagId: null,
status: HubStatus.active,
costCenter: costCenter,
);
});
}

View File

@@ -20,6 +20,7 @@ abstract interface class HubsConnectorRepository {
String? street,
String? country,
String? zipCode,
String? costCenterId,
});
/// Updates an existing hub.
@@ -36,6 +37,7 @@ abstract interface class HubsConnectorRepository {
String? street,
String? country,
String? zipCode,
String? costCenterId,
});
/// Deletes a hub.

View File

@@ -28,6 +28,7 @@ export 'src/entities/business/business_setting.dart';
export 'src/entities/business/hub.dart';
export 'src/entities/business/hub_department.dart';
export 'src/entities/business/vendor.dart';
export 'src/entities/business/cost_center.dart';
// Events & Assignments
export 'src/entities/events/event.dart';

View File

@@ -0,0 +1,22 @@
import 'package:equatable/equatable.dart';
/// Represents a financial cost center used for billing and tracking.
class CostCenter extends Equatable {
const CostCenter({
required this.id,
required this.name,
this.code,
});
/// Unique identifier.
final String id;
/// Display name of the cost center.
final String name;
/// Optional alphanumeric code associated with this cost center.
final String? code;
@override
List<Object?> get props => <Object?>[id, name, code];
}

View File

@@ -1,5 +1,7 @@
import 'package:equatable/equatable.dart';
import 'cost_center.dart';
/// The status of a [Hub].
enum HubStatus {
/// Fully operational.
@@ -14,7 +16,6 @@ enum HubStatus {
/// Represents a branch location or operational unit within a [Business].
class Hub extends Equatable {
const Hub({
required this.id,
required this.businessId,
@@ -22,6 +23,7 @@ class Hub extends Equatable {
required this.address,
this.nfcTagId,
required this.status,
this.costCenter,
});
/// Unique identifier.
final String id;
@@ -41,6 +43,9 @@ class Hub extends Equatable {
/// Operational status.
final HubStatus status;
/// Assigned cost center for this hub.
final CostCenter? costCenter;
@override
List<Object?> get props => <Object?>[id, businessId, name, address, nfcTagId, status];
List<Object?> get props => <Object?>[id, businessId, name, address, nfcTagId, status, costCenter];
}

View File

@@ -13,6 +13,7 @@ class OneTimeOrder extends Equatable {
this.hub,
this.eventName,
this.vendorId,
this.hubManagerId,
this.roleRates = const <String, double>{},
});
/// The specific date for the shift or event.
@@ -33,6 +34,9 @@ class OneTimeOrder extends Equatable {
/// Selected vendor id for this order.
final String? vendorId;
/// Optional hub manager id.
final String? hubManagerId;
/// Role hourly rates keyed by role id.
final Map<String, double> roleRates;
@@ -44,6 +48,7 @@ class OneTimeOrder extends Equatable {
hub,
eventName,
vendorId,
hubManagerId,
roleRates,
];
}

View File

@@ -27,6 +27,8 @@ class OrderItem extends Equatable {
this.hours = 0,
this.totalValue = 0,
this.confirmedApps = const <Map<String, dynamic>>[],
this.hubManagerId,
this.hubManagerName,
});
/// Unique identifier of the order.
@@ -83,6 +85,12 @@ class OrderItem extends Equatable {
/// List of confirmed worker applications.
final List<Map<String, dynamic>> confirmedApps;
/// Optional ID of the assigned hub manager.
final String? hubManagerId;
/// Optional Name of the assigned hub manager.
final String? hubManagerName;
@override
List<Object?> get props => <Object?>[
id,
@@ -103,5 +111,7 @@ class OrderItem extends Equatable {
totalValue,
eventName,
confirmedApps,
hubManagerId,
hubManagerName,
];
}

View File

@@ -11,6 +11,7 @@ class PermanentOrder extends Equatable {
this.hub,
this.eventName,
this.vendorId,
this.hubManagerId,
this.roleRates = const <String, double>{},
});
@@ -23,6 +24,7 @@ class PermanentOrder extends Equatable {
final OneTimeOrderHubDetails? hub;
final String? eventName;
final String? vendorId;
final String? hubManagerId;
final Map<String, double> roleRates;
@override
@@ -33,6 +35,7 @@ class PermanentOrder extends Equatable {
hub,
eventName,
vendorId,
hubManagerId,
roleRates,
];
}

View File

@@ -12,6 +12,7 @@ class RecurringOrder extends Equatable {
this.hub,
this.eventName,
this.vendorId,
this.hubManagerId,
this.roleRates = const <String, double>{},
});
@@ -39,6 +40,9 @@ class RecurringOrder extends Equatable {
/// Selected vendor id for this order.
final String? vendorId;
/// Optional hub manager id.
final String? hubManagerId;
/// Role hourly rates keyed by role id.
final Map<String, double> roleRates;
@@ -52,6 +56,7 @@ class RecurringOrder extends Equatable {
hub,
eventName,
vendorId,
hubManagerId,
roleRates,
];
}

View File

@@ -1,5 +1,6 @@
library;
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
@@ -8,6 +9,7 @@ import 'src/domain/repositories/hub_repository_interface.dart';
import 'src/domain/usecases/assign_nfc_tag_usecase.dart';
import 'src/domain/usecases/create_hub_usecase.dart';
import 'src/domain/usecases/delete_hub_usecase.dart';
import 'src/domain/usecases/get_cost_centers_usecase.dart';
import 'src/domain/usecases/get_hubs_usecase.dart';
import 'src/domain/usecases/update_hub_usecase.dart';
import 'src/presentation/blocs/client_hubs_bloc.dart';
@@ -32,6 +34,7 @@ class ClientHubsModule extends Module {
// UseCases
i.addLazySingleton(GetHubsUseCase.new);
i.addLazySingleton(GetCostCentersUseCase.new);
i.addLazySingleton(CreateHubUseCase.new);
i.addLazySingleton(DeleteHubUseCase.new);
i.addLazySingleton(AssignNfcTagUseCase.new);
@@ -61,6 +64,18 @@ class ClientHubsModule extends Module {
);
r.child(
ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.editHub),
transition: TransitionType.custom,
customTransition: CustomTransition(
opaque: false,
transitionBuilder: (
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return FadeTransition(opacity: animation, child: child);
},
),
child: (_) {
final Map<String, dynamic> data = r.args.data as Map<String, dynamic>;
return EditHubPage(

View File

@@ -1,4 +1,4 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/hub_repository_interface.dart';
@@ -24,6 +24,24 @@ class HubRepositoryImpl implements HubRepositoryInterface {
return _connectorRepository.getHubs(businessId: businessId);
}
@override
Future<List<CostCenter>> getCostCenters() async {
return _service.run(() async {
final result = await _service.connector.listTeamHudDepartments().execute();
final Set<String> seen = <String>{};
final List<CostCenter> costCenters = <CostCenter>[];
for (final dc.ListTeamHudDepartmentsTeamHudDepartments dep
in result.data.teamHudDepartments) {
final String? cc = dep.costCenter;
if (cc != null && cc.isNotEmpty && !seen.contains(cc)) {
seen.add(cc);
costCenters.add(CostCenter(id: cc, name: dep.name, code: cc));
}
}
return costCenters;
});
}
@override
Future<Hub> createHub({
required String name,
@@ -36,6 +54,7 @@ class HubRepositoryImpl implements HubRepositoryInterface {
String? street,
String? country,
String? zipCode,
String? costCenterId,
}) async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.createHub(
@@ -50,6 +69,7 @@ class HubRepositoryImpl implements HubRepositoryInterface {
street: street,
country: country,
zipCode: zipCode,
costCenterId: costCenterId,
);
}
@@ -79,6 +99,7 @@ class HubRepositoryImpl implements HubRepositoryInterface {
String? street,
String? country,
String? zipCode,
String? costCenterId,
}) async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.updateHub(
@@ -94,6 +115,7 @@ class HubRepositoryImpl implements HubRepositoryInterface {
street: street,
country: country,
zipCode: zipCode,
costCenterId: costCenterId,
);
}
}

View File

@@ -19,6 +19,7 @@ class CreateHubArguments extends UseCaseArgument {
this.street,
this.country,
this.zipCode,
this.costCenterId,
});
/// The name of the hub.
final String name;
@@ -34,6 +35,9 @@ class CreateHubArguments extends UseCaseArgument {
final String? street;
final String? country;
final String? zipCode;
/// The cost center of the hub.
final String? costCenterId;
@override
List<Object?> get props => <Object?>[
@@ -47,5 +51,6 @@ class CreateHubArguments extends UseCaseArgument {
street,
country,
zipCode,
costCenterId,
];
}

View File

@@ -11,6 +11,9 @@ abstract interface class HubRepositoryInterface {
/// Returns a list of [Hub] entities.
Future<List<Hub>> getHubs();
/// Fetches the list of available cost centers for the current business.
Future<List<CostCenter>> getCostCenters();
/// Creates a new hub.
///
/// Takes the [name] and [address] of the new hub.
@@ -26,6 +29,7 @@ abstract interface class HubRepositoryInterface {
String? street,
String? country,
String? zipCode,
String? costCenterId,
});
/// Deletes a hub by its [id].
@@ -51,5 +55,6 @@ abstract interface class HubRepositoryInterface {
String? street,
String? country,
String? zipCode,
String? costCenterId,
});
}

View File

@@ -29,6 +29,7 @@ class CreateHubUseCase implements UseCase<CreateHubArguments, Hub> {
street: arguments.street,
country: arguments.country,
zipCode: arguments.zipCode,
costCenterId: arguments.costCenterId,
);
}
}

View File

@@ -0,0 +1,14 @@
import 'package:krow_domain/krow_domain.dart';
import '../repositories/hub_repository_interface.dart';
/// Usecase to fetch all available cost centers.
class GetCostCentersUseCase {
GetCostCentersUseCase({required HubRepositoryInterface repository})
: _repository = repository;
final HubRepositoryInterface _repository;
Future<List<CostCenter>> call() async {
return _repository.getCostCenters();
}
}

View File

@@ -17,6 +17,7 @@ class UpdateHubArguments extends UseCaseArgument {
this.street,
this.country,
this.zipCode,
this.costCenterId,
});
final String id;
@@ -30,6 +31,7 @@ class UpdateHubArguments extends UseCaseArgument {
final String? street;
final String? country;
final String? zipCode;
final String? costCenterId;
@override
List<Object?> get props => <Object?>[
@@ -44,6 +46,7 @@ class UpdateHubArguments extends UseCaseArgument {
street,
country,
zipCode,
costCenterId,
];
}
@@ -67,6 +70,7 @@ class UpdateHubUseCase implements UseCase<UpdateHubArguments, Hub> {
street: params.street,
country: params.country,
zipCode: params.zipCode,
costCenterId: params.costCenterId,
);
}
}

View File

@@ -1,8 +1,10 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../../domain/arguments/create_hub_arguments.dart';
import '../../../domain/usecases/create_hub_usecase.dart';
import '../../../domain/usecases/update_hub_usecase.dart';
import '../../../domain/usecases/get_cost_centers_usecase.dart';
import 'edit_hub_event.dart';
import 'edit_hub_state.dart';
@@ -12,15 +14,36 @@ class EditHubBloc extends Bloc<EditHubEvent, EditHubState>
EditHubBloc({
required CreateHubUseCase createHubUseCase,
required UpdateHubUseCase updateHubUseCase,
required GetCostCentersUseCase getCostCentersUseCase,
}) : _createHubUseCase = createHubUseCase,
_updateHubUseCase = updateHubUseCase,
_getCostCentersUseCase = getCostCentersUseCase,
super(const EditHubState()) {
on<EditHubCostCentersLoadRequested>(_onCostCentersLoadRequested);
on<EditHubAddRequested>(_onAddRequested);
on<EditHubUpdateRequested>(_onUpdateRequested);
}
final CreateHubUseCase _createHubUseCase;
final UpdateHubUseCase _updateHubUseCase;
final GetCostCentersUseCase _getCostCentersUseCase;
Future<void> _onCostCentersLoadRequested(
EditHubCostCentersLoadRequested event,
Emitter<EditHubState> emit,
) async {
await handleError(
emit: emit.call,
action: () async {
final List<CostCenter> costCenters = await _getCostCentersUseCase.call();
emit(state.copyWith(costCenters: costCenters));
},
onError: (String errorKey) => state.copyWith(
status: EditHubStatus.failure,
errorMessage: errorKey,
),
);
}
Future<void> _onAddRequested(
EditHubAddRequested event,
@@ -43,12 +66,13 @@ class EditHubBloc extends Bloc<EditHubEvent, EditHubState>
street: event.street,
country: event.country,
zipCode: event.zipCode,
costCenterId: event.costCenterId,
),
);
emit(
state.copyWith(
status: EditHubStatus.success,
successMessage: 'Hub created successfully',
successKey: 'created',
),
);
},
@@ -79,12 +103,13 @@ class EditHubBloc extends Bloc<EditHubEvent, EditHubState>
street: event.street,
country: event.country,
zipCode: event.zipCode,
costCenterId: event.costCenterId,
),
);
emit(
state.copyWith(
status: EditHubStatus.success,
successMessage: 'Hub updated successfully',
successKey: 'updated',
),
);
},

View File

@@ -8,6 +8,11 @@ abstract class EditHubEvent extends Equatable {
List<Object?> get props => <Object?>[];
}
/// Event triggered to load all available cost centers.
class EditHubCostCentersLoadRequested extends EditHubEvent {
const EditHubCostCentersLoadRequested();
}
/// Event triggered to add a new hub.
class EditHubAddRequested extends EditHubEvent {
const EditHubAddRequested({
@@ -21,6 +26,7 @@ class EditHubAddRequested extends EditHubEvent {
this.street,
this.country,
this.zipCode,
this.costCenterId,
});
final String name;
@@ -33,6 +39,7 @@ class EditHubAddRequested extends EditHubEvent {
final String? street;
final String? country;
final String? zipCode;
final String? costCenterId;
@override
List<Object?> get props => <Object?>[
@@ -46,6 +53,7 @@ class EditHubAddRequested extends EditHubEvent {
street,
country,
zipCode,
costCenterId,
];
}
@@ -63,6 +71,7 @@ class EditHubUpdateRequested extends EditHubEvent {
this.street,
this.country,
this.zipCode,
this.costCenterId,
});
final String id;
@@ -76,6 +85,7 @@ class EditHubUpdateRequested extends EditHubEvent {
final String? street;
final String? country;
final String? zipCode;
final String? costCenterId;
@override
List<Object?> get props => <Object?>[
@@ -90,5 +100,6 @@ class EditHubUpdateRequested extends EditHubEvent {
street,
country,
zipCode,
costCenterId,
];
}

View File

@@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
/// Status of the edit hub operation.
enum EditHubStatus {
@@ -21,6 +22,8 @@ class EditHubState extends Equatable {
this.status = EditHubStatus.initial,
this.errorMessage,
this.successMessage,
this.successKey,
this.costCenters = const <CostCenter>[],
});
/// The status of the operation.
@@ -32,19 +35,35 @@ class EditHubState extends Equatable {
/// The success message if the operation succeeded.
final String? successMessage;
/// Localization key for success message: 'created' | 'updated'.
final String? successKey;
/// Available cost centers for selection.
final List<CostCenter> costCenters;
/// Create a copy of this state with the given fields replaced.
EditHubState copyWith({
EditHubStatus? status,
String? errorMessage,
String? successMessage,
String? successKey,
List<CostCenter>? costCenters,
}) {
return EditHubState(
status: status ?? this.status,
errorMessage: errorMessage ?? this.errorMessage,
successMessage: successMessage ?? this.successMessage,
successKey: successKey ?? this.successKey,
costCenters: costCenters ?? this.costCenters,
);
}
@override
List<Object?> get props => <Object?>[status, errorMessage, successMessage];
List<Object?> get props => <Object?>[
status,
errorMessage,
successMessage,
successKey,
costCenters,
];
}

View File

@@ -36,7 +36,7 @@ class HubDetailsBloc extends Bloc<HubDetailsEvent, HubDetailsState>
emit(
state.copyWith(
status: HubDetailsStatus.deleted,
successMessage: 'Hub deleted successfully',
successKey: 'deleted',
),
);
},

View File

@@ -24,6 +24,7 @@ class HubDetailsState extends Equatable {
this.status = HubDetailsStatus.initial,
this.errorMessage,
this.successMessage,
this.successKey,
});
/// The status of the operation.
@@ -35,19 +36,24 @@ class HubDetailsState extends Equatable {
/// The success message if the operation succeeded.
final String? successMessage;
/// Localization key for success message: 'deleted'.
final String? successKey;
/// Create a copy of this state with the given fields replaced.
HubDetailsState copyWith({
HubDetailsStatus? status,
String? errorMessage,
String? successMessage,
String? successKey,
}) {
return HubDetailsState(
status: status ?? this.status,
errorMessage: errorMessage ?? this.errorMessage,
successMessage: successMessage ?? this.successMessage,
successKey: successKey ?? this.successKey,
);
}
@override
List<Object?> get props => <Object?>[status, errorMessage, successMessage];
List<Object?> get props => <Object?>[status, errorMessage, successMessage, successKey];
}

View File

@@ -57,20 +57,6 @@ class ClientHubsPage extends StatelessWidget {
builder: (BuildContext context, ClientHubsState state) {
return Scaffold(
backgroundColor: UiColors.bgMenu,
floatingActionButton: FloatingActionButton(
onPressed: () async {
final bool? success = await Modular.to.toEditHub();
if (success == true && context.mounted) {
BlocProvider.of<ClientHubsBloc>(
context,
).add(const ClientHubsFetched());
}
},
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: const Icon(UiIcons.add),
),
body: CustomScrollView(
slivers: <Widget>[
_buildAppBar(context),
@@ -165,20 +151,35 @@ class ClientHubsPage extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
t.client_hubs.title,
style: UiTypography.headline1m.white,
),
Text(
t.client_hubs.subtitle,
style: UiTypography.body2r.copyWith(
color: UiColors.switchInactive,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
t.client_hubs.title,
style: UiTypography.headline1m.white,
),
),
],
Text(
t.client_hubs.subtitle,
style: UiTypography.body2r.copyWith(
color: UiColors.switchInactive,
),
),
],
),
),
UiButton.primary(
onPressed: () async {
final bool? success = await Modular.to.toEditHub();
if (success == true && context.mounted) {
BlocProvider.of<ClientHubsBloc>(
context,
).add(const ClientHubsFetched());
}
},
text: t.client_hubs.add_hub,
leadingIcon: UiIcons.add,
size: UiButtonSize.small,
),
],
),

View File

@@ -3,15 +3,14 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:google_places_flutter/model/prediction.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/edit_hub/edit_hub_bloc.dart';
import '../blocs/edit_hub/edit_hub_event.dart';
import '../blocs/edit_hub/edit_hub_state.dart';
import '../widgets/edit_hub/edit_hub_form_section.dart';
import '../widgets/hub_form_dialog.dart';
/// A dedicated full-screen page for adding or editing a hub.
/// A wrapper page that shows the hub form in a modal-style layout.
class EditHubPage extends StatefulWidget {
const EditHubPage({this.hub, required this.bloc, super.key});
@@ -23,66 +22,11 @@ class EditHubPage extends StatefulWidget {
}
class _EditHubPageState extends State<EditHubPage> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
late final TextEditingController _addressController;
late final FocusNode _addressFocusNode;
Prediction? _selectedPrediction;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.hub?.name);
_addressController = TextEditingController(text: widget.hub?.address);
_addressFocusNode = FocusNode();
// Update header on change (if header is added back)
_nameController.addListener(() => setState(() {}));
_addressController.addListener(() => setState(() {}));
}
@override
void dispose() {
_nameController.dispose();
_addressController.dispose();
_addressFocusNode.dispose();
super.dispose();
}
void _onSave() {
if (!_formKey.currentState!.validate()) return;
if (_addressController.text.trim().isEmpty) {
UiSnackbar.show(
context,
message: t.client_hubs.add_hub_dialog.address_hint,
type: UiSnackbarType.error,
);
return;
}
if (widget.hub == null) {
widget.bloc.add(
EditHubAddRequested(
name: _nameController.text.trim(),
address: _addressController.text.trim(),
placeId: _selectedPrediction?.placeId,
latitude: double.tryParse(_selectedPrediction?.lat ?? ''),
longitude: double.tryParse(_selectedPrediction?.lng ?? ''),
),
);
} else {
widget.bloc.add(
EditHubUpdateRequested(
id: widget.hub!.id,
name: _nameController.text.trim(),
address: _addressController.text.trim(),
placeId: _selectedPrediction?.placeId,
latitude: double.tryParse(_selectedPrediction?.lat ?? ''),
longitude: double.tryParse(_selectedPrediction?.lng ?? ''),
),
);
}
// Load available cost centers
widget.bloc.add(const EditHubCostCentersLoadRequested());
}
@override
@@ -91,17 +35,18 @@ class _EditHubPageState extends State<EditHubPage> {
value: widget.bloc,
child: BlocListener<EditHubBloc, EditHubState>(
listenWhen: (EditHubState prev, EditHubState curr) =>
prev.status != curr.status ||
prev.successMessage != curr.successMessage,
prev.status != curr.status || prev.successKey != curr.successKey,
listener: (BuildContext context, EditHubState state) {
if (state.status == EditHubStatus.success &&
state.successMessage != null) {
state.successKey != null) {
final String message = state.successKey == 'created'
? t.client_hubs.edit_hub.created_success
: t.client_hubs.edit_hub.updated_success;
UiSnackbar.show(
context,
message: state.successMessage!,
message: message,
type: UiSnackbarType.success,
);
// Pop back to the previous screen.
Modular.to.pop(true);
}
if (state.status == EditHubStatus.failure &&
@@ -118,42 +63,59 @@ class _EditHubPageState extends State<EditHubPage> {
final bool isSaving = state.status == EditHubStatus.loading;
return Scaffold(
backgroundColor: UiColors.bgMenu,
appBar: UiAppBar(
title: widget.hub == null
? t.client_hubs.add_hub_dialog.title
: t.client_hubs.edit_hub.title,
subtitle: widget.hub == null
? t.client_hubs.add_hub_dialog.create_button
: t.client_hubs.edit_hub.subtitle,
onLeadingPressed: () => Modular.to.pop(),
),
backgroundColor: UiColors.bgOverlay,
body: Stack(
children: <Widget>[
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: EditHubFormSection(
formKey: _formKey,
nameController: _nameController,
addressController: _addressController,
addressFocusNode: _addressFocusNode,
onAddressSelected: (Prediction prediction) {
_selectedPrediction = prediction;
},
onSave: _onSave,
isSaving: isSaving,
isEdit: widget.hub != null,
),
),
],
// Tap background to dismiss
GestureDetector(
onTap: () => Modular.to.pop(),
child: Container(color: Colors.transparent),
),
// Dialog-style content centered
Align(
alignment: Alignment.center,
child: HubFormDialog(
hub: widget.hub,
costCenters: state.costCenters,
onCancel: () => Modular.to.pop(),
onSave: ({
required String name,
required String address,
String? costCenterId,
String? placeId,
double? latitude,
double? longitude,
}) {
if (widget.hub == null) {
widget.bloc.add(
EditHubAddRequested(
name: name,
address: address,
costCenterId: costCenterId,
placeId: placeId,
latitude: latitude,
longitude: longitude,
),
);
} else {
widget.bloc.add(
EditHubUpdateRequested(
id: widget.hub!.id,
name: name,
address: address,
costCenterId: costCenterId,
placeId: placeId,
latitude: latitude,
longitude: longitude,
),
);
}
},
),
),
// ── Loading overlay ──────────────────────────────────────
// Global loading overlay if saving
if (isSaving)
Container(
color: UiColors.black.withValues(alpha: 0.1),

View File

@@ -29,9 +29,12 @@ class HubDetailsPage extends StatelessWidget {
child: BlocListener<HubDetailsBloc, HubDetailsState>(
listener: (BuildContext context, HubDetailsState state) {
if (state.status == HubDetailsStatus.deleted) {
final String message = state.successKey == 'deleted'
? t.client_hubs.hub_details.deleted_success
: (state.successMessage ?? t.client_hubs.hub_details.deleted_success);
UiSnackbar.show(
context,
message: state.successMessage ?? 'Hub deleted successfully',
message: message,
type: UiSnackbarType.success,
);
Modular.to.pop(true); // Return true to indicate change
@@ -80,6 +83,15 @@ class HubDetailsPage extends StatelessWidget {
icon: UiIcons.nfc,
isHighlight: hub.nfcTagId != null,
),
const SizedBox(height: UiConstants.space4),
HubDetailsItem(
label: t.client_hubs.hub_details.cost_center_label,
value: hub.costCenter != null
? '${hub.costCenter!.name} (${hub.costCenter!.code})'
: t.client_hubs.hub_details.cost_center_none,
icon: UiIcons.bank, // Using bank icon for cost center
isHighlight: hub.costCenter != null,
),
],
),
),

View File

@@ -2,6 +2,7 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:google_places_flutter/model/prediction.dart';
import 'package:krow_domain/krow_domain.dart';
import '../hub_address_autocomplete.dart';
import 'edit_hub_field_label.dart';
@@ -15,6 +16,9 @@ class EditHubFormSection extends StatelessWidget {
required this.addressFocusNode,
required this.onAddressSelected,
required this.onSave,
this.costCenters = const <CostCenter>[],
this.selectedCostCenterId,
required this.onCostCenterChanged,
this.isSaving = false,
this.isEdit = false,
super.key,
@@ -26,6 +30,9 @@ class EditHubFormSection extends StatelessWidget {
final FocusNode addressFocusNode;
final ValueChanged<Prediction> onAddressSelected;
final VoidCallback onSave;
final List<CostCenter> costCenters;
final String? selectedCostCenterId;
final ValueChanged<String?> onCostCenterChanged;
final bool isSaving;
final bool isEdit;
@@ -44,7 +51,7 @@ class EditHubFormSection extends StatelessWidget {
textInputAction: TextInputAction.next,
validator: (String? value) {
if (value == null || value.trim().isEmpty) {
return 'Name is required';
return t.client_hubs.edit_hub.name_required;
}
return null;
},
@@ -62,6 +69,51 @@ class EditHubFormSection extends StatelessWidget {
onSelected: onAddressSelected,
),
const SizedBox(height: UiConstants.space4),
EditHubFieldLabel(t.client_hubs.edit_hub.cost_center_label),
InkWell(
onTap: () => _showCostCenterSelector(context),
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: 14,
),
decoration: BoxDecoration(
color: UiColors.input,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(
color: selectedCostCenterId != null
? UiColors.ring
: UiColors.border,
width: selectedCostCenterId != null ? 2 : 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Expanded(
child: Text(
selectedCostCenterId != null
? _getCostCenterName(selectedCostCenterId!)
: t.client_hubs.edit_hub.cost_center_hint,
style: selectedCostCenterId != null
? UiTypography.body1r.textPrimary
: UiTypography.body2r.textPlaceholder,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const Icon(
Icons.keyboard_arrow_down,
color: UiColors.iconSecondary,
),
],
),
),
),
const SizedBox(height: UiConstants.space8),
// ── Save button ──────────────────────────────────
@@ -102,4 +154,59 @@ class EditHubFormSection extends StatelessWidget {
),
);
}
String _getCostCenterName(String id) {
try {
final CostCenter cc = costCenters.firstWhere((CostCenter item) => item.id == id);
return cc.code != null ? '${cc.name} (${cc.code})' : cc.name;
} catch (_) {
return id;
}
}
Future<void> _showCostCenterSelector(BuildContext context) async {
final CostCenter? selected = await showDialog<CostCenter>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
title: Text(
t.client_hubs.edit_hub.cost_center_label,
style: UiTypography.headline3m.textPrimary,
),
contentPadding: const EdgeInsets.symmetric(vertical: 16),
content: SizedBox(
width: double.maxFinite,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child : costCenters.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(t.client_hubs.edit_hub.cost_centers_empty),
)
: ListView.builder(
shrinkWrap: true,
itemCount: costCenters.length,
itemBuilder: (BuildContext context, int index) {
final CostCenter cc = costCenters[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(cc.name, style: UiTypography.body1m.textPrimary),
subtitle: cc.code != null ? Text(cc.code!, style: UiTypography.body2r.textSecondary) : null,
onTap: () => Navigator.of(context).pop(cc),
);
},
),
),
),
);
},
);
if (selected != null) {
onCostCenterChanged(selected.id);
}
}
}

View File

@@ -11,6 +11,7 @@ class HubAddressAutocomplete extends StatelessWidget {
required this.controller,
required this.hintText,
this.focusNode,
this.decoration,
this.onSelected,
super.key,
});
@@ -18,6 +19,7 @@ class HubAddressAutocomplete extends StatelessWidget {
final TextEditingController controller;
final String hintText;
final FocusNode? focusNode;
final InputDecoration? decoration;
final void Function(Prediction prediction)? onSelected;
@override
@@ -25,6 +27,7 @@ class HubAddressAutocomplete extends StatelessWidget {
return GooglePlaceAutoCompleteTextField(
textEditingController: controller,
focusNode: focusNode,
inputDecoration: decoration ?? const InputDecoration(),
googleAPIKey: AppConfig.googleMapsApiKey,
debounceTime: 500,
countries: HubsConstants.supportedCountries,

View File

@@ -5,25 +5,30 @@ import 'package:google_places_flutter/model/prediction.dart';
import 'package:krow_domain/krow_domain.dart';
import 'hub_address_autocomplete.dart';
import 'edit_hub/edit_hub_field_label.dart';
/// A dialog for adding or editing a hub.
/// A bottom sheet dialog for adding or editing a hub.
class HubFormDialog extends StatefulWidget {
/// Creates a [HubFormDialog].
const HubFormDialog({
required this.onSave,
required this.onCancel,
this.hub,
this.costCenters = const <CostCenter>[],
super.key,
});
/// The hub to edit. If null, a new hub is created.
final Hub? hub;
/// Available cost centers for selection.
final List<CostCenter> costCenters;
/// Callback when the "Save" button is pressed.
final void Function(
String name,
String address, {
final void Function({
required String name,
required String address,
String? costCenterId,
String? placeId,
double? latitude,
double? longitude,
@@ -40,6 +45,7 @@ class _HubFormDialogState extends State<HubFormDialog> {
late final TextEditingController _nameController;
late final TextEditingController _addressController;
late final FocusNode _addressFocusNode;
String? _selectedCostCenterId;
Prediction? _selectedPrediction;
@override
@@ -48,6 +54,7 @@ class _HubFormDialogState extends State<HubFormDialog> {
_nameController = TextEditingController(text: widget.hub?.name);
_addressController = TextEditingController(text: widget.hub?.address);
_addressFocusNode = FocusNode();
_selectedCostCenterId = widget.hub?.costCenter?.id;
}
@override
@@ -63,102 +70,193 @@ class _HubFormDialogState extends State<HubFormDialog> {
@override
Widget build(BuildContext context) {
final bool isEditing = widget.hub != null;
final String title = isEditing
? 'Edit Hub' // TODO: localize
final String title = isEditing
? t.client_hubs.edit_hub.title
: t.client_hubs.add_hub_dialog.title;
final String buttonText = isEditing
? 'Save Changes' // TODO: localize
? t.client_hubs.edit_hub.save_button
: t.client_hubs.add_hub_dialog.create_button;
return Container(
color: UiColors.bgOverlay,
child: Center(
child: SingleChildScrollView(
child: Container(
width: MediaQuery.of(context).size.width * 0.9,
padding: const EdgeInsets.all(UiConstants.space5),
decoration: BoxDecoration(
color: UiColors.bgPopup,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
boxShadow: const <BoxShadow>[
BoxShadow(color: UiColors.popupShadow, blurRadius: 20),
],
return Center(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
decoration: BoxDecoration(
color: UiColors.bgPopup,
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 3),
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.15),
blurRadius: 30,
offset: const Offset(0, 10),
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
title,
style: UiTypography.headline3m.textPrimary,
],
),
padding: const EdgeInsets.all(UiConstants.space6),
child: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
title,
style: UiTypography.headline3m.textPrimary.copyWith(
fontSize: 20,
),
const SizedBox(height: UiConstants.space5),
_buildFieldLabel(t.client_hubs.add_hub_dialog.name_label),
TextFormField(
controller: _nameController,
style: UiTypography.body1r.textPrimary,
validator: (String? value) {
if (value == null || value.trim().isEmpty) {
return 'Name is required';
}
return null;
},
decoration: _buildInputDecoration(
t.client_hubs.add_hub_dialog.name_hint,
),
const SizedBox(height: UiConstants.space5),
// ── Hub Name ────────────────────────────────
EditHubFieldLabel(t.client_hubs.add_hub_dialog.name_label),
const SizedBox(height: UiConstants.space2),
TextFormField(
controller: _nameController,
style: UiTypography.body1r.textPrimary,
textInputAction: TextInputAction.next,
validator: (String? value) {
if (value == null || value.trim().isEmpty) {
return t.client_hubs.add_hub_dialog.name_required;
}
return null;
},
decoration: _buildInputDecoration(
t.client_hubs.add_hub_dialog.name_hint,
),
),
const SizedBox(height: UiConstants.space4),
// ── Cost Center ─────────────────────────────
EditHubFieldLabel(t.client_hubs.add_hub_dialog.cost_center_label),
const SizedBox(height: UiConstants.space2),
InkWell(
onTap: _showCostCenterSelector,
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: 16,
),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFD),
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5),
border: Border.all(
color: _selectedCostCenterId != null
? UiColors.primary
: UiColors.primary.withValues(alpha: 0.1),
width: _selectedCostCenterId != null ? 2 : 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Expanded(
child: Text(
_selectedCostCenterId != null
? _getCostCenterName(_selectedCostCenterId!)
: t.client_hubs.add_hub_dialog.cost_center_hint,
style: _selectedCostCenterId != null
? UiTypography.body1r.textPrimary
: UiTypography.body2r.textPlaceholder.copyWith(
color: UiColors.textSecondary.withValues(alpha: 0.5),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const Icon(
Icons.keyboard_arrow_down,
color: UiColors.iconSecondary,
),
],
),
),
const SizedBox(height: UiConstants.space4),
_buildFieldLabel(t.client_hubs.add_hub_dialog.address_label),
HubAddressAutocomplete(
controller: _addressController,
hintText: t.client_hubs.add_hub_dialog.address_hint,
focusNode: _addressFocusNode,
onSelected: (Prediction prediction) {
_selectedPrediction = prediction;
},
),
const SizedBox(height: UiConstants.space4),
// ── Address ─────────────────────────────────
EditHubFieldLabel(t.client_hubs.add_hub_dialog.address_label),
const SizedBox(height: UiConstants.space2),
HubAddressAutocomplete(
controller: _addressController,
hintText: t.client_hubs.add_hub_dialog.address_hint,
decoration: _buildInputDecoration(
t.client_hubs.add_hub_dialog.address_hint,
),
const SizedBox(height: UiConstants.space8),
Row(
children: <Widget>[
Expanded(
child: UiButton.secondary(
onPressed: widget.onCancel,
text: t.common.cancel,
focusNode: _addressFocusNode,
onSelected: (Prediction prediction) {
_selectedPrediction = prediction;
},
),
const SizedBox(height: UiConstants.space8),
// ── Buttons ─────────────────────────────────
Row(
children: <Widget>[
Expanded(
child: UiButton.secondary(
style: OutlinedButton.styleFrom(
side: BorderSide(
color: UiColors.primary.withValues(alpha: 0.1),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
UiConstants.radiusBase * 1.5,
),
),
),
onPressed: widget.onCancel,
text: t.common.cancel,
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: UiButton.primary(
onPressed: () {
if (_formKey.currentState!.validate()) {
if (_addressController.text.trim().isEmpty) {
UiSnackbar.show(context, message: 'Address is required', type: UiSnackbarType.error);
return;
}
widget.onSave(
_nameController.text,
_addressController.text,
placeId: _selectedPrediction?.placeId,
latitude: double.tryParse(
_selectedPrediction?.lat ?? '',
),
longitude: double.tryParse(
_selectedPrediction?.lng ?? '',
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: UiButton.primary(
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.accent,
foregroundColor: UiColors.accentForeground,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
UiConstants.radiusBase * 1.5,
),
),
),
onPressed: () {
if (_formKey.currentState!.validate()) {
if (_addressController.text.trim().isEmpty) {
UiSnackbar.show(
context,
message: t.client_hubs.add_hub_dialog.address_required,
type: UiSnackbarType.error,
);
return;
}
},
text: buttonText,
),
widget.onSave(
name: _nameController.text.trim(),
address: _addressController.text.trim(),
costCenterId: _selectedCostCenterId,
placeId: _selectedPrediction?.placeId,
latitude: double.tryParse(
_selectedPrediction?.lat ?? '',
),
longitude: double.tryParse(
_selectedPrediction?.lng ?? '',
),
);
}
},
text: buttonText,
),
],
),
],
),
),
],
),
],
),
),
),
@@ -166,35 +264,87 @@ class _HubFormDialogState extends State<HubFormDialog> {
);
}
Widget _buildFieldLabel(String label) {
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space2),
child: Text(label, style: UiTypography.body2m.textPrimary),
);
}
InputDecoration _buildInputDecoration(String hint) {
return InputDecoration(
hintText: hint,
hintStyle: UiTypography.body2r.textPlaceholder,
hintStyle: UiTypography.body2r.textPlaceholder.copyWith(
color: UiColors.textSecondary.withValues(alpha: 0.5),
),
filled: true,
fillColor: UiColors.input,
fillColor: const Color(0xFFF8FAFD),
contentPadding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: 14,
vertical: 16,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
borderSide: const BorderSide(color: UiColors.border),
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5),
borderSide: BorderSide(color: UiColors.primary.withValues(alpha: 0.1)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
borderSide: const BorderSide(color: UiColors.border),
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5),
borderSide: BorderSide(color: UiColors.primary.withValues(alpha: 0.1)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
borderSide: const BorderSide(color: UiColors.ring, width: 2),
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5),
borderSide: const BorderSide(color: UiColors.primary, width: 2),
),
errorStyle: UiTypography.footnote2r.textError,
);
}
String _getCostCenterName(String id) {
try {
return widget.costCenters.firstWhere((CostCenter cc) => cc.id == id).name;
} catch (_) {
return id;
}
}
Future<void> _showCostCenterSelector() async {
final CostCenter? selected = await showDialog<CostCenter>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
title: Text(
t.client_hubs.add_hub_dialog.cost_center_label,
style: UiTypography.headline3m.textPrimary,
),
contentPadding: const EdgeInsets.symmetric(vertical: 16),
content: SizedBox(
width: double.maxFinite,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child: widget.costCenters.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(t.client_hubs.add_hub_dialog.cost_centers_empty),
)
: ListView.builder(
shrinkWrap: true,
itemCount: widget.costCenters.length,
itemBuilder: (BuildContext context, int index) {
final CostCenter cc = widget.costCenters[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(cc.name, style: UiTypography.body1m.textPrimary),
subtitle: cc.code != null ? Text(cc.code!, style: UiTypography.body2r.textSecondary) : null,
onTap: () => Navigator.of(context).pop(cc),
);
},
),
),
),
);
},
);
if (selected != null) {
setState(() {
_selectedCostCenterId = selected.id;
});
}
}
}

View File

@@ -31,6 +31,8 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
on<OneTimeOrderPositionUpdated>(_onPositionUpdated);
on<OneTimeOrderSubmitted>(_onSubmitted);
on<OneTimeOrderInitialized>(_onInitialized);
on<OneTimeOrderHubManagerChanged>(_onHubManagerChanged);
on<OneTimeOrderManagersLoaded>(_onManagersLoaded);
_loadVendors();
_loadHubs();
@@ -134,6 +136,43 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
}
}
Future<void> _loadManagersForHub(
String hubId,
) async {
final List<OneTimeOrderManagerOption>? managers =
await handleErrorWithResult(
action: () async {
final fdc.QueryResult<dc.ListTeamMembersData, void> result =
await _service.connector.listTeamMembers().execute();
return result.data.teamMembers
.where(
(dc.ListTeamMembersTeamMembers member) =>
member.teamHubId == hubId &&
member.role is dc.Known<dc.TeamMemberRole> &&
(member.role as dc.Known<dc.TeamMemberRole>).value ==
dc.TeamMemberRole.MANAGER,
)
.map(
(dc.ListTeamMembersTeamMembers member) =>
OneTimeOrderManagerOption(
id: member.id,
name: member.user.fullName ?? 'Unknown',
),
)
.toList();
},
onError: (_) {
add(const OneTimeOrderManagersLoaded(<OneTimeOrderManagerOption>[]));
},
);
if (managers != null) {
add(OneTimeOrderManagersLoaded(managers));
}
}
Future<void> _onVendorsLoaded(
OneTimeOrderVendorsLoaded event,
Emitter<OneTimeOrderState> emit,
@@ -171,15 +210,36 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
location: selectedHub?.name ?? '',
),
);
if (selectedHub != null) {
_loadManagersForHub(selectedHub.id);
}
}
void _onHubChanged(
OneTimeOrderHubChanged event,
Emitter<OneTimeOrderState> emit,
) {
emit(state.copyWith(selectedHub: event.hub, location: event.hub.name));
_loadManagersForHub(event.hub.id);
}
void _onHubManagerChanged(
OneTimeOrderHubManagerChanged event,
Emitter<OneTimeOrderState> emit,
) {
emit(state.copyWith(selectedManager: event.manager));
}
void _onManagersLoaded(
OneTimeOrderManagersLoaded event,
Emitter<OneTimeOrderState> emit,
) {
emit(state.copyWith(managers: event.managers));
}
void _onEventNameChanged(
OneTimeOrderEventNameChanged event,
Emitter<OneTimeOrderState> emit,
@@ -267,6 +327,7 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
),
eventName: state.eventName,
vendorId: state.selectedVendor?.id,
hubManagerId: state.selectedManager?.id,
roleRates: roleRates,
);
await _createOneTimeOrderUseCase(OneTimeOrderArguments(order: order));

View File

@@ -89,3 +89,21 @@ class OneTimeOrderInitialized extends OneTimeOrderEvent {
@override
List<Object?> get props => <Object?>[data];
}
class OneTimeOrderHubManagerChanged extends OneTimeOrderEvent {
const OneTimeOrderHubManagerChanged(this.manager);
final OneTimeOrderManagerOption? manager;
@override
List<Object?> get props => <Object?>[manager];
}
class OneTimeOrderManagersLoaded extends OneTimeOrderEvent {
const OneTimeOrderManagersLoaded(this.managers);
final List<OneTimeOrderManagerOption> managers;
@override
List<Object?> get props => <Object?>[managers];
}

View File

@@ -16,6 +16,8 @@ class OneTimeOrderState extends Equatable {
this.hubs = const <OneTimeOrderHubOption>[],
this.selectedHub,
this.roles = const <OneTimeOrderRoleOption>[],
this.managers = const <OneTimeOrderManagerOption>[],
this.selectedManager,
});
factory OneTimeOrderState.initial() {
@@ -29,6 +31,7 @@ class OneTimeOrderState extends Equatable {
vendors: const <Vendor>[],
hubs: const <OneTimeOrderHubOption>[],
roles: const <OneTimeOrderRoleOption>[],
managers: const <OneTimeOrderManagerOption>[],
);
}
final DateTime date;
@@ -42,6 +45,8 @@ class OneTimeOrderState extends Equatable {
final List<OneTimeOrderHubOption> hubs;
final OneTimeOrderHubOption? selectedHub;
final List<OneTimeOrderRoleOption> roles;
final List<OneTimeOrderManagerOption> managers;
final OneTimeOrderManagerOption? selectedManager;
OneTimeOrderState copyWith({
DateTime? date,
@@ -55,6 +60,8 @@ class OneTimeOrderState extends Equatable {
List<OneTimeOrderHubOption>? hubs,
OneTimeOrderHubOption? selectedHub,
List<OneTimeOrderRoleOption>? roles,
List<OneTimeOrderManagerOption>? managers,
OneTimeOrderManagerOption? selectedManager,
}) {
return OneTimeOrderState(
date: date ?? this.date,
@@ -68,6 +75,8 @@ class OneTimeOrderState extends Equatable {
hubs: hubs ?? this.hubs,
selectedHub: selectedHub ?? this.selectedHub,
roles: roles ?? this.roles,
managers: managers ?? this.managers,
selectedManager: selectedManager ?? this.selectedManager,
);
}
@@ -98,6 +107,8 @@ class OneTimeOrderState extends Equatable {
hubs,
selectedHub,
roles,
managers,
selectedManager,
];
}
@@ -158,3 +169,17 @@ class OneTimeOrderRoleOption extends Equatable {
@override
List<Object?> get props => <Object?>[id, name, costPerHour];
}
class OneTimeOrderManagerOption extends Equatable {
const OneTimeOrderManagerOption({
required this.id,
required this.name,
});
final String id;
final String name;
@override
List<Object?> get props => <Object?>[id, name];
}

View File

@@ -31,6 +31,8 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
on<PermanentOrderPositionUpdated>(_onPositionUpdated);
on<PermanentOrderSubmitted>(_onSubmitted);
on<PermanentOrderInitialized>(_onInitialized);
on<PermanentOrderHubManagerChanged>(_onHubManagerChanged);
on<PermanentOrderManagersLoaded>(_onManagersLoaded);
_loadVendors();
_loadHubs();
@@ -182,6 +184,10 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
location: selectedHub?.name ?? '',
),
);
if (selectedHub != null) {
_loadManagersForHub(selectedHub.id, emit);
}
}
void _onHubChanged(
@@ -189,8 +195,61 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
Emitter<PermanentOrderState> emit,
) {
emit(state.copyWith(selectedHub: event.hub, location: event.hub.name));
_loadManagersForHub(event.hub.id, emit);
}
void _onHubManagerChanged(
PermanentOrderHubManagerChanged event,
Emitter<PermanentOrderState> emit,
) {
emit(state.copyWith(selectedManager: event.manager));
}
void _onManagersLoaded(
PermanentOrderManagersLoaded event,
Emitter<PermanentOrderState> emit,
) {
emit(state.copyWith(managers: event.managers));
}
Future<void> _loadManagersForHub(
String hubId,
Emitter<PermanentOrderState> emit,
) async {
final List<PermanentOrderManagerOption>? managers =
await handleErrorWithResult(
action: () async {
final fdc.QueryResult<dc.ListTeamMembersData, void> result =
await _service.connector.listTeamMembers().execute();
return result.data.teamMembers
.where(
(dc.ListTeamMembersTeamMembers member) =>
member.teamHubId == hubId &&
member.role is dc.Known<dc.TeamMemberRole> &&
(member.role as dc.Known<dc.TeamMemberRole>).value ==
dc.TeamMemberRole.MANAGER,
)
.map(
(dc.ListTeamMembersTeamMembers member) =>
PermanentOrderManagerOption(
id: member.id,
name: member.user.fullName ?? 'Unknown',
),
)
.toList();
},
onError: (_) => emit(
state.copyWith(managers: const <PermanentOrderManagerOption>[]),
),
);
if (managers != null) {
emit(state.copyWith(managers: managers, selectedManager: null));
}
}
void _onEventNameChanged(
PermanentOrderEventNameChanged event,
Emitter<PermanentOrderState> emit,
@@ -330,6 +389,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
),
eventName: state.eventName,
vendorId: state.selectedVendor?.id,
hubManagerId: state.selectedManager?.id,
roleRates: roleRates,
);
await _createPermanentOrderUseCase(order);

View File

@@ -106,3 +106,20 @@ class PermanentOrderInitialized extends PermanentOrderEvent {
@override
List<Object?> get props => <Object?>[data];
}
class PermanentOrderHubManagerChanged extends PermanentOrderEvent {
const PermanentOrderHubManagerChanged(this.manager);
final PermanentOrderManagerOption? manager;
@override
List<Object?> get props => <Object?>[manager];
}
class PermanentOrderManagersLoaded extends PermanentOrderEvent {
const PermanentOrderManagersLoaded(this.managers);
final List<PermanentOrderManagerOption> managers;
@override
List<Object?> get props => <Object?>[managers];
}

View File

@@ -18,6 +18,8 @@ class PermanentOrderState extends Equatable {
this.hubs = const <PermanentOrderHubOption>[],
this.selectedHub,
this.roles = const <PermanentOrderRoleOption>[],
this.managers = const <PermanentOrderManagerOption>[],
this.selectedManager,
});
factory PermanentOrderState.initial() {
@@ -45,6 +47,7 @@ class PermanentOrderState extends Equatable {
vendors: const <Vendor>[],
hubs: const <PermanentOrderHubOption>[],
roles: const <PermanentOrderRoleOption>[],
managers: const <PermanentOrderManagerOption>[],
);
}
@@ -61,6 +64,8 @@ class PermanentOrderState extends Equatable {
final List<PermanentOrderHubOption> hubs;
final PermanentOrderHubOption? selectedHub;
final List<PermanentOrderRoleOption> roles;
final List<PermanentOrderManagerOption> managers;
final PermanentOrderManagerOption? selectedManager;
PermanentOrderState copyWith({
DateTime? startDate,
@@ -76,6 +81,8 @@ class PermanentOrderState extends Equatable {
List<PermanentOrderHubOption>? hubs,
PermanentOrderHubOption? selectedHub,
List<PermanentOrderRoleOption>? roles,
List<PermanentOrderManagerOption>? managers,
PermanentOrderManagerOption? selectedManager,
}) {
return PermanentOrderState(
startDate: startDate ?? this.startDate,
@@ -91,6 +98,8 @@ class PermanentOrderState extends Equatable {
hubs: hubs ?? this.hubs,
selectedHub: selectedHub ?? this.selectedHub,
roles: roles ?? this.roles,
managers: managers ?? this.managers,
selectedManager: selectedManager ?? this.selectedManager,
);
}
@@ -124,6 +133,8 @@ class PermanentOrderState extends Equatable {
hubs,
selectedHub,
roles,
managers,
selectedManager,
];
}
@@ -185,6 +196,20 @@ class PermanentOrderRoleOption extends Equatable {
List<Object?> get props => <Object?>[id, name, costPerHour];
}
class PermanentOrderManagerOption extends Equatable {
const PermanentOrderManagerOption({
required this.id,
required this.name,
});
final String id;
final String name;
@override
List<Object?> get props => <Object?>[id, name];
}
class PermanentOrderPosition extends Equatable {
const PermanentOrderPosition({
required this.role,

View File

@@ -32,6 +32,8 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
on<RecurringOrderPositionUpdated>(_onPositionUpdated);
on<RecurringOrderSubmitted>(_onSubmitted);
on<RecurringOrderInitialized>(_onInitialized);
on<RecurringOrderHubManagerChanged>(_onHubManagerChanged);
on<RecurringOrderManagersLoaded>(_onManagersLoaded);
_loadVendors();
_loadHubs();
@@ -169,10 +171,10 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
await _loadRolesForVendor(event.vendor.id, emit);
}
void _onHubsLoaded(
Future<void> _onHubsLoaded(
RecurringOrderHubsLoaded event,
Emitter<RecurringOrderState> emit,
) {
) async {
final RecurringOrderHubOption? selectedHub = event.hubs.isNotEmpty
? event.hubs.first
: null;
@@ -183,13 +185,69 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
location: selectedHub?.name ?? '',
),
);
if (selectedHub != null) {
await _loadManagersForHub(selectedHub.id, emit);
}
}
void _onHubChanged(
Future<void> _onHubChanged(
RecurringOrderHubChanged event,
Emitter<RecurringOrderState> emit,
) {
) async {
emit(state.copyWith(selectedHub: event.hub, location: event.hub.name));
await _loadManagersForHub(event.hub.id, emit);
}
void _onHubManagerChanged(
RecurringOrderHubManagerChanged event,
Emitter<RecurringOrderState> emit,
) {
emit(state.copyWith(selectedManager: event.manager));
}
void _onManagersLoaded(
RecurringOrderManagersLoaded event,
Emitter<RecurringOrderState> emit,
) {
emit(state.copyWith(managers: event.managers));
}
Future<void> _loadManagersForHub(
String hubId,
Emitter<RecurringOrderState> emit,
) async {
final List<RecurringOrderManagerOption>? managers =
await handleErrorWithResult(
action: () async {
final fdc.QueryResult<dc.ListTeamMembersData, void> result =
await _service.connector.listTeamMembers().execute();
return result.data.teamMembers
.where(
(dc.ListTeamMembersTeamMembers member) =>
member.teamHubId == hubId &&
member.role is dc.Known<dc.TeamMemberRole> &&
(member.role as dc.Known<dc.TeamMemberRole>).value ==
dc.TeamMemberRole.MANAGER,
)
.map(
(dc.ListTeamMembersTeamMembers member) =>
RecurringOrderManagerOption(
id: member.id,
name: member.user.fullName ?? 'Unknown',
),
)
.toList();
},
onError: (_) => emit(
state.copyWith(managers: const <RecurringOrderManagerOption>[]),
),
);
if (managers != null) {
emit(state.copyWith(managers: managers, selectedManager: null));
}
}
void _onEventNameChanged(
@@ -349,6 +407,7 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
),
eventName: state.eventName,
vendorId: state.selectedVendor?.id,
hubManagerId: state.selectedManager?.id,
roleRates: roleRates,
);
await _createRecurringOrderUseCase(order);

View File

@@ -115,3 +115,20 @@ class RecurringOrderInitialized extends RecurringOrderEvent {
@override
List<Object?> get props => <Object?>[data];
}
class RecurringOrderHubManagerChanged extends RecurringOrderEvent {
const RecurringOrderHubManagerChanged(this.manager);
final RecurringOrderManagerOption? manager;
@override
List<Object?> get props => <Object?>[manager];
}
class RecurringOrderManagersLoaded extends RecurringOrderEvent {
const RecurringOrderManagersLoaded(this.managers);
final List<RecurringOrderManagerOption> managers;
@override
List<Object?> get props => <Object?>[managers];
}

View File

@@ -19,6 +19,8 @@ class RecurringOrderState extends Equatable {
this.hubs = const <RecurringOrderHubOption>[],
this.selectedHub,
this.roles = const <RecurringOrderRoleOption>[],
this.managers = const <RecurringOrderManagerOption>[],
this.selectedManager,
});
factory RecurringOrderState.initial() {
@@ -47,6 +49,7 @@ class RecurringOrderState extends Equatable {
vendors: const <Vendor>[],
hubs: const <RecurringOrderHubOption>[],
roles: const <RecurringOrderRoleOption>[],
managers: const <RecurringOrderManagerOption>[],
);
}
@@ -64,6 +67,8 @@ class RecurringOrderState extends Equatable {
final List<RecurringOrderHubOption> hubs;
final RecurringOrderHubOption? selectedHub;
final List<RecurringOrderRoleOption> roles;
final List<RecurringOrderManagerOption> managers;
final RecurringOrderManagerOption? selectedManager;
RecurringOrderState copyWith({
DateTime? startDate,
@@ -80,6 +85,8 @@ class RecurringOrderState extends Equatable {
List<RecurringOrderHubOption>? hubs,
RecurringOrderHubOption? selectedHub,
List<RecurringOrderRoleOption>? roles,
List<RecurringOrderManagerOption>? managers,
RecurringOrderManagerOption? selectedManager,
}) {
return RecurringOrderState(
startDate: startDate ?? this.startDate,
@@ -96,6 +103,8 @@ class RecurringOrderState extends Equatable {
hubs: hubs ?? this.hubs,
selectedHub: selectedHub ?? this.selectedHub,
roles: roles ?? this.roles,
managers: managers ?? this.managers,
selectedManager: selectedManager ?? this.selectedManager,
);
}
@@ -132,6 +141,8 @@ class RecurringOrderState extends Equatable {
hubs,
selectedHub,
roles,
managers,
selectedManager,
];
}
@@ -193,6 +204,20 @@ class RecurringOrderRoleOption extends Equatable {
List<Object?> get props => <Object?>[id, name, costPerHour];
}
class RecurringOrderManagerOption extends Equatable {
const RecurringOrderManagerOption({
required this.id,
required this.name,
});
final String id;
final String name;
@override
List<Object?> get props => <Object?>[id, name];
}
class RecurringOrderPosition extends Equatable {
const RecurringOrderPosition({
required this.role,

View File

@@ -48,6 +48,10 @@ class OneTimeOrderPage extends StatelessWidget {
hubs: state.hubs.map(_mapHub).toList(),
positions: state.positions.map(_mapPosition).toList(),
roles: state.roles.map(_mapRole).toList(),
selectedHubManager: state.selectedManager != null
? _mapManager(state.selectedManager!)
: null,
hubManagers: state.managers.map(_mapManager).toList(),
isValid: state.isValid,
onEventNameChanged: (String val) =>
bloc.add(OneTimeOrderEventNameChanged(val)),
@@ -61,6 +65,17 @@ class OneTimeOrderPage extends StatelessWidget {
);
bloc.add(OneTimeOrderHubChanged(originalHub));
},
onHubManagerChanged: (OrderManagerUiModel? val) {
if (val == null) {
bloc.add(const OneTimeOrderHubManagerChanged(null));
return;
}
final OneTimeOrderManagerOption original =
state.managers.firstWhere(
(OneTimeOrderManagerOption m) => m.id == val.id,
);
bloc.add(OneTimeOrderHubManagerChanged(original));
},
onPositionAdded: () => bloc.add(const OneTimeOrderPositionAdded()),
onPositionUpdated: (int index, OrderPositionUiModel val) {
final OneTimeOrderPosition original = state.positions[index];
@@ -130,4 +145,9 @@ class OneTimeOrderPage extends StatelessWidget {
lunchBreak: pos.lunchBreak,
);
}
OrderManagerUiModel _mapManager(OneTimeOrderManagerOption manager) {
return OrderManagerUiModel(id: manager.id, name: manager.name);
}
}

View File

@@ -42,6 +42,10 @@ class PermanentOrderPage extends StatelessWidget {
? _mapHub(state.selectedHub!)
: null,
hubs: state.hubs.map(_mapHub).toList(),
hubManagers: state.managers.map(_mapManager).toList(),
selectedHubManager: state.selectedManager != null
? _mapManager(state.selectedManager!)
: null,
positions: state.positions.map(_mapPosition).toList(),
roles: state.roles.map(_mapRole).toList(),
isValid: state.isValid,
@@ -59,6 +63,17 @@ class PermanentOrderPage extends StatelessWidget {
);
bloc.add(PermanentOrderHubChanged(originalHub));
},
onHubManagerChanged: (OrderManagerUiModel? val) {
if (val == null) {
bloc.add(const PermanentOrderHubManagerChanged(null));
return;
}
final PermanentOrderManagerOption original =
state.managers.firstWhere(
(PermanentOrderManagerOption m) => m.id == val.id,
);
bloc.add(PermanentOrderHubManagerChanged(original));
},
onPositionAdded: () =>
bloc.add(const PermanentOrderPositionAdded()),
onPositionUpdated: (int index, OrderPositionUiModel val) {
@@ -181,4 +196,8 @@ class PermanentOrderPage extends StatelessWidget {
lunchBreak: pos.lunchBreak ?? 'NO_BREAK',
);
}
OrderManagerUiModel _mapManager(PermanentOrderManagerOption manager) {
return OrderManagerUiModel(id: manager.id, name: manager.name);
}
}

View File

@@ -43,6 +43,10 @@ class RecurringOrderPage extends StatelessWidget {
? _mapHub(state.selectedHub!)
: null,
hubs: state.hubs.map(_mapHub).toList(),
hubManagers: state.managers.map(_mapManager).toList(),
selectedHubManager: state.selectedManager != null
? _mapManager(state.selectedManager!)
: null,
positions: state.positions.map(_mapPosition).toList(),
roles: state.roles.map(_mapRole).toList(),
isValid: state.isValid,
@@ -62,6 +66,17 @@ class RecurringOrderPage extends StatelessWidget {
);
bloc.add(RecurringOrderHubChanged(originalHub));
},
onHubManagerChanged: (OrderManagerUiModel? val) {
if (val == null) {
bloc.add(const RecurringOrderHubManagerChanged(null));
return;
}
final RecurringOrderManagerOption original =
state.managers.firstWhere(
(RecurringOrderManagerOption m) => m.id == val.id,
);
bloc.add(RecurringOrderHubManagerChanged(original));
},
onPositionAdded: () =>
bloc.add(const RecurringOrderPositionAdded()),
onPositionUpdated: (int index, OrderPositionUiModel val) {
@@ -193,4 +208,8 @@ class RecurringOrderPage extends StatelessWidget {
lunchBreak: pos.lunchBreak ?? 'NO_BREAK',
);
}
OrderManagerUiModel _mapManager(RecurringOrderManagerOption manager) {
return OrderManagerUiModel(id: manager.id, name: manager.name);
}
}

View File

@@ -0,0 +1,167 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'order_ui_models.dart';
class HubManagerSelector extends StatelessWidget {
const HubManagerSelector({
required this.managers,
required this.selectedManager,
required this.onChanged,
required this.hintText,
required this.label,
this.description,
this.noManagersText,
this.noneText,
super.key,
});
final List<OrderManagerUiModel> managers;
final OrderManagerUiModel? selectedManager;
final ValueChanged<OrderManagerUiModel?> onChanged;
final String hintText;
final String label;
final String? description;
final String? noManagersText;
final String? noneText;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
label,
style: UiTypography.body1m.textPrimary,
),
if (description != null) ...<Widget>[
const SizedBox(height: UiConstants.space2),
Text(description!, style: UiTypography.body2r.textSecondary),
],
const SizedBox(height: UiConstants.space2),
InkWell(
onTap: () => _showSelector(context),
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: 14,
),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(
color: selectedManager != null ? UiColors.primary : UiColors.border,
width: selectedManager != null ? 2 : 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
children: <Widget>[
Icon(
UiIcons.user,
color: selectedManager != null
? UiColors.primary
: UiColors.iconSecondary,
size: 20,
),
const SizedBox(width: UiConstants.space3),
Text(
selectedManager?.name ?? hintText,
style: selectedManager != null
? UiTypography.body1r.textPrimary
: UiTypography.body2r.textPlaceholder,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
const Icon(
Icons.keyboard_arrow_down,
color: UiColors.iconSecondary,
),
],
),
),
),
],
);
}
Future<void> _showSelector(BuildContext context) async {
final OrderManagerUiModel? selected = await showDialog<OrderManagerUiModel>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
title: Text(
label,
style: UiTypography.headline3m.textPrimary,
),
contentPadding: const EdgeInsets.symmetric(vertical: 16),
content: SizedBox(
width: double.maxFinite,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child: ListView.builder(
shrinkWrap: true,
itemCount: managers.isEmpty ? 2 : managers.length + 1,
itemBuilder: (BuildContext context, int index) {
final String emptyText = noManagersText ?? 'No hub managers available';
final String noneLabel = noneText ?? 'None';
if (managers.isEmpty) {
if (index == 0) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Text(emptyText),
);
}
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(noneLabel, style: UiTypography.body1m.textSecondary),
onTap: () => Navigator.of(context).pop(
OrderManagerUiModel(id: 'NONE', name: noneLabel),
),
);
}
if (index == managers.length) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(noneLabel, style: UiTypography.body1m.textSecondary),
onTap: () => Navigator.of(context).pop(
OrderManagerUiModel(id: 'NONE', name: noneLabel),
),
);
}
final OrderManagerUiModel manager = managers[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
title: Text(manager.name, style: UiTypography.body1m.textPrimary),
subtitle: manager.phone != null
? Text(manager.phone!, style: UiTypography.body2r.textSecondary)
: null,
onTap: () => Navigator.of(context).pop(manager),
);
},
),
),
),
);
},
);
if (selected != null) {
if (selected.id == 'NONE') {
onChanged(null);
} else {
onChanged(selected);
}
}
}
}

View File

@@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
import '../order_ui_models.dart';
import '../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';
@@ -23,11 +24,14 @@ class OneTimeOrderView extends StatelessWidget {
required this.hubs,
required this.positions,
required this.roles,
required this.hubManagers,
required this.selectedHubManager,
required this.isValid,
required this.onEventNameChanged,
required this.onVendorChanged,
required this.onDateChanged,
required this.onHubChanged,
required this.onHubManagerChanged,
required this.onPositionAdded,
required this.onPositionUpdated,
required this.onPositionRemoved,
@@ -47,12 +51,15 @@ class OneTimeOrderView extends StatelessWidget {
final List<OrderHubUiModel> hubs;
final List<OrderPositionUiModel> positions;
final List<OrderRoleUiModel> roles;
final List<OrderManagerUiModel> hubManagers;
final OrderManagerUiModel? selectedHubManager;
final bool isValid;
final ValueChanged<String> onEventNameChanged;
final ValueChanged<Vendor> onVendorChanged;
final ValueChanged<DateTime> onDateChanged;
final ValueChanged<OrderHubUiModel> onHubChanged;
final ValueChanged<OrderManagerUiModel?> onHubManagerChanged;
final VoidCallback onPositionAdded;
final void Function(int index, OrderPositionUiModel position) onPositionUpdated;
final void Function(int index) onPositionRemoved;
@@ -143,12 +150,15 @@ class OneTimeOrderView extends StatelessWidget {
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,
@@ -179,12 +189,15 @@ class _OneTimeOrderForm extends StatelessWidget {
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,
@@ -196,6 +209,8 @@ class _OneTimeOrderForm extends StatelessWidget {
final DateTime date;
final OrderHubUiModel? selectedHub;
final List<OrderHubUiModel> hubs;
final OrderManagerUiModel? selectedHubManager;
final List<OrderManagerUiModel> hubManagers;
final List<OrderPositionUiModel> positions;
final List<OrderRoleUiModel> roles;
@@ -203,6 +218,7 @@ class _OneTimeOrderForm extends StatelessWidget {
final ValueChanged<Vendor> onVendorChanged;
final ValueChanged<DateTime> onDateChanged;
final ValueChanged<OrderHubUiModel> onHubChanged;
final ValueChanged<OrderManagerUiModel?> onHubManagerChanged;
final VoidCallback onPositionAdded;
final void Function(int index, OrderPositionUiModel position) onPositionUpdated;
final void Function(int index) onPositionRemoved;
@@ -310,6 +326,18 @@ class _OneTimeOrderForm extends StatelessWidget {
),
),
),
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(

View File

@@ -94,3 +94,19 @@ class OrderPositionUiModel extends Equatable {
@override
List<Object?> get props => <Object?>[role, count, startTime, endTime, lunchBreak];
}
class OrderManagerUiModel extends Equatable {
const OrderManagerUiModel({
required this.id,
required this.name,
this.phone,
});
final String id;
final String name;
final String? phone;
@override
List<Object?> get props => <Object?>[id, name, phone];
}

View File

@@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart' show Vendor;
import '../order_ui_models.dart';
import '../hub_manager_selector.dart';
import 'permanent_order_date_picker.dart';
import 'permanent_order_event_name_input.dart';
import 'permanent_order_header.dart';
@@ -24,12 +25,15 @@ class PermanentOrderView extends StatelessWidget {
required this.hubs,
required this.positions,
required this.roles,
required this.hubManagers,
required this.selectedHubManager,
required this.isValid,
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,
@@ -48,6 +52,8 @@ class PermanentOrderView extends StatelessWidget {
final List<String> permanentDays;
final OrderHubUiModel? selectedHub;
final List<OrderHubUiModel> hubs;
final OrderManagerUiModel? selectedHubManager;
final List<OrderManagerUiModel> hubManagers;
final List<OrderPositionUiModel> positions;
final List<OrderRoleUiModel> roles;
final bool isValid;
@@ -57,6 +63,7 @@ class PermanentOrderView extends StatelessWidget {
final ValueChanged<DateTime> onStartDateChanged;
final ValueChanged<int> onDayToggled;
final ValueChanged<OrderHubUiModel> onHubChanged;
final ValueChanged<OrderManagerUiModel?> onHubManagerChanged;
final VoidCallback onPositionAdded;
final void Function(int index, OrderPositionUiModel position) onPositionUpdated;
final void Function(int index) onPositionRemoved;
@@ -156,9 +163,12 @@ class PermanentOrderView extends StatelessWidget {
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()),
@@ -194,9 +204,12 @@ class _PermanentOrderForm extends StatelessWidget {
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;
@@ -214,10 +227,14 @@ class _PermanentOrderForm extends StatelessWidget {
final ValueChanged<DateTime> onStartDateChanged;
final ValueChanged<int> onDayToggled;
final ValueChanged<OrderHubUiModel> onHubChanged;
final ValueChanged<OrderManagerUiModel?> onHubManagerChanged;
final VoidCallback onPositionAdded;
final void Function(int index, OrderPositionUiModel position) onPositionUpdated;
final void Function(int index) onPositionRemoved;
final List<OrderManagerUiModel> hubManagers;
final OrderManagerUiModel? selectedHubManager;
@override
Widget build(BuildContext context) {
final TranslationsClientCreateOrderPermanentEn labels =
@@ -331,6 +348,18 @@ class _PermanentOrderForm extends StatelessWidget {
),
),
),
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(

View File

@@ -3,6 +3,7 @@ import 'package:krow_domain/krow_domain.dart' show Vendor;
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../order_ui_models.dart';
import '../hub_manager_selector.dart';
import 'recurring_order_date_picker.dart';
import 'recurring_order_event_name_input.dart';
import 'recurring_order_header.dart';
@@ -25,6 +26,8 @@ class RecurringOrderView extends StatelessWidget {
required this.hubs,
required this.positions,
required this.roles,
required this.hubManagers,
required this.selectedHubManager,
required this.isValid,
required this.onEventNameChanged,
required this.onVendorChanged,
@@ -32,6 +35,7 @@ class RecurringOrderView extends StatelessWidget {
required this.onEndDateChanged,
required this.onDayToggled,
required this.onHubChanged,
required this.onHubManagerChanged,
required this.onPositionAdded,
required this.onPositionUpdated,
required this.onPositionRemoved,
@@ -51,6 +55,8 @@ class RecurringOrderView extends StatelessWidget {
final List<String> recurringDays;
final OrderHubUiModel? selectedHub;
final List<OrderHubUiModel> hubs;
final OrderManagerUiModel? selectedHubManager;
final List<OrderManagerUiModel> hubManagers;
final List<OrderPositionUiModel> positions;
final List<OrderRoleUiModel> roles;
final bool isValid;
@@ -61,6 +67,7 @@ class RecurringOrderView extends StatelessWidget {
final ValueChanged<DateTime> onEndDateChanged;
final ValueChanged<int> onDayToggled;
final ValueChanged<OrderHubUiModel> onHubChanged;
final ValueChanged<OrderManagerUiModel?> onHubManagerChanged;
final VoidCallback onPositionAdded;
final void Function(int index, OrderPositionUiModel position) onPositionUpdated;
final void Function(int index) onPositionRemoved;
@@ -165,9 +172,12 @@ class RecurringOrderView extends StatelessWidget {
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()),
@@ -205,9 +215,12 @@ class _RecurringOrderForm extends StatelessWidget {
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;
@@ -227,10 +240,15 @@ class _RecurringOrderForm extends StatelessWidget {
final ValueChanged<DateTime> onEndDateChanged;
final ValueChanged<int> onDayToggled;
final ValueChanged<OrderHubUiModel> onHubChanged;
final ValueChanged<OrderManagerUiModel?> onHubManagerChanged;
final VoidCallback onPositionAdded;
final void Function(int index, OrderPositionUiModel position) onPositionUpdated;
final void Function(int index) onPositionRemoved;
final List<OrderManagerUiModel> hubManagers;
final OrderManagerUiModel? selectedHubManager;
@override
Widget build(BuildContext context) {
final TranslationsClientCreateOrderRecurringEn labels =
@@ -351,6 +369,18 @@ class _RecurringOrderForm extends StatelessWidget {
),
),
),
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(

View File

@@ -1,3 +1,4 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:firebase_auth/firebase_auth.dart' as firebase;
import 'package:firebase_data_connect/firebase_data_connect.dart';
@@ -57,6 +58,9 @@ class OrderEditSheetState extends State<OrderEditSheet> {
const <dc.ListTeamHubsByOwnerIdTeamHubs>[];
dc.ListTeamHubsByOwnerIdTeamHubs? _selectedHub;
List<dc.ListTeamMembersTeamMembers> _managers = const <dc.ListTeamMembersTeamMembers>[];
dc.ListTeamMembersTeamMembers? _selectedManager;
String? _shiftId;
List<_ShiftRoleKey> _originalShiftRoles = const <_ShiftRoleKey>[];
@@ -246,6 +250,9 @@ class OrderEditSheetState extends State<OrderEditSheet> {
}
});
}
if (selected != null) {
await _loadManagersForHub(selected.id, widget.order.hubManagerId);
}
} catch (_) {
if (mounted) {
setState(() {
@@ -331,6 +338,47 @@ class OrderEditSheetState extends State<OrderEditSheet> {
}
}
Future<void> _loadManagersForHub(String hubId, [String? preselectedId]) async {
try {
final QueryResult<dc.ListTeamMembersData, void> result =
await _dataConnect.listTeamMembers().execute();
final List<dc.ListTeamMembersTeamMembers> hubManagers = result.data.teamMembers
.where(
(dc.ListTeamMembersTeamMembers member) =>
member.teamHubId == hubId &&
member.role is dc.Known<dc.TeamMemberRole> &&
(member.role as dc.Known<dc.TeamMemberRole>).value ==
dc.TeamMemberRole.MANAGER,
)
.toList();
dc.ListTeamMembersTeamMembers? selected;
if (preselectedId != null && preselectedId.isNotEmpty) {
for (final dc.ListTeamMembersTeamMembers m in hubManagers) {
if (m.id == preselectedId) {
selected = m;
break;
}
}
}
if (mounted) {
setState(() {
_managers = hubManagers;
_selectedManager = selected;
});
}
} catch (_) {
if (mounted) {
setState(() {
_managers = const <dc.ListTeamMembersTeamMembers>[];
_selectedManager = null;
});
}
}
}
Map<String, dynamic> _emptyPosition() {
return <String, dynamic>{
'shiftId': _shiftId,
@@ -639,7 +687,7 @@ class OrderEditSheetState extends State<OrderEditSheet> {
padding: const EdgeInsets.all(UiConstants.space5),
children: <Widget>[
Text(
'Edit Your Order',
t.client_view_orders.order_edit_sheet.title,
style: UiTypography.headline3m.textPrimary,
),
const SizedBox(height: UiConstants.space4),
@@ -697,7 +745,7 @@ class OrderEditSheetState extends State<OrderEditSheet> {
_buildSectionHeader('ORDER NAME'),
UiTextField(
controller: _orderNameController,
hintText: 'Order name',
hintText: t.client_view_orders.order_edit_sheet.order_name_hint,
prefixIcon: UiIcons.briefcase,
),
const SizedBox(height: UiConstants.space4),
@@ -744,13 +792,17 @@ class OrderEditSheetState extends State<OrderEditSheet> {
),
),
),
const SizedBox(height: UiConstants.space4),
_buildHubManagerSelector(),
const SizedBox(height: UiConstants.space6),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
'POSITIONS',
t.client_view_orders.order_edit_sheet.positions_section,
style: UiTypography.headline4m.textPrimary,
),
TextButton(
@@ -770,7 +822,7 @@ class OrderEditSheetState extends State<OrderEditSheet> {
color: UiColors.primary,
),
Text(
'Add Position',
t.client_view_orders.order_edit_sheet.add_position,
style: UiTypography.body2m.primary,
),
],
@@ -791,7 +843,7 @@ class OrderEditSheetState extends State<OrderEditSheet> {
),
),
_buildBottomAction(
label: 'Review ${_positions.length} Positions',
label: t.client_view_orders.order_edit_sheet.review_positions(count: _positions.length.toString()),
onPressed: () => setState(() => _showReview = true),
),
const Padding(
@@ -807,6 +859,132 @@ class OrderEditSheetState extends State<OrderEditSheet> {
);
}
Widget _buildHubManagerSelector() {
final TranslationsClientViewOrdersOrderEditSheetEn oes =
t.client_view_orders.order_edit_sheet;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_buildSectionHeader(oes.shift_contact_section),
Text(oes.shift_contact_desc, style: UiTypography.body2r.textSecondary),
const SizedBox(height: UiConstants.space2),
InkWell(
onTap: () => _showHubManagerSelector(),
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: 14,
),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(
color: _selectedManager != null ? UiColors.primary : UiColors.border,
width: _selectedManager != null ? 2 : 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
children: <Widget>[
Icon(
UiIcons.user,
color: _selectedManager != null
? UiColors.primary
: UiColors.iconSecondary,
size: 20,
),
const SizedBox(width: UiConstants.space3),
Text(
_selectedManager?.user.fullName ?? oes.select_contact,
style: _selectedManager != null
? UiTypography.body1r.textPrimary
: UiTypography.body2r.textPlaceholder,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
const Icon(
Icons.keyboard_arrow_down,
color: UiColors.iconSecondary,
),
],
),
),
),
],
);
}
Future<void> _showHubManagerSelector() async {
final dc.ListTeamMembersTeamMembers? selected = await showDialog<dc.ListTeamMembersTeamMembers?>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
title: Text(
t.client_view_orders.order_edit_sheet.shift_contact_section,
style: UiTypography.headline3m.textPrimary,
),
contentPadding: const EdgeInsets.symmetric(vertical: 16),
content: SizedBox(
width: double.maxFinite,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child: ListView.builder(
shrinkWrap: true,
itemCount: _managers.isEmpty ? 2 : _managers.length + 1,
itemBuilder: (BuildContext context, int index) {
if (_managers.isEmpty) {
if (index == 0) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Text(t.client_view_orders.order_edit_sheet.no_hub_managers),
);
}
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(t.client_view_orders.order_edit_sheet.none, style: UiTypography.body1m.textSecondary),
onTap: () => Navigator.of(context).pop(null),
);
}
if (index == _managers.length) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(t.client_view_orders.order_edit_sheet.none, style: UiTypography.body1m.textSecondary),
onTap: () => Navigator.of(context).pop(null),
);
}
final dc.ListTeamMembersTeamMembers manager = _managers[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
title: Text(manager.user.fullName ?? 'Unknown', style: UiTypography.body1m.textPrimary),
onTap: () => Navigator.of(context).pop(manager),
);
},
),
),
),
);
},
);
if (mounted) {
if (selected == null && _managers.isEmpty) {
// Tapped outside or selected None
setState(() => _selectedManager = null);
} else {
setState(() => _selectedManager = selected);
}
}
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 20),
@@ -839,11 +1017,11 @@ class OrderEditSheetState extends State<OrderEditSheet> {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'One-Time Order',
t.client_view_orders.order_edit_sheet.one_time_order_title,
style: UiTypography.headline3m.copyWith(color: UiColors.white),
),
Text(
'Refine your staffing needs',
t.client_view_orders.order_edit_sheet.refine_subtitle,
style: UiTypography.footnote2r.copyWith(
color: UiColors.white.withValues(alpha: 0.8),
),
@@ -885,7 +1063,7 @@ class OrderEditSheetState extends State<OrderEditSheet> {
GestureDetector(
onTap: () => _removePosition(index),
child: Text(
'Remove',
t.client_view_orders.order_edit_sheet.remove,
style: UiTypography.footnote1m.copyWith(
color: UiColors.destructive,
),
@@ -896,7 +1074,7 @@ class OrderEditSheetState extends State<OrderEditSheet> {
const SizedBox(height: UiConstants.space3),
_buildDropdownField(
hint: 'Select role',
hint: t.client_view_orders.order_edit_sheet.select_role_hint,
value: pos['roleId'],
items: <String>[
..._roles.map((_RoleOption role) => role.id),
@@ -931,14 +1109,14 @@ class OrderEditSheetState extends State<OrderEditSheet> {
children: <Widget>[
Expanded(
child: _buildInlineTimeInput(
label: 'Start',
label: t.client_view_orders.order_edit_sheet.start_label,
value: pos['start_time'],
onTap: () async {
final TimeOfDay? picked = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
);
if (picked != null && context.mounted) {
if (picked != null && mounted) {
_updatePosition(
index,
'start_time',
@@ -951,14 +1129,14 @@ class OrderEditSheetState extends State<OrderEditSheet> {
const SizedBox(width: UiConstants.space2),
Expanded(
child: _buildInlineTimeInput(
label: 'End',
label: t.client_view_orders.order_edit_sheet.end_label,
value: pos['end_time'],
onTap: () async {
final TimeOfDay? picked = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
);
if (picked != null && context.mounted) {
if (picked != null && mounted) {
_updatePosition(
index,
'end_time',
@@ -974,7 +1152,7 @@ class OrderEditSheetState extends State<OrderEditSheet> {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Workers',
t.client_view_orders.order_edit_sheet.workers_label,
style: UiTypography.footnote2r.textSecondary,
),
const SizedBox(height: UiConstants.space1),
@@ -1029,7 +1207,7 @@ class OrderEditSheetState extends State<OrderEditSheet> {
const Icon(UiIcons.mapPin, size: 14, color: UiColors.primary),
const SizedBox(width: UiConstants.space1),
Text(
'Use different location for this position',
t.client_view_orders.order_edit_sheet.different_location,
style: UiTypography.footnote1m.copyWith(
color: UiColors.primary,
),
@@ -1053,7 +1231,7 @@ class OrderEditSheetState extends State<OrderEditSheet> {
),
const SizedBox(width: UiConstants.space1),
Text(
'Different Location',
t.client_view_orders.order_edit_sheet.different_location_title,
style: UiTypography.footnote1m.textSecondary,
),
],
@@ -1071,7 +1249,7 @@ class OrderEditSheetState extends State<OrderEditSheet> {
const SizedBox(height: UiConstants.space2),
UiTextField(
controller: TextEditingController(text: pos['location']),
hintText: 'Enter different address',
hintText: t.client_view_orders.order_edit_sheet.enter_address_hint,
onChanged: (String val) =>
_updatePosition(index, 'location', val),
),
@@ -1082,7 +1260,7 @@ class OrderEditSheetState extends State<OrderEditSheet> {
_buildSectionHeader('LUNCH BREAK'),
_buildDropdownField(
hint: 'No Break',
hint: t.client_view_orders.order_edit_sheet.no_break,
value: pos['lunch_break'],
items: <String>[
'NO_BREAK',
@@ -1105,7 +1283,7 @@ class OrderEditSheetState extends State<OrderEditSheet> {
case 'MIN_60':
return '60 min (Unpaid)';
default:
return 'No Break';
return t.client_view_orders.order_edit_sheet.no_break;
}
},
onChanged: (dynamic val) =>
@@ -1263,11 +1441,11 @@ class OrderEditSheetState extends State<OrderEditSheet> {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
_buildSummaryItem('${_positions.length}', 'Positions'),
_buildSummaryItem('$totalWorkers', 'Workers'),
_buildSummaryItem('${_positions.length}', t.client_view_orders.order_edit_sheet.positions),
_buildSummaryItem('$totalWorkers', t.client_view_orders.order_edit_sheet.workers),
_buildSummaryItem(
'\$${totalCost.round()}',
'Est. Cost',
t.client_view_orders.order_edit_sheet.est_cost,
),
],
),
@@ -1326,7 +1504,7 @@ class OrderEditSheetState extends State<OrderEditSheet> {
const SizedBox(height: 24),
Text(
'Positions Breakdown',
t.client_view_orders.order_edit_sheet.positions_breakdown,
style: UiTypography.body2b.textPrimary,
),
const SizedBox(height: 12),
@@ -1357,14 +1535,14 @@ class OrderEditSheetState extends State<OrderEditSheet> {
children: <Widget>[
Expanded(
child: UiButton.secondary(
text: 'Edit',
text: t.client_view_orders.order_edit_sheet.edit_button,
onPressed: () => setState(() => _showReview = false),
),
),
const SizedBox(width: 12),
Expanded(
child: UiButton.primary(
text: 'Confirm & Save',
text: t.client_view_orders.order_edit_sheet.confirm_save,
onPressed: () async {
setState(() => _isLoading = true);
await _saveOrderChanges();
@@ -1426,7 +1604,7 @@ class OrderEditSheetState extends State<OrderEditSheet> {
children: <Widget>[
Text(
(role?.name ?? pos['roleName']?.toString() ?? '').isEmpty
? 'Position'
? t.client_view_orders.order_edit_sheet.position_singular
: (role?.name ?? pos['roleName']?.toString() ?? ''),
style: UiTypography.body2b.textPrimary,
),
@@ -1492,14 +1670,14 @@ class OrderEditSheetState extends State<OrderEditSheet> {
),
const SizedBox(height: 24),
Text(
'Order Updated!',
t.client_view_orders.order_edit_sheet.order_updated_title,
style: UiTypography.headline1m.copyWith(color: UiColors.white),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Text(
'Your shift has been updated successfully.',
t.client_view_orders.order_edit_sheet.order_updated_message,
textAlign: TextAlign.center,
style: UiTypography.body1r.copyWith(
color: UiColors.white.withValues(alpha: 0.7),
@@ -1510,7 +1688,7 @@ class OrderEditSheetState extends State<OrderEditSheet> {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: UiButton.secondary(
text: 'Back to Orders',
text: t.client_view_orders.order_edit_sheet.back_to_orders,
fullWidth: true,
style: OutlinedButton.styleFrom(
backgroundColor: UiColors.white,

View File

@@ -259,6 +259,31 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
),
],
),
if (order.hubManagerName != null) ...<Widget>[
const SizedBox(height: UiConstants.space2),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const Padding(
padding: EdgeInsets.only(top: 2),
child: Icon(
UiIcons.user,
size: 14,
color: UiColors.iconSecondary,
),
),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Text(
order.hubManagerName!,
style: UiTypography.footnote2r.textSecondary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
],
),
),

View File

@@ -24,15 +24,52 @@ class SettingsActions extends StatelessWidget {
delegate: SliverChildListDelegate(<Widget>[
const SizedBox(height: UiConstants.space5),
// Edit Profile button (Yellow)
UiButton.primary(
text: labels.edit_profile,
fullWidth: true,
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.accent,
foregroundColor: UiColors.accentForeground,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 2),
),
),
onPressed: () => Modular.to.toClientEditProfile(),
),
const SizedBox(height: UiConstants.space4),
// Hubs button (Yellow)
UiButton.primary(
text: labels.hubs,
fullWidth: true,
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.accent,
foregroundColor: UiColors.accentForeground,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 2),
),
),
onPressed: () => Modular.to.toClientHubs(),
),
const SizedBox(height: UiConstants.space5),
// Quick Links card
_QuickLinksCard(labels: labels),
const SizedBox(height: UiConstants.space4),
const SizedBox(height: UiConstants.space5),
// Log Out button (outlined)
BlocBuilder<ClientSettingsBloc, ClientSettingsState>(
builder: (BuildContext context, ClientSettingsState state) {
return UiButton.secondary(
text: labels.log_out,
fullWidth: true,
style: OutlinedButton.styleFrom(
side: const BorderSide(color: UiColors.black),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 2),
),
),
onPressed: state is ClientSettingsLoading
? null
: () => _showSignOutDialog(context),
@@ -113,7 +150,7 @@ class _QuickLinksCard extends StatelessWidget {
onTap: () => Modular.to.toClientHubs(),
),
_QuickLinkItem(
icon: UiIcons.building,
icon: UiIcons.file,
title: labels.billing_payments,
onTap: () => Modular.to.toClientBilling(),
),

View File

@@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../../blocs/client_settings_bloc.dart';
/// A widget that displays the log out button.
@@ -58,7 +59,7 @@ class SettingsLogout extends StatelessWidget {
style: UiTypography.headline3m.textPrimary,
),
content: Text(
'Are you sure you want to log out?',
t.client_settings.profile.log_out_confirmation,
style: UiTypography.body2r.textSecondary,
),
actions: <Widget>[

View File

@@ -31,7 +31,7 @@ class SettingsProfileHeader extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
// ── Top bar: back arrow + title ──────────────────
// ── Top bar: back arrow + centered title ─────────
SafeArea(
bottom: false,
child: Padding(
@@ -39,21 +39,25 @@ class SettingsProfileHeader extends StatelessWidget {
horizontal: UiConstants.space4,
vertical: UiConstants.space2,
),
child: Row(
child: Stack(
alignment: Alignment.center,
children: <Widget>[
GestureDetector(
onTap: () => Modular.to.toClientHome(),
child: const Icon(
UiIcons.arrowLeft,
color: UiColors.white,
size: 22,
Align(
alignment: Alignment.centerLeft,
child: GestureDetector(
onTap: () => Modular.to.toClientHome(),
child: const Icon(
UiIcons.arrowLeft,
color: UiColors.white,
size: 22,
),
),
),
const SizedBox(width: UiConstants.space3),
Text(
labels.title,
style: UiTypography.body1b.copyWith(
color: UiColors.white,
fontSize: 18,
),
),
],

View File

@@ -925,6 +925,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.257.0"
marionette_flutter:
dependency: transitive
description:
name: marionette_flutter
sha256: "0077073f62a8031879a91be41aa91629f741a7f1348b18feacd53443dae3819f"
url: "https://pub.dev"
source: hosted
version: "0.3.0"
matcher:
dependency: transitive
description:

View File

@@ -15,6 +15,7 @@ mutation createOrder(
$shifts: Any
$requested: Int
$teamHubId: UUID!
$hubManagerId: UUID
$recurringDays: [String!]
$permanentStartDate: Timestamp
$permanentDays: [String!]
@@ -40,6 +41,7 @@ mutation createOrder(
shifts: $shifts
requested: $requested
teamHubId: $teamHubId
hubManagerId: $hubManagerId
recurringDays: $recurringDays
permanentDays: $permanentDays
notes: $notes

View File

@@ -47,6 +47,9 @@ type Order @table(name: "orders", key: ["id"]) {
teamHubId: UUID!
teamHub: TeamHub! @ref(fields: "teamHubId", references: "id")
hubManagerId: UUID
hubManager: TeamMember @ref(fields: "hubManagerId", references: "id")
date: Timestamp
startDate: Timestamp #for recurring and permanent

View File

@@ -1,266 +1,365 @@
# KROW Workforce API Contracts
This document captures all API contracts used by the Staff and Client mobile applications. It serves as a single reference document to understand what each endpoint does, its expected inputs, returned outputs, and any non-obvious details.
This document captures all API contracts used by the Staff and Client mobile applications. The application backend is powered by **Firebase Data Connect (GraphQL)**, so traditional REST endpoints do not exist natively. For clarity and ease of reading for all engineering team members, the tables below formulate these GraphQL Data Connect queries and mutations into their **Conceptual REST Endpoints** alongside the actual **Data Connect Operation Name**.
---
## Staff Application
### Authentication / Onboarding Pages (Get Started, Intro, Phone Verification, Profile Setup, Personal Info)
### Authentication / Onboarding Pages
*(Pages: get_started_page.dart, intro_page.dart, phone_verification_page.dart, profile_setup_page.dart)*
#### Setup / User Validation API
| Field | Description |
|---|---|
| **Endpoint name** | `/getUserById` |
| **Purpose** | Retrieves the base user profile to determine authentication status and role access (e.g., if user is STAFF). |
| **Conceptual Endpoint** | `GET /users/{id}` |
| **Data Connect OP** | `getUserById` |
| **Purpose** | Retrieves the base user profile to determine authentication status and role access (e.g., if the user is STAFF). |
| **Operation** | Query |
| **Inputs** | `id: UUID!` (Firebase UID) |
| **Outputs** | `User { id, email, phone, role }` |
| **Notes** | Required after OTP verification to route users. |
| **Notes** | Required after OTP verification to route users appropriately. |
#### Create Default User API
| Field | Description |
|---|---|
| **Endpoint name** | `/createUser` |
| **Conceptual Endpoint** | `POST /users` |
| **Data Connect OP** | `createUser` |
| **Purpose** | Inserts a base user record into the system during initial signup. |
| **Operation** | Mutation |
| **Inputs** | `id: UUID!`, `role: UserBaseRole` |
| **Outputs** | `id` of newly created User |
| **Notes** | Used explicitly during the "Sign Up" flow if the user doesn't exist. |
| **Notes** | Used explicitly during the "Sign Up" flow if the user doesn't physically exist in the database. |
#### Get Staff Profile API
| Field | Description |
|---|---|
| **Endpoint name** | `/getStaffByUserId` |
| **Conceptual Endpoint** | `GET /staff/user/{userId}` |
| **Data Connect OP** | `getStaffByUserId` |
| **Purpose** | Finds the specific Staff record associated with the base user ID. |
| **Operation** | Query |
| **Inputs** | `userId: UUID!` |
| **Outputs** | `Staffs { id, userId, fullName, email, phone, photoUrl, status }` |
| **Notes** | Needed to verify if a complete staff profile exists before fully authenticating. |
| **Notes** | Needed to verify if a complete staff profile exists before allowing navigation to the main app dashboard. |
#### Update Staff Profile API
| Field | Description |
|---|---|
| **Endpoint name** | `/updateStaff` |
| **Conceptual Endpoint** | `PUT /staff/{id}` |
| **Data Connect OP** | `updateStaff` |
| **Purpose** | Saves onboarding data across Personal Info, Experience, and Preferred Locations pages. |
| **Operation** | Mutation |
| **Inputs** | `id: UUID!`, `fullName`, `email`, `phone`, `addres`, etc. |
| **Inputs** | `id: UUID!`, `fullName`, `email`, `phone`, `address`, etc. |
| **Outputs** | `id` |
| **Notes** | Called incrementally during profile setup wizard. |
| **Notes** | Called incrementally during the profile setup wizard as the user fills out step-by-step information. |
### Home Page & Benefits Overview
*(Pages: worker_home_page.dart, benefits_overview_page.dart)*
### Home Page (worker_home_page.dart) & Benefits Overview
#### Load Today/Tomorrow Shifts
| Field | Description |
|---|---|
| **Endpoint name** | `/getApplicationsByStaffId` |
| **Conceptual Endpoint** | `GET /staff/{staffId}/applications` |
| **Data Connect OP** | `getApplicationsByStaffId` |
| **Purpose** | Retrieves applications (shifts) assigned to the current staff member within a specific date range. |
| **Operation** | Query |
| **Inputs** | `staffId: UUID!`, `dayStart: Timestamp`, `dayEnd: Timestamp` |
| **Outputs** | `Applications { shift, shiftRole, status, createdAt }` |
| **Notes** | The frontend filters the query response for `CONFIRMED` applications to display "Today's" and "Tomorrow's" shifts. |
| **Notes** | The frontend filters the query response for `CONFIRMED` applications to successfully display "Today's" and "Tomorrow's" shifts. |
#### List Recommended Shifts
| Field | Description |
|---|---|
| **Endpoint name** | `/listShifts` |
| **Conceptual Endpoint** | `GET /shifts/recommended` |
| **Data Connect OP** | `listShifts` |
| **Purpose** | Fetches open shifts that are available for the staff to apply to. |
| **Operation** | Query |
| **Inputs** | None directly mapped, but filters OPEN shifts purely on the client side at the time. |
| **Inputs** | None directly mapped on load, but fetches available items logically. |
| **Outputs** | `Shifts { id, title, orderId, cost, location, startTime, endTime, status }` |
| **Notes** | Limits output to 10 on the frontend. Should ideally rely on a `$status: OPEN` parameter. |
| **Notes** | Limits output to 10 on the frontend. Should ideally rely on an active backend `$status: OPEN` parameter. |
#### Benefits Summary API
| Field | Description |
|---|---|
| **Endpoint name** | `/listBenefitsDataByStaffId` |
| **Purpose** | Retrieves accrued benefits (e.g., Sick time, Vacation) to display on the home screen. |
| **Conceptual Endpoint** | `GET /staff/{staffId}/benefits` |
| **Data Connect OP** | `listBenefitsDataByStaffId` |
| **Purpose** | Retrieves accrued benefits (e.g., Sick time, Vacation) to display gracefully on the home screen. |
| **Operation** | Query |
| **Inputs** | `staffId: UUID!` |
| **Outputs** | `BenefitsDatas { vendorBenefitPlan { title, total }, current }` |
| **Notes** | Calculates `usedHours = total - current`. |
| **Notes** | Used by `benefits_overview_page.dart`. Derives available metrics via `usedHours = total - current`. |
### Find Shifts / Shift Details Pages
*(Pages: shifts_page.dart, shift_details_page.dart)*
### Find Shifts / Shift Details Pages (shifts_page.dart)
#### List Available Shifts Filtered
| Field | Description |
|---|---|
| **Endpoint name** | `/filterShifts` |
| **Conceptual Endpoint** | `GET /shifts` |
| **Data Connect OP** | `filterShifts` |
| **Purpose** | Used to fetch Open Shifts in specific regions when the worker searches in the "Find Shifts" tab. |
| **Operation** | Query |
| **Inputs** | `$status: ShiftStatus`, `$dateFrom: Timestamp`, `$dateTo: Timestamp` |
| **Outputs** | `Shifts { id, title, location, cost, durationDays, order { business, vendor } }` |
| **Notes** | - |
| **Notes** | Main driver for discovering available work. |
#### Get Shift Details
| Field | Description |
|---|---|
| **Endpoint name** | `/getShiftById` |
| **Purpose** | Gets deeper details for a single shift including exact uniform/managers needed. |
| **Conceptual Endpoint** | `GET /shifts/{id}` |
| **Data Connect OP** | `getShiftById` |
| **Purpose** | Gets deeper details for a single shift including exact uniform requirements and managers. |
| **Operation** | Query |
| **Inputs** | `id: UUID!` |
| **Outputs** | `Shift { id, title, hours, cost, locationAddress, workersNeeded ... }` |
| **Notes** | - |
| **Notes** | Invoked when users click into a full `shift_details_page.dart`. |
#### Apply To Shift
| Field | Description |
|---|---|
| **Endpoint name** | `/createApplication` |
| **Purpose** | Worker submits an intent to take an open shift. |
| **Conceptual Endpoint** | `POST /applications` |
| **Data Connect OP** | `createApplication` |
| **Purpose** | Worker submits an intent to take an open shift (creates an application record). |
| **Operation** | Mutation |
| **Inputs** | `shiftId`, `staffId`, `status: APPLIED` |
| **Outputs** | `Application ID` |
| **Notes** | A shift status will switch to `CONFIRMED` via admin approval. |
| **Inputs** | `shiftId: UUID!`, `staffId: UUID!`, `roleId: UUID!`, `status: ApplicationStatus!` (e.g. `PENDING` or `CONFIRMED`), `origin: ApplicationOrigin!` (e.g. `STAFF`); optional: `checkInTime`, `checkOutTime` |
| **Outputs** | `application_insert.id` (Application ID) |
| **Notes** | The app uses `status: CONFIRMED` and `origin: STAFF` when claiming; backend also supports `PENDING` for admin review flows. After creation, shift-role assigned count and shift filled count are updated. |
### Availability Page
*(Pages: availability_page.dart)*
### Availability Page (availability_page.dart)
#### Get Default Availability
| Field | Description |
|---|---|
| **Endpoint name** | `/listStaffAvailabilitiesByStaffId` |
| **Conceptual Endpoint** | `GET /staff/{staffId}/availabilities` |
| **Data Connect OP** | `listStaffAvailabilitiesByStaffId` |
| **Purpose** | Fetches the standard Mon-Sun recurring availability for a staff member. |
| **Operation** | Query |
| **Inputs** | `staffId: UUID!` |
| **Outputs** | `StaffAvailabilities { dayOfWeek, isAvailable, startTime, endTime }` |
| **Notes** | - |
| **Notes** | Bound to Monday through Sunday configuration. |
#### Update Availability
| Field | Description |
|---|---|
| **Endpoint name** | `/updateStaffAvailability` (or `createStaffAvailability`) |
| **Conceptual Endpoint** | `PUT /staff/availabilities/{id}` |
| **Data Connect OP** | `updateStaffAvailability` (or `createStaffAvailability` for new entries) |
| **Purpose** | Upserts availability preferences. |
| **Operation** | Mutation |
| **Inputs** | `staffId`, `dayOfWeek`, `isAvailable`, `startTime`, `endTime` |
| **Outputs** | `id` |
| **Notes** | Called individually per day edited. |
### Payments Page (payments_page.dart)
### Payments Page
*(Pages: payments_page.dart, early_pay_page.dart)*
#### Get Recent Payments
| Field | Description |
|---|---|
| **Endpoint name** | `/listRecentPaymentsByStaffId` |
| **Conceptual Endpoint** | `GET /staff/{staffId}/payments` |
| **Data Connect OP** | `listRecentPaymentsByStaffId` |
| **Purpose** | Loads the history of earnings and timesheets completed by the staff. |
| **Operation** | Query |
| **Inputs** | `staffId: UUID!` |
| **Outputs** | `Payments { amount, processDate, shiftId, status }` |
| **Notes** | Displays historical metrics under Earnings tab. |
| **Notes** | Displays historical metrics under the comprehensive Earnings tab. |
### Compliance / Profiles
*(Pages: certificates_page.dart, documents_page.dart, tax_forms_page.dart, form_i9_page.dart, form_w4_page.dart)*
### Compliance / Profiles (Agreements, W4, I9, Documents)
#### Get Tax Forms
| Field | Description |
|---|---|
| **Endpoint name** | `/getTaxFormsByStaffId` |
| **Purpose** | Check the filing status of I9 and W4 forms. |
| **Conceptual Endpoint** | `GET /staff/{staffId}/tax-forms` |
| **Data Connect OP** | `getTaxFormsByStaffId` |
| **Purpose** | Check the filing status and detailed inputs of I9 and W4 forms. |
| **Operation** | Query |
| **Inputs** | `staffId: UUID!` |
| **Outputs** | `TaxForms { formType, isCompleted, updatedDate }` |
| **Notes** | Required for staff to be eligible for shifts. |
| **Notes** | Crucial requirement for staff to be eligible to apply for highly regulated shifts. |
#### Update Tax Forms
| Field | Description |
|---|---|
| **Endpoint name** | `/updateTaxForm` |
| **Purpose** | Submits state and filing for the given tax form type. |
| **Conceptual Endpoint** | `PUT /tax-forms/{id}` |
| **Data Connect OP** | `updateTaxForm` |
| **Purpose** | Submits state and filing for the given tax form type (W4/I9). |
| **Operation** | Mutation |
| **Inputs** | `id`, `dataPoints...` |
| **Outputs** | `id` |
| **Notes** | Updates compliance state. |
| **Notes** | Modifies the core compliance state variables directly. |
---
## Client Application
### Authentication / Intro (Sign In, Get Started)
### Authentication / Intro
*(Pages: client_sign_in_page.dart, client_get_started_page.dart)*
#### Client User Validation API
| Field | Description |
|---|---|
| **Endpoint name** | `/getUserById` |
| **Purpose** | Retrieves the base user profile to determine authentication status and role access (e.g., if user is BUSINESS). |
| **Conceptual Endpoint** | `GET /users/{id}` |
| **Data Connect OP** | `getUserById` |
| **Purpose** | Retrieves the base user profile to determine authentication status and role access (ensuring user is BUSINESS). |
| **Operation** | Query |
| **Inputs** | `id: UUID!` (Firebase UID) |
| **Outputs** | `User { id, email, phone, userRole }` |
| **Notes** | Must check if `userRole == BUSINESS` or `BOTH`. |
| **Notes** | Validates against conditional statements checking `userRole == BUSINESS` or `BOTH`. |
#### Get Business Profile API
#### Get Businesses By User API
| Field | Description |
|---|---|
| **Endpoint name** | `/getBusinessByUserId` |
| **Conceptual Endpoint** | `GET /business/user/{userId}` |
| **Data Connect OP** | `getBusinessesByUserId` |
| **Purpose** | Maps the authenticated user to their client business context. |
| **Operation** | Query |
| **Inputs** | `userId: UUID!` |
| **Outputs** | `Business { id, businessName, email, contactName }` |
| **Notes** | Used to set the working scopes (Business ID) across the entire app. |
| **Inputs** | `userId: String!` |
| **Outputs** | `Businesses { id, businessName, email, contactName }` |
| **Notes** | Dictates the working scopes (Business ID) across the entire application lifecycle and binds the user. |
### Hubs Page (client_hubs_page.dart, edit_hub.dart)
#### List Hubs
### Hubs Page
*(Pages: client_hubs_page.dart, edit_hub_page.dart, hub_details_page.dart)*
#### List Hubs by Team
| Field | Description |
|---|---|
| **Endpoint name** | `/listTeamHubsByBusinessId` |
| **Purpose** | Fetches the primary working sites (Hubs) for a client. |
| **Conceptual Endpoint** | `GET /teams/{teamId}/hubs` |
| **Data Connect OP** | `getTeamHubsByTeamId` |
| **Purpose** | Fetches the primary working sites (Hubs) for a client context by using Team mapping. |
| **Operation** | Query |
| **Inputs** | `businessId: UUID!` |
| **Outputs** | `TeamHubs { id, hubName, address, contact, active }` |
| **Notes** | - |
| **Inputs** | `teamId: UUID!` |
| **Outputs** | `TeamHubs { id, hubName, address, managerName, isActive }` |
| **Notes** | `teamId` is derived first from `getTeamsByOwnerId(ownerId: businessId)`. |
#### Update / Delete Hub
#### Create / Update / Delete Hub
| Field | Description |
|---|---|
| **Endpoint name** | `/updateTeamHub` / `/deleteTeamHub` |
| **Purpose** | Edits or archives a Hub location. |
| **Conceptual Endpoint** | `POST /team-hubs` / `PUT /team-hubs/{id}` / `DELETE /team-hubs/{id}` |
| **Data Connect OP** | `createTeamHub` / `updateTeamHub` / `deleteTeamHub` |
| **Purpose** | Provisions, Edits details directly, or Removes a Team Hub location. |
| **Operation** | Mutation |
| **Inputs** | `id: UUID!`, `hubName`, `address`, etc (for Update) |
| **Inputs** | `id: UUID!`, optionally `hubName`, `address`, etc. |
| **Outputs** | `id` |
| **Notes** | - |
| **Notes** | Fired from `edit_hub_page.dart` mutations. |
### Orders Page
*(Pages: create_order_page.dart, view_orders_page.dart, recurring_order_page.dart)*
### Orders Page (create_order, view_orders)
#### Create Order
| Field | Description |
|---|---|
| **Endpoint name** | `/createOrder` |
| **Purpose** | The client submits a new request for temporary staff (can result in multiple Shifts generated on the backend). |
| **Conceptual Endpoint** | `POST /orders` |
| **Data Connect OP** | `createOrder` |
| **Purpose** | Submits a new request for temporary staff requirements. |
| **Operation** | Mutation |
| **Inputs** | `businessId`, `eventName`, `orderType`, `status` |
| **Outputs** | `id` (Order ID) |
| **Notes** | This creates an order. Shift instances are subsequently created through secondary mutations. |
| **Notes** | This explicitly invokes an order pipeline, meaning Shift instances are subsequently created through secondary mutations triggered after order instantiation. |
#### List Orders
| Field | Description |
|---|---|
| **Endpoint name** | `/getOrdersByBusinessId` |
| **Conceptual Endpoint** | `GET /business/{businessId}/orders` |
| **Data Connect OP** | `listOrdersByBusinessId` |
| **Purpose** | Retrieves all ongoing and past staff requests from the client. |
| **Operation** | Query |
| **Inputs** | `businessId: UUID!` |
| **Outputs** | `Orders { id, eventName, shiftCount, status }` |
| **Notes** | - |
| **Outputs** | `Orders { id, eventName }` |
| **Notes** | Populates the `view_orders_page.dart`. |
### Billing Pages
*(Pages: billing_page.dart, pending_invoices_page.dart, completion_review_page.dart)*
### Billing Pages (billing_page.dart, pending_invoices)
#### List Invoices
| Field | Description |
|---|---|
| **Endpoint name** | `/listInvoicesByBusinessId` |
| **Purpose** | Fetches "Pending", "Paid", and "Disputed" invoices for the client to review. |
| **Conceptual Endpoint** | `GET /business/{businessId}/invoices` |
| **Data Connect OP** | `listInvoicesByBusinessId` |
| **Purpose** | Fetches all invoices bound directly to the active business context (mapped directly in Firebase Schema). |
| **Operation** | Query |
| **Inputs** | `businessId: UUID!` |
| **Outputs** | `Invoices { id, amountDue, issueDate, status }` |
| **Notes** | Used across all Billing view tabs. |
| **Outputs** | `Invoices { id, amount, issueDate, status }` |
| **Notes** | Used massively across all Billing view tabs. |
#### Mark Invoice
#### Mark / Dispute Invoice
| Field | Description |
|---|---|
| **Endpoint name** | `/updateInvoice` |
| **Purpose** | Marks an invoice as disputed or pays it (changes status). |
| **Conceptual Endpoint** | `PUT /invoices/{id}` |
| **Data Connect OP** | `updateInvoice` |
| **Purpose** | Actively marks an invoice as disputed or pays it directly (altering status). |
| **Operation** | Mutation |
| **Inputs** | `id: UUID!`, `status: InvoiceStatus` |
| **Outputs** | `id` |
| **Notes** | Disputing usually involves setting a memo or flag. |
| **Notes** | Disputing usually involves setting a `disputeReason` flag state dynamically via builder pattern. |
### Reports Page
*(Pages: reports_page.dart, coverage_report_page.dart, performance_report_page.dart)*
### Reports Page (reports_page.dart)
#### Get Coverage Stats
| Field | Description |
|---|---|
| **Endpoint name** | `/getCoverageStatsByBusiness` |
| **Purpose** | Provides data on fulfillments rates vs actual requests. |
| **Conceptual Endpoint** | `GET /business/{businessId}/coverage` |
| **Data Connect OP** | `listShiftsForCoverage` |
| **Purpose** | Provides data on Shifts grouped by Date for fulfillment calculations. |
| **Operation** | Query |
| **Inputs** | `businessId: UUID!` |
| **Outputs** | `Stats { totalRequested, totalFilled, percentage }` |
| **Notes** | Driven mostly by aggregated backend views. |
| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` |
| **Outputs** | `Shifts { id, date, workersNeeded, filled, status }` |
| **Notes** | The frontend aggregates the raw backend rows to compose Coverage percentage natively. |
#### Get Daily Ops Stats
| Field | Description |
|---|---|
| **Conceptual Endpoint** | `GET /business/{businessId}/dailyops` |
| **Data Connect OP** | `listShiftsForDailyOpsByBusiness` |
| **Purpose** | Supplies current day operations and shift tracking progress. |
| **Operation** | Query |
| **Inputs** | `businessId: UUID!`, `date: Timestamp!` |
| **Outputs** | `Shifts { id, title, location, workersNeeded, filled }` |
| **Notes** | - |
#### Get Forecast Stats
| Field | Description |
|---|---|
| **Conceptual Endpoint** | `GET /business/{businessId}/forecast` |
| **Data Connect OP** | `listShiftsForForecastByBusiness` |
| **Purpose** | Retrieves scheduled future shifts to calculate financial run-rates. |
| **Operation** | Query |
| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` |
| **Outputs** | `Shifts { id, date, workersNeeded, hours, cost }` |
| **Notes** | The App maps hours `x` cost to deliver Financial Dashboards. |
#### Get Performance KPIs
| Field | Description |
|---|---|
| **Conceptual Endpoint** | `GET /business/{businessId}/performance` |
| **Data Connect OP** | `listShiftsForPerformanceByBusiness` |
| **Purpose** | Fetches historical data allowing time-to-fill and completion-rate calculations. |
| **Operation** | Query |
| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` |
| **Outputs** | `Shifts { id, workersNeeded, filled, createdAt, filledAt }` |
| **Notes** | Data Connect exposes timestamps so the App calculates `avgFillTimeHours`. |
#### Get No-Show Metrics
| Field | Description |
|---|---|
| **Conceptual Endpoint** | `GET /business/{businessId}/noshows` |
| **Data Connect OP** | `listShiftsForNoShowRangeByBusiness` |
| **Purpose** | Retrieves shifts where workers historically ghosted the platform. |
| **Operation** | Query |
| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` |
| **Outputs** | `Shifts { id, date }` |
| **Notes** | Accompanies `listApplicationsForNoShowRange` cascading querying to generate full report. |
#### Get Spend Analytics
| Field | Description |
|---|---|
| **Conceptual Endpoint** | `GET /business/{businessId}/spend` |
| **Data Connect OP** | `listInvoicesForSpendByBusiness` |
| **Purpose** | Detailed invoice aggregates for Spend metrics filtering. |
| **Operation** | Query |
| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` |
| **Outputs** | `Invoices { id, issueDate, dueDate, amount, status }` |
| **Notes** | Used explicitly under the "Spend Report" graphings. |
---
*This document reflects the current state of Data Connect definitions implemented across the frontend and mapped manually by reviewing Repository and UI logic.*

266
docs/api-contracts.md Normal file
View File

@@ -0,0 +1,266 @@
# KROW Workforce API Contracts
This document captures all API contracts used by the Staff and Client mobile applications. It serves as a single reference document to understand what each endpoint does, its expected inputs, returned outputs, and any non-obvious details.
---
## Staff Application
### Authentication / Onboarding Pages (Get Started, Intro, Phone Verification, Profile Setup, Personal Info)
#### Setup / User Validation API
| Field | Description |
|---|---|
| **Endpoint name** | `/getUserById` |
| **Purpose** | Retrieves the base user profile to determine authentication status and role access (e.g., if user is STAFF). |
| **Operation** | Query |
| **Inputs** | `id: UUID!` (Firebase UID) |
| **Outputs** | `User { id, email, phone, role }` |
| **Notes** | Required after OTP verification to route users. |
#### Create Default User API
| Field | Description |
|---|---|
| **Endpoint name** | `/createUser` |
| **Purpose** | Inserts a base user record into the system during initial signup. |
| **Operation** | Mutation |
| **Inputs** | `id: UUID!`, `role: UserBaseRole` |
| **Outputs** | `id` of newly created User |
| **Notes** | Used explicitly during the "Sign Up" flow if the user doesn't exist. |
#### Get Staff Profile API
| Field | Description |
|---|---|
| **Endpoint name** | `/getStaffByUserId` |
| **Purpose** | Finds the specific Staff record associated with the base user ID. |
| **Operation** | Query |
| **Inputs** | `userId: UUID!` |
| **Outputs** | `Staffs { id, userId, fullName, email, phone, photoUrl, status }` |
| **Notes** | Needed to verify if a complete staff profile exists before fully authenticating. |
#### Update Staff Profile API
| Field | Description |
|---|---|
| **Endpoint name** | `/updateStaff` |
| **Purpose** | Saves onboarding data across Personal Info, Experience, and Preferred Locations pages. |
| **Operation** | Mutation |
| **Inputs** | `id: UUID!`, `fullName`, `email`, `phone`, `addres`, etc. |
| **Outputs** | `id` |
| **Notes** | Called incrementally during profile setup wizard. |
### Home Page (worker_home_page.dart) & Benefits Overview
#### Load Today/Tomorrow Shifts
| Field | Description |
|---|---|
| **Endpoint name** | `/getApplicationsByStaffId` |
| **Purpose** | Retrieves applications (shifts) assigned to the current staff member within a specific date range. |
| **Operation** | Query |
| **Inputs** | `staffId: UUID!`, `dayStart: Timestamp`, `dayEnd: Timestamp` |
| **Outputs** | `Applications { shift, shiftRole, status, createdAt }` |
| **Notes** | The frontend filters the query response for `CONFIRMED` applications to display "Today's" and "Tomorrow's" shifts. |
#### List Recommended Shifts
| Field | Description |
|---|---|
| **Endpoint name** | `/listShifts` |
| **Purpose** | Fetches open shifts that are available for the staff to apply to. |
| **Operation** | Query |
| **Inputs** | None directly mapped, but filters OPEN shifts purely on the client side at the time. |
| **Outputs** | `Shifts { id, title, orderId, cost, location, startTime, endTime, status }` |
| **Notes** | Limits output to 10 on the frontend. Should ideally rely on a `$status: OPEN` parameter. |
#### Benefits Summary API
| Field | Description |
|---|---|
| **Endpoint name** | `/listBenefitsDataByStaffId` |
| **Purpose** | Retrieves accrued benefits (e.g., Sick time, Vacation) to display on the home screen. |
| **Operation** | Query |
| **Inputs** | `staffId: UUID!` |
| **Outputs** | `BenefitsDatas { vendorBenefitPlan { title, total }, current }` |
| **Notes** | Calculates `usedHours = total - current`. |
### Find Shifts / Shift Details Pages (shifts_page.dart)
#### List Available Shifts Filtered
| Field | Description |
|---|---|
| **Endpoint name** | `/filterShifts` |
| **Purpose** | Used to fetch Open Shifts in specific regions when the worker searches in the "Find Shifts" tab. |
| **Operation** | Query |
| **Inputs** | `$status: ShiftStatus`, `$dateFrom: Timestamp`, `$dateTo: Timestamp` |
| **Outputs** | `Shifts { id, title, location, cost, durationDays, order { business, vendor } }` |
| **Notes** | - |
#### Get Shift Details
| Field | Description |
|---|---|
| **Endpoint name** | `/getShiftById` |
| **Purpose** | Gets deeper details for a single shift including exact uniform/managers needed. |
| **Operation** | Query |
| **Inputs** | `id: UUID!` |
| **Outputs** | `Shift { id, title, hours, cost, locationAddress, workersNeeded ... }` |
| **Notes** | - |
#### Apply To Shift
| Field | Description |
|---|---|
| **Endpoint name** | `/createApplication` |
| **Purpose** | Worker submits an intent to take an open shift. |
| **Operation** | Mutation |
| **Inputs** | `shiftId`, `staffId`, `status: APPLIED` |
| **Outputs** | `Application ID` |
| **Notes** | A shift status will switch to `CONFIRMED` via admin approval. |
### Availability Page (availability_page.dart)
#### Get Default Availability
| Field | Description |
|---|---|
| **Endpoint name** | `/listStaffAvailabilitiesByStaffId` |
| **Purpose** | Fetches the standard Mon-Sun recurring availability for a staff member. |
| **Operation** | Query |
| **Inputs** | `staffId: UUID!` |
| **Outputs** | `StaffAvailabilities { dayOfWeek, isAvailable, startTime, endTime }` |
| **Notes** | - |
#### Update Availability
| Field | Description |
|---|---|
| **Endpoint name** | `/updateStaffAvailability` (or `createStaffAvailability`) |
| **Purpose** | Upserts availability preferences. |
| **Operation** | Mutation |
| **Inputs** | `staffId`, `dayOfWeek`, `isAvailable`, `startTime`, `endTime` |
| **Outputs** | `id` |
| **Notes** | Called individually per day edited. |
### Payments Page (payments_page.dart)
#### Get Recent Payments
| Field | Description |
|---|---|
| **Endpoint name** | `/listRecentPaymentsByStaffId` |
| **Purpose** | Loads the history of earnings and timesheets completed by the staff. |
| **Operation** | Query |
| **Inputs** | `staffId: UUID!` |
| **Outputs** | `Payments { amount, processDate, shiftId, status }` |
| **Notes** | Displays historical metrics under Earnings tab. |
### Compliance / Profiles (Agreements, W4, I9, Documents)
#### Get Tax Forms
| Field | Description |
|---|---|
| **Endpoint name** | `/getTaxFormsByStaffId` |
| **Purpose** | Check the filing status of I9 and W4 forms. |
| **Operation** | Query |
| **Inputs** | `staffId: UUID!` |
| **Outputs** | `TaxForms { formType, isCompleted, updatedDate }` |
| **Notes** | Required for staff to be eligible for shifts. |
#### Update Tax Forms
| Field | Description |
|---|---|
| **Endpoint name** | `/updateTaxForm` |
| **Purpose** | Submits state and filing for the given tax form type. |
| **Operation** | Mutation |
| **Inputs** | `id`, `dataPoints...` |
| **Outputs** | `id` |
| **Notes** | Updates compliance state. |
---
## Client Application
### Authentication / Intro (Sign In, Get Started)
#### Client User Validation API
| Field | Description |
|---|---|
| **Endpoint name** | `/getUserById` |
| **Purpose** | Retrieves the base user profile to determine authentication status and role access (e.g., if user is BUSINESS). |
| **Operation** | Query |
| **Inputs** | `id: UUID!` (Firebase UID) |
| **Outputs** | `User { id, email, phone, userRole }` |
| **Notes** | Must check if `userRole == BUSINESS` or `BOTH`. |
#### Get Business Profile API
| Field | Description |
|---|---|
| **Endpoint name** | `/getBusinessByUserId` |
| **Purpose** | Maps the authenticated user to their client business context. |
| **Operation** | Query |
| **Inputs** | `userId: UUID!` |
| **Outputs** | `Business { id, businessName, email, contactName }` |
| **Notes** | Used to set the working scopes (Business ID) across the entire app. |
### Hubs Page (client_hubs_page.dart, edit_hub.dart)
#### List Hubs
| Field | Description |
|---|---|
| **Endpoint name** | `/listTeamHubsByBusinessId` |
| **Purpose** | Fetches the primary working sites (Hubs) for a client. |
| **Operation** | Query |
| **Inputs** | `businessId: UUID!` |
| **Outputs** | `TeamHubs { id, hubName, address, contact, active }` |
| **Notes** | - |
#### Update / Delete Hub
| Field | Description |
|---|---|
| **Endpoint name** | `/updateTeamHub` / `/deleteTeamHub` |
| **Purpose** | Edits or archives a Hub location. |
| **Operation** | Mutation |
| **Inputs** | `id: UUID!`, `hubName`, `address`, etc (for Update) |
| **Outputs** | `id` |
| **Notes** | - |
### Orders Page (create_order, view_orders)
#### Create Order
| Field | Description |
|---|---|
| **Endpoint name** | `/createOrder` |
| **Purpose** | The client submits a new request for temporary staff (can result in multiple Shifts generated on the backend). |
| **Operation** | Mutation |
| **Inputs** | `businessId`, `eventName`, `orderType`, `status` |
| **Outputs** | `id` (Order ID) |
| **Notes** | This creates an order. Shift instances are subsequently created through secondary mutations. |
#### List Orders
| Field | Description |
|---|---|
| **Endpoint name** | `/getOrdersByBusinessId` |
| **Purpose** | Retrieves all ongoing and past staff requests from the client. |
| **Operation** | Query |
| **Inputs** | `businessId: UUID!` |
| **Outputs** | `Orders { id, eventName, shiftCount, status }` |
| **Notes** | - |
### Billing Pages (billing_page.dart, pending_invoices)
#### List Invoices
| Field | Description |
|---|---|
| **Endpoint name** | `/listInvoicesByBusinessId` |
| **Purpose** | Fetches "Pending", "Paid", and "Disputed" invoices for the client to review. |
| **Operation** | Query |
| **Inputs** | `businessId: UUID!` |
| **Outputs** | `Invoices { id, amountDue, issueDate, status }` |
| **Notes** | Used across all Billing view tabs. |
#### Mark Invoice
| Field | Description |
|---|---|
| **Endpoint name** | `/updateInvoice` |
| **Purpose** | Marks an invoice as disputed or pays it (changes status). |
| **Operation** | Mutation |
| **Inputs** | `id: UUID!`, `status: InvoiceStatus` |
| **Outputs** | `id` |
| **Notes** | Disputing usually involves setting a memo or flag. |
### Reports Page (reports_page.dart)
#### Get Coverage Stats
| Field | Description |
|---|---|
| **Endpoint name** | `/getCoverageStatsByBusiness` |
| **Purpose** | Provides data on fulfillments rates vs actual requests. |
| **Operation** | Query |
| **Inputs** | `businessId: UUID!` |
| **Outputs** | `Stats { totalRequested, totalFilled, percentage }` |
| **Notes** | Driven mostly by aggregated backend views. |
---
*This document reflects the current state of Data Connect definitions implemented across the frontend and mapped manually by reviewing Repository and UI logic.*

BIN
docs/available_gql.txt Normal file

Binary file not shown.

View File

@@ -0,0 +1,87 @@
# 📱 Research: Flutter Integration Testing Evaluation
**Issue:** #533
**Focus:** Maestro vs. Marionette MCP (LeanCode)
**Status:** ✅ Completed
**Target Apps:** `KROW Client App` & `KROW Staff App`
---
## 1. Executive Summary & Recommendation
Following a technical spike implementing full authentication flows (Login/Signup) for both KROW platforms, **Maestro is the recommended integration testing framework.**
While **Marionette MCP** offers an innovative LLM-driven approach for exploratory debugging, it lacks the determinism required for a production-grade CI/CD pipeline. Maestro provides the stability, speed, and native OS interaction necessary to gate our releases effectively.
### Why Maestro Wins for KROW:
* **Zero-Flake Execution:** Built-in wait logic handles Firebase Auth latency without hard-coded `sleep()` calls.
* **Platform Parity:** Single `.yaml` definitions drive both iOS and Android build variants.
* **Non-Invasive:** Maestro tests the compiled `.apk` or `.app` (Black-box), ensuring we test exactly what the user sees.
* **System Level Access:** Handles native OS permission dialogs (Camera/Location/Notifications) which Marionette cannot "see."
---
## 2. Technical Evaluation Matrix
| Criteria | Maestro | Marionette MCP | Winner |
| :--- | :--- | :--- | :--- |
| **Test Authoring** | **High Speed:** Declarative YAML; Maestro Studio recorder. | **Variable:** Requires precise Prompt Engineering. | **Maestro** |
| **Execution Latency** | **Low:** Instantaneous interaction (~5s flows). | **High:** LLM API roundtrips (~45s+ flows). | **Maestro** |
| **Environment** | Works on Release/Production builds. | Restricted to Debug/Profile modes. | **Maestro** |
| **CI/CD Readiness** | Native CLI; easy GitHub Actions integration. | High overhead; depends on external AI APIs. | **Maestro** |
| **Context Awareness** | Interacts with Native OS & Bottom Sheets. | Limited to the Flutter Widget Tree. | **Maestro** |
---
## 3. Spike Analysis & Findings
### Tool A: Maestro (The Standard)
We verified the `login.yaml` and `signup.yaml` flows across both apps. Maestro successfully abstracted the asynchronous nature of our **Data Connect** and **Firebase** backends.
* **Pros:** * **Semantics Driven:** By targeting `Semantics(identifier: '...')` in our `/design_system/`, tests remain stable even if the UI text changes for localization.
* **Automatic Tolerance:** It detects spinning loaders and waits for destination widgets automatically.
* **Cons:** * Requires strict adherence to adding `Semantics` wrappers on all interactive components.
### Tool B: Marionette MCP (The Experiment)
We spiked this using the `marionette_flutter` binding and executing via **Cursor/Claude**.
* **Pros:** * Phenomenal for visual "smoke testing" and live-debugging UI issues via natural language.
* **Cons:** * **Non-Deterministic:** Prone to "hallucinations" during heavy network traffic.
* **Architecture Blocker:** Requires the Dart VM Service to be active, making it impossible to test against hardened production builds.
---
## 4. Implementation & Migration Blueprint
### Phase 1: Semantics Enforcement
We must enforce a linting rule or PR checklist: All interactive widgets in `@krow/design_system` must include a unique `identifier`.
```dart
// Standardized Implementation
Semantics(
identifier: 'login_submit_button',
child: KrowPrimaryButton(
onPressed: _handleLogin,
label: 'Sign In',
),
)
```
### Phase 2: Repository Structure (Implemented)
Maestro flows are co-located with each app:
* `apps/mobile/apps/client/maestro/login.yaml` — Client login
* `apps/mobile/apps/client/maestro/signup.yaml` — Client signup
* `apps/mobile/apps/staff/maestro/login.yaml` — Staff login (phone + OTP)
* `apps/mobile/apps/staff/maestro/signup.yaml` — Staff signup (phone + OTP)
Each directory has a README with run instructions.
**Marionette MCP:** `marionette_flutter` is added to both apps; `MarionetteBinding` is initialized in debug mode. See [marionette-spike-usage.md](marionette-spike-usage.md) for prompts and workflow.
### Phase 3: CI/CD Integration
The Maestro CLI will be added to our **GitHub Actions** workflow to automate quality gates.
* **Trigger:** Every PR targeting `main` or `develop`.
* **Action:** Generate a build, execute `maestro test`, and block merge on failure.

View File

@@ -0,0 +1,84 @@
# How to Run Maestro Integration Tests
## Credentials
| Flow | Credentials |
|------|-------------|
| **Client login** | legendary@krowd.com / Demo2026! |
| **Staff login** | 5557654321 / OTP 123456 |
| **Client signup** | Env vars: `MAESTRO_CLIENT_EMAIL`, `MAESTRO_CLIENT_PASSWORD`, `MAESTRO_CLIENT_COMPANY` |
| **Staff signup** | Env var: `MAESTRO_STAFF_SIGNUP_PHONE` (must be new Firebase test phone) |
---
## Step-by-step: Run login tests
### 1. Install Maestro CLI
```bash
curl -Ls "https://get.maestro.mobile.dev" | bash
```
Or: https://maestro.dev/docs/getting-started/installation
### 2. Add Firebase test phone (Staff app only)
In [Firebase Console](https://console.firebase.google.com) → your project → **Authentication****Sign-in method****Phone****Phone numbers for testing**:
- Add: **+1 5557654321** with verification code **123456**
### 3. Build and install the apps
From the **project root**:
```bash
# Client
make mobile-client-build PLATFORM=apk MODE=debug
adb install apps/mobile/apps/client/build/app/outputs/flutter-apk/app-debug.apk
# Staff
make mobile-staff-build PLATFORM=apk MODE=debug
adb install apps/mobile/apps/staff/build/app/outputs/flutter-apk/app-debug.apk
```
Or run the app on a connected device/emulator: `make mobile-client-dev-android DEVICE=<id>` (then Maestro can launch the already-installed app by appId).
### 4. Run Maestro tests
From the **project root** (`e:\Krow-google\krow-workforce`):
```bash
# Client login (uses legendary@krowd.com / Demo2026!)
maestro test apps/mobile/apps/client/maestro/login.yaml
# Staff login (uses 5557654321 / OTP 123456)
maestro test apps/mobile/apps/staff/maestro/login.yaml
```
### 5. Run signup tests (optional)
**Client signup** — set env vars first:
```bash
$env:MAESTRO_CLIENT_EMAIL="newuser@example.com"
$env:MAESTRO_CLIENT_PASSWORD="YourPassword123!"
$env:MAESTRO_CLIENT_COMPANY="Test Company"
maestro test apps/mobile/apps/client/maestro/signup.yaml
```
**Staff signup** — use a new Firebase test phone:
```bash
# Add +1 555-555-0000 / 123456 in Firebase, then:
$env:MAESTRO_STAFF_SIGNUP_PHONE="5555550000"
maestro test apps/mobile/apps/staff/maestro/signup.yaml
```
---
## Checklist
- [ ] Maestro CLI installed
- [ ] Firebase test phone +1 5557654321 / 123456 added (for staff)
- [ ] Client app built and installed
- [ ] Staff app built and installed
- [ ] Run from project root: `maestro test apps/mobile/apps/client/maestro/login.yaml`
- [ ] Run from project root: `maestro test apps/mobile/apps/staff/maestro/login.yaml`

View File

@@ -0,0 +1,58 @@
# Marionette MCP Spike — Usage Guide
**Issue:** #533
**Purpose:** Document how to run the Marionette MCP spike for auth flows.
## Prerequisites
1. **Marionette MCP server** — Install globally:
```bash
dart pub global activate marionette_mcp
```
2. **Add Marionette to Cursor** — In `.cursor/mcp.json` or global config:
```json
{
"mcpServers": {
"marionette": {
"command": "marionette_mcp",
"args": []
}
}
}
```
3. **Run app in debug mode** — The app must be running with VM Service:
```bash
cd apps/mobile && flutter run -d <device_id>
```
4. **Get VM Service URI** — From the `flutter run` output, copy the `ws://127.0.0.1:XXXX/ws` URI (often shown in the DevTools link).
## Spike flows (AI agent prompts)
Use these prompts with the Marionette MCP connected to the running app.
### Client — Login
> Connect to the app using the VM Service URI. Navigate to the Get Started screen, tap "Sign In", enter legendary@krowd.com and Demo2026!, then tap "Sign In". Verify we land on the home screen.
### Client — Sign up
> Connect to the app. Tap "Create Account", fill in Company, Email, Password (and confirm) with new credentials, then tap "Create Account". Verify we land on the home screen.
### Staff — Login
> Connect to the app. Tap "Log In", enter phone number 5557654321, tap "Send Code", enter OTP 123456, tap "Continue". Verify we reach the staff home screen.
> (Firebase test phone: +1 555-765-4321 / OTP 123456)
### Staff — Sign up
> Connect to the app. Tap "Sign Up", enter a NEW phone number (Firebase test phone), tap "Send Code", enter OTP, tap "Continue". Verify we reach Profile Setup or staff home.
## Limitations observed (from spike)
- **Debug only** — Marionette needs the Dart VM Service; does not work with release builds.
- **Non-deterministic** — LLM-driven actions can vary in behavior and timing.
- **Latency** — Each step involves API roundtrips (~45s+ for full flow vs ~5s for Maestro).
- **Best use** — Exploratory testing, live debugging, smoke checks during development.