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: