Merge branch 'dev' into Inconsistent-Shift-Booking-Status

This commit is contained in:
Achintha Isuru
2026-02-26 10:38:02 -05:00
committed by GitHub
204 changed files with 15504 additions and 1061 deletions

View File

@@ -0,0 +1,64 @@
name: Backend Foundation
on:
pull_request:
branches:
- dev
- main
push:
branches:
- dev
- main
jobs:
backend-foundation-makefile:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Validate backend make targets
run: |
make backend-help
make help | grep "backend-"
- name: Dry-run backend automation targets
run: |
make -n backend-enable-apis ENV=dev
make -n backend-bootstrap-dev ENV=dev
make -n backend-deploy-core ENV=dev
make -n backend-deploy-commands ENV=dev
make -n backend-deploy-workers ENV=dev
make -n backend-smoke-core ENV=dev
make -n backend-smoke-commands ENV=dev
make -n backend-logs-core ENV=dev
backend-services-tests:
runs-on: ubuntu-latest
strategy:
matrix:
service:
- backend/core-api
- backend/command-api
defaults:
run:
working-directory: ${{ matrix.service }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: ${{ matrix.service }}/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run tests
env:
AUTH_BYPASS: "true"
LLM_MOCK: "true"
run: npm test

7
.gitignore vendored
View File

@@ -43,6 +43,7 @@ lerna-debug.log*
*.temp
tmp/
temp/
scripts/issues-to-create.md
# ==============================================================================
# SECURITY (CRITICAL)
@@ -186,3 +187,9 @@ krow-workforce-export-latest/
# Data Connect Generated SDKs (Explicit)
apps/mobile/packages/data_connect/lib/src/dataconnect_generated/
apps/web/src/dataconnect-generated/
AGENTS.md
CLAUDE.md
GEMINI.md
TASKS.md

30
CHANGELOG.md Normal file
View File

@@ -0,0 +1,30 @@
# KROW Workforce Change Log
| Date | Version | Change |
|---|---|---|
| 2026-02-24 | 0.1.0 | Confirmed dev owner access and current runtime baseline in `krow-workforce-dev`. |
| 2026-02-24 | 0.1.1 | Added backend foundation implementation plan document. |
| 2026-02-24 | 0.1.2 | Added API implementation contract and transition route aliases. |
| 2026-02-24 | 0.1.3 | Added auth-first security policy with deferred role-map integration hooks. |
| 2026-02-24 | 0.1.4 | Locked defaults for idempotency, validation, bucket split, model provider, and p95 objectives. |
| 2026-02-24 | 0.1.5 | Added backend makefile module and CI workflow for backend target validation. |
| 2026-02-24 | 0.1.6 | Added Cloud SQL-backed idempotency storage, migration script, and command API test coverage. |
| 2026-02-24 | 0.1.7 | Added `/health` endpoints and switched smoke checks to `/health` for Cloud Run compatibility. |
| 2026-02-24 | 0.1.8 | Enabled dev frontend reachability and made deploy auth mode environment-aware (`dev` public, `staging` private). |
| 2026-02-24 | 0.1.9 | Switched core API from mock behavior to real GCS upload/signed URLs and real Vertex model calls in dev deployment. |
| 2026-02-24 | 0.1.10 | Hardened core APIs with signed URL ownership/expiry checks, object existence checks, and per-user LLM rate limiting. |
| 2026-02-24 | 0.1.11 | Added frontend-ready core API guide and linked M4 API catalog to it as source of truth for consumption. |
| 2026-02-24 | 0.1.12 | Reduced M4 API docs to core-only scope and removed command-route references until command implementation is complete. |
| 2026-02-24 | 0.1.13 | Added verification architecture contract with endpoint design and workflow split for attire, government ID, and certification. |
| 2026-02-24 | 0.1.14 | Implemented core verification endpoints in dev and updated frontend/API docs with live verification route contracts. |
| 2026-02-24 | 0.1.15 | Added live Vertex Flash Lite attire verification path and third-party adapter scaffolding for government ID and certification checks. |
| 2026-02-24 | 0.1.16 | Added M4 target schema blueprint doc with first-principles modular model, constraints, and migration phases. |
| 2026-02-24 | 0.1.17 | Added full current-schema mermaid model relationship map to the M4 target schema blueprint. |
| 2026-02-24 | 0.1.18 | Updated schema blueprint with explicit multi-tenant stakeholder model and phased RBAC rollout with shadow mode before enforcement. |
| 2026-02-24 | 0.1.19 | Added customer stakeholder-wheel mapping and future stakeholder extension model to the M4 schema blueprint. |
| 2026-02-25 | 0.1.20 | Added roadmap CSV schema-reconciliation document with stakeholder capability matrix and concrete schema gap analysis. |
| 2026-02-25 | 0.1.21 | Updated target schema blueprint with roadmap-evidence section plus attendance/offense, stakeholder-network, and settlement-table coverage. |
| 2026-02-25 | 0.1.22 | Updated core actor scenarios with explicit business and vendor user partitioning via membership tables. |
| 2026-02-25 | 0.1.23 | Updated schema blueprint and reconciliation docs to add `business_memberships` and `vendor_memberships` as first-class data actors. |
| 2026-02-25 | 0.1.24 | Removed stale `m4-discrepencies.md` document from M4 planning docs cleanup. |
| 2026-02-25 | 0.1.25 | Added target schema model catalog with keys and domain relationship diagrams for slide/workshop use. |

129
CLAUDE.md
View File

@@ -1,129 +0,0 @@
# CLAUDE.md - Project Context for AI Assistants
This file provides context for Claude Code and other AI assistants working on this codebase.
## Project Overview
**KROW Workforce** is a workforce management platform connecting businesses with temporary/on-demand workers. It consists of:
- **Client App**: For businesses to create orders, manage hubs, handle billing
- **Staff App**: For workers to manage availability, clock in/out, view earnings
- **Web Dashboard**: Admin portal (React/Vite - WIP)
- **Backend**: Firebase Data Connect + PostgreSQL on Cloud SQL
## Monorepo Structure
```
/apps
/mobile # Flutter apps (managed by Melos)
/apps
/client # krowwithus_client - Business app
/staff # krowwithus_staff - Worker app
/design_system_viewer
/packages
/core # Base utilities
/domain # Business entities, repository interfaces
/data_connect # Data layer, Firebase Data Connect SDK
/design_system # Shared UI components
/core_localization # i18n (Slang)
/features
/client/* # Client-specific features
/staff/* # Staff-specific features
/web-dashboard # React web app (WIP)
/backend
/dataconnect # GraphQL schemas, Firebase Data Connect config
/cloud-functions # Serverless functions (placeholder)
/internal
/launchpad # Internal DevOps portal
/api-harness # API testing tool
/makefiles # Modular Make targets
/docs # Project documentation
```
## Key Commands
### Mobile Development
```bash
# Install dependencies
make mobile-install
# Run client app (specify your device ID)
make mobile-client-dev-android DEVICE=<device_id>
# Run staff app
make mobile-staff-dev-android DEVICE=<device_id>
# Find your device ID
flutter devices
# Build APK
make mobile-client-build PLATFORM=apk
make mobile-staff-build PLATFORM=apk
# Code generation (localization + build_runner)
cd apps/mobile && melos run gen:all
```
### Web Development
```bash
make install # Install web dependencies
make dev # Run web dev server
```
### Data Connect
```bash
make dataconnect-sync # Deploy schemas, migrate, regenerate SDK
```
## Architecture Patterns
- **State Management**: BLoC pattern (flutter_bloc)
- **Navigation**: Flutter Modular
- **Architecture**: Clean Architecture (domain/data/presentation layers)
- **Feature Organization**: Each feature is a separate package
- **Value Objects**: Equatable for entity equality
## Code Conventions
- Features go in `/apps/mobile/packages/features/{client|staff}/`
- Shared code goes in `/apps/mobile/packages/{core|domain|data_connect}/`
- UI components go in `/apps/mobile/packages/design_system/`
- GraphQL schemas go in `/backend/dataconnect/schema/`
- Documentation language: **English**
## Important Files
- `apps/mobile/melos.yaml` - Melos workspace config
- `makefiles/mobile.mk` - Mobile Make targets
- `backend/dataconnect/dataconnect.yaml` - Data Connect config
- `firebase.json` - Firebase hosting/emulator config
- `BLOCKERS.md` - Known blockers and deviations
## Branch Protection
- `main` and `dev` branches are protected
- Always create feature branches: `feature/`, `fix/`, `chore/`
- PRs required for merging
## Testing Mobile Apps
1. Connect your Android device or start emulator
2. Run `flutter devices` to get device ID
3. Run `make mobile-client-dev-android DEVICE=<your_device_id>`
## Common Issues
### "No devices found with name 'android'"
The Makefile defaults to device ID `android`. Override with your actual device:
```bash
make mobile-client-dev-android DEVICE=3fb285a7
```
### Dependency resolution issues
```bash
cd apps/mobile && melos clean && melos bootstrap
```
### Code generation out of sync
```bash
cd apps/mobile && melos run gen:all
```

138
GEMINI.md
View File

@@ -1,138 +0,0 @@
# GEMINI.md - Project Context for AI Assistants
This file provides context for Gemini and other AI assistants working on this codebase.
## Project Overview
**KROW Workforce** is a workforce management platform connecting businesses with temporary/on-demand workers. It consists of:
- **Client App**: For businesses to create orders, manage hubs, handle billing
- **Staff App**: For workers to manage availability, clock in/out, view earnings
- **Web Dashboard**: Admin portal (React/Vite - WIP)
- **Backend**: Firebase Data Connect + PostgreSQL on Cloud SQL
## Monorepo Structure
```
/apps
/mobile # Flutter apps (managed by Melos)
/apps
/client # krowwithus_client - Business app
/staff # krowwithus_staff - Worker app
/design_system_viewer
/packages
/core # Base utilities
/domain # Business entities, repository interfaces
/data_connect # Data layer, Firebase Data Connect SDK
/design_system # Shared UI components
/core_localization # i18n (Slang)
/features
/client/* # Client-specific features
/staff/* # Staff-specific features
/web-dashboard # React web app (WIP)
/backend
/dataconnect # GraphQL schemas, Firebase Data Connect config
/cloud-functions # Serverless functions (placeholder)
/internal
/launchpad # Internal DevOps portal
/api-harness # API testing tool
/makefiles # Modular Make targets
/docs # Project documentation
/bugs # Bug reports and screenshots
```
## Key Commands
### Mobile Development
```bash
# Install dependencies
make mobile-install
# Run client app (specify your device ID)
make mobile-client-dev-android DEVICE=<device_id>
# Run staff app
make mobile-staff-dev-android DEVICE=<device_id>
# Find your device ID
flutter devices
# Build APK
make mobile-client-build PLATFORM=apk
make mobile-staff-build PLATFORM=apk
# Code generation (localization + build_runner)
cd apps/mobile && melos run gen:all
```
### Web Development
```bash
make install # Install web dependencies
make dev # Run web dev server
```
### Data Connect
```bash
make dataconnect-sync # Deploy schemas, migrate, regenerate SDK
```
## Architecture Patterns
- **State Management**: BLoC pattern (flutter_bloc)
- **Navigation**: Flutter Modular
- **Architecture**: Clean Architecture (domain/data/presentation layers)
- **Feature Organization**: Each feature is a separate package
- **Value Objects**: Equatable for entity equality
## Code Conventions
- Features go in `/apps/mobile/packages/features/{client|staff}/`
- Shared code goes in `/apps/mobile/packages/{core|domain|data_connect}/`
- UI components go in `/apps/mobile/packages/design_system/`
- GraphQL schemas go in `/backend/dataconnect/schema/`
- Documentation language: **English**
## Important Files
- `apps/mobile/melos.yaml` - Melos workspace config
- `makefiles/mobile.mk` - Mobile Make targets
- `backend/dataconnect/dataconnect.yaml` - Data Connect config
- `firebase.json` - Firebase hosting/emulator config
- `BLOCKERS.md` - Known blockers and deviations
- `bugs/BUG-REPORT-*.md` - Bug reports with analysis
## Branch Protection
- `main` and `dev` branches are protected
- Always create feature branches: `feature/`, `fix/`, `chore/`
- PRs required for merging
## Testing Mobile Apps
1. Connect your Android device or start emulator
2. Run `flutter devices` to get device ID
3. Run `make mobile-client-dev-android DEVICE=<your_device_id>`
## Common Issues
### "No devices found with name 'android'"
The Makefile defaults to device ID `android`. Override with your actual device:
```bash
make mobile-client-dev-android DEVICE=3fb285a7
```
### Dependency resolution issues
```bash
cd apps/mobile && melos clean && melos bootstrap
```
### Code generation out of sync
```bash
cd apps/mobile && melos run gen:all
```
## Known Technical Debt
See `bugs/BUG-REPORT-*.md` for detailed analysis of:
- Authentication/User sync issues
- Error handling architecture (needs AppException pattern)
- BLoC state management patterns (copyWith null handling)

View File

@@ -11,6 +11,7 @@ include makefiles/web.mk
include makefiles/launchpad.mk
include makefiles/mobile.mk
include makefiles/dataconnect.mk
include makefiles/backend.mk
include makefiles/tools.mk
# --- Main Help Command ---
@@ -55,6 +56,9 @@ help:
@echo " make mobile-test Run flutter test for client+staff"
@echo " make mobile-hot-reload Hot reload running Flutter app"
@echo " make mobile-hot-restart Hot restart running Flutter app"
@echo " make test-e2e Run full Maestro E2E suite (Client + Staff auth)"
@echo " make test-e2e-client Run Client Maestro E2E only"
@echo " make test-e2e-staff Run Staff Maestro E2E only"
@echo ""
@echo " 🗄️ DATA CONNECT & BACKEND (backend/dataconnect)"
@echo " ────────────────────────────────────────────────────────────────────"
@@ -71,6 +75,19 @@ help:
@echo " make dataconnect-bootstrap-validation-database ONE-TIME: Setup validation database"
@echo " make dataconnect-backup-dev-to-validation Backup dev database to validation"
@echo ""
@echo " ☁️ BACKEND FOUNDATION (Cloud Run + Workers)"
@echo " ────────────────────────────────────────────────────────────────────"
@echo " make backend-help Show backend foundation commands"
@echo " make backend-enable-apis [ENV=dev] Enable backend GCP APIs"
@echo " make backend-bootstrap-dev Bootstrap backend foundation resources (dev)"
@echo " make backend-migrate-idempotency Create/upgrade command idempotency table"
@echo " make backend-deploy-core [ENV=dev] Build and deploy core API service"
@echo " make backend-deploy-commands [ENV=dev] Build and deploy command API service"
@echo " make backend-deploy-workers [ENV=dev] Deploy async worker functions scaffold"
@echo " make backend-smoke-core [ENV=dev] Run health smoke test for core service (/health)"
@echo " make backend-smoke-commands [ENV=dev] Run health smoke test for command service (/health)"
@echo " make backend-logs-core [ENV=dev] Tail/read logs for core service"
@echo ""
@echo " 🛠️ DEVELOPMENT TOOLS"
@echo " ────────────────────────────────────────────────────────────────────"
@echo " make install-melos Install Melos globally (for mobile dev)"

View File

@@ -15,6 +15,11 @@ import io.flutter.embedding.engine.FlutterEngine;
public final class GeneratedPluginRegistrant {
private static final String TAG = "GeneratedPluginRegistrant";
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
try {
flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.appcheck.FlutterFirebaseAppCheckPlugin());
} catch (Exception e) {
@@ -30,6 +35,16 @@ public final class GeneratedPluginRegistrant {
} catch (Exception e) {
Log.e(TAG, "Error registering plugin firebase_core, io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin image_picker_android, io.flutter.plugins.imagepicker.ImagePickerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
} catch (Exception e) {

View File

@@ -6,6 +6,12 @@
#import "GeneratedPluginRegistrant.h"
#if __has_include(<file_picker/FilePickerPlugin.h>)
#import <file_picker/FilePickerPlugin.h>
#else
@import file_picker;
#endif
#if __has_include(<firebase_app_check/FLTFirebaseAppCheckPlugin.h>)
#import <firebase_app_check/FLTFirebaseAppCheckPlugin.h>
#else
@@ -24,6 +30,12 @@
@import firebase_core;
#endif
#if __has_include(<image_picker_ios/FLTImagePickerPlugin.h>)
#import <image_picker_ios/FLTImagePickerPlugin.h>
#else
@import image_picker_ios;
#endif
#if __has_include(<shared_preferences_foundation/SharedPreferencesPlugin.h>)
#import <shared_preferences_foundation/SharedPreferencesPlugin.h>
#else
@@ -39,9 +51,11 @@
@implementation GeneratedPluginRegistrant
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
[FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]];
[FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]];
[FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]];
[FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]];
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
}

View File

@@ -6,9 +6,13 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
url_launcher_linux
)

View File

@@ -5,6 +5,8 @@
import FlutterMacOS
import Foundation
import file_picker
import file_selector_macos
import firebase_app_check
import firebase_auth
import firebase_core
@@ -12,6 +14,8 @@ import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))

View File

@@ -0,0 +1,33 @@
# Maestro Integration Tests — Client App
Auth flows for the KROW Client app.
See [docs/research/flutter-testing-tools.md](/docs/research/flutter-testing-tools.md) and [maestro-test-run-instructions.md](/docs/research/maestro-test-run-instructions.md).
## Structure
```
maestro/
auth/
sign_in.yaml
sign_up.yaml
```
## Credentials (env, never hardcoded)
| Flow | Env variables |
|------|---------------|
| sign_in | `TEST_CLIENT_EMAIL`, `TEST_CLIENT_PASSWORD` |
| sign_up | `TEST_CLIENT_EMAIL`, `TEST_CLIENT_PASSWORD`, `TEST_CLIENT_COMPANY` |
## Run
```bash
# Via Makefile (export vars first)
make test-e2e-client
# Direct
maestro test apps/mobile/apps/client/maestro/auth/sign_in.yaml \
-e TEST_CLIENT_EMAIL=... -e TEST_CLIENT_PASSWORD=...
maestro test apps/mobile/apps/client/maestro/auth/sign_up.yaml \
-e TEST_CLIENT_EMAIL=... -e TEST_CLIENT_PASSWORD=... -e TEST_CLIENT_COMPANY=...
```

View File

@@ -0,0 +1,22 @@
# Client App — Sign In flow
# Credentials via env: TEST_CLIENT_EMAIL, TEST_CLIENT_PASSWORD
# Run: maestro test apps/mobile/apps/client/maestro/auth/sign_in.yaml -e TEST_CLIENT_EMAIL=... -e TEST_CLIENT_PASSWORD=...
# Or: export MAESTRO_TEST_CLIENT_EMAIL / MAESTRO_TEST_CLIENT_PASSWORD (Maestro auto-reads MAESTRO_*)
appId: com.krowwithus.client
env:
EMAIL: ${TEST_CLIENT_EMAIL}
PASSWORD: ${TEST_CLIENT_PASSWORD}
---
- launchApp
- assertVisible: "Sign In"
- tapOn: "Sign In"
- assertVisible: "Email"
- tapOn:
id: sign_in_email
- inputText: ${EMAIL}
- tapOn:
id: sign_in_password
- inputText: ${PASSWORD}
- tapOn: "Sign In"
- assertVisible: "Home"

View File

@@ -0,0 +1,28 @@
# Client App — Sign Up flow
# Credentials via env: TEST_CLIENT_EMAIL, TEST_CLIENT_PASSWORD, TEST_CLIENT_COMPANY
# Run: maestro test apps/mobile/apps/client/maestro/auth/sign_up.yaml -e TEST_CLIENT_EMAIL=... -e TEST_CLIENT_PASSWORD=... -e TEST_CLIENT_COMPANY=...
appId: com.krowwithus.client
env:
EMAIL: ${TEST_CLIENT_EMAIL}
PASSWORD: ${TEST_CLIENT_PASSWORD}
COMPANY: ${TEST_CLIENT_COMPANY}
---
- launchApp
- assertVisible: "Create Account"
- tapOn: "Create Account"
- assertVisible: "Company"
- tapOn:
id: sign_up_company
- inputText: ${COMPANY}
- tapOn:
id: sign_up_email
- inputText: ${EMAIL}
- tapOn:
id: sign_up_password
- inputText: ${PASSWORD}
- tapOn:
id: sign_up_confirm_password
- inputText: ${PASSWORD}
- tapOn: "Create Account"
- assertVisible: "Home"

View File

@@ -6,11 +6,14 @@
#include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
#include <firebase_auth/firebase_auth_plugin_c_api.h>
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FirebaseAuthPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
FirebaseCorePluginCApiRegisterWithRegistrar(

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
firebase_auth
firebase_core
url_launcher_windows

View File

@@ -15,6 +15,11 @@ import io.flutter.embedding.engine.FlutterEngine;
public final class GeneratedPluginRegistrant {
private static final String TAG = "GeneratedPluginRegistrant";
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
try {
flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.appcheck.FlutterFirebaseAppCheckPlugin());
} catch (Exception e) {
@@ -45,6 +50,11 @@ public final class GeneratedPluginRegistrant {
} catch (Exception e) {
Log.e(TAG, "Error registering plugin google_maps_flutter_android, io.flutter.plugins.googlemaps.GoogleMapsPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin image_picker_android, io.flutter.plugins.imagepicker.ImagePickerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
} catch (Exception e) {

View File

@@ -6,6 +6,12 @@
#import "GeneratedPluginRegistrant.h"
#if __has_include(<file_picker/FilePickerPlugin.h>)
#import <file_picker/FilePickerPlugin.h>
#else
@import file_picker;
#endif
#if __has_include(<firebase_app_check/FLTFirebaseAppCheckPlugin.h>)
#import <firebase_app_check/FLTFirebaseAppCheckPlugin.h>
#else
@@ -36,6 +42,12 @@
@import google_maps_flutter_ios;
#endif
#if __has_include(<image_picker_ios/FLTImagePickerPlugin.h>)
#import <image_picker_ios/FLTImagePickerPlugin.h>
#else
@import image_picker_ios;
#endif
#if __has_include(<permission_handler_apple/PermissionHandlerPlugin.h>)
#import <permission_handler_apple/PermissionHandlerPlugin.h>
#else
@@ -57,11 +69,13 @@
@implementation GeneratedPluginRegistrant
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
[FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]];
[FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]];
[FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]];
[FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]];
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
[FLTGoogleMapsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTGoogleMapsPlugin"]];
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
[PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]];
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];

View File

@@ -5,12 +5,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krowwithus_staff/firebase_options.dart';
import 'package:staff_authentication/staff_authentication.dart'
as staff_authentication;
import 'package:staff_main/staff_main.dart' as staff_main;
import 'package:krow_core/core.dart';
import 'src/widgets/session_listener.dart';
@@ -26,7 +26,10 @@ void main() async {
// Initialize session listener for Firebase Auth state changes
DataConnectService.instance.initializeAuthListener(
allowedRoles: <String>['STAFF', 'BOTH'], // Only allow users with STAFF or BOTH roles
allowedRoles: <String>[
'STAFF',
'BOTH',
], // Only allow users with STAFF or BOTH roles
);
runApp(
@@ -40,8 +43,8 @@ void main() async {
/// The main application module.
class AppModule extends Module {
@override
List<Module> get imports =>
<Module>[
List<Module> get imports => <Module>[
CoreModule(),
core_localization.LocalizationModule(),
staff_authentication.StaffAuthenticationModule(),
];

View File

@@ -6,9 +6,13 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
url_launcher_linux
)

View File

@@ -5,6 +5,8 @@
import FlutterMacOS
import Foundation
import file_picker
import file_selector_macos
import firebase_app_check
import firebase_auth
import firebase_core
@@ -13,6 +15,8 @@ import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))

View File

@@ -0,0 +1,38 @@
# Maestro Integration Tests — Staff App
Auth flows for the KROW Staff app.
See [docs/research/flutter-testing-tools.md](/docs/research/flutter-testing-tools.md) and [maestro-test-run-instructions.md](/docs/research/maestro-test-run-instructions.md).
## Structure
```
maestro/
auth/
sign_in.yaml
sign_up.yaml
```
## Prerequisites
- Firebase test phone in Auth > Phone (e.g. +1 555-765-4321 / OTP 123456)
- For sign_up: use a different test number (not yet registered)
## Credentials (env, never hardcoded)
| Flow | Env variables |
|------|---------------|
| sign_in | `TEST_STAFF_PHONE`, `TEST_STAFF_OTP` |
| sign_up | `TEST_STAFF_SIGNUP_PHONE`, `TEST_STAFF_OTP` |
## Run
```bash
# Via Makefile (export vars first)
make test-e2e-staff
# Direct
maestro test apps/mobile/apps/staff/maestro/auth/sign_in.yaml \
-e TEST_STAFF_PHONE=5557654321 -e TEST_STAFF_OTP=123456
maestro test apps/mobile/apps/staff/maestro/auth/sign_up.yaml \
-e TEST_STAFF_SIGNUP_PHONE=... -e TEST_STAFF_OTP=123456
```

View File

@@ -0,0 +1,24 @@
# Staff App — Sign In flow (Phone + OTP)
# Credentials via env: TEST_STAFF_PHONE, TEST_STAFF_OTP
# Firebase: add test phone in Auth > Phone (e.g. +1 555-765-4321 / OTP 123456)
# Run: maestro test apps/mobile/apps/staff/maestro/auth/sign_in.yaml -e TEST_STAFF_PHONE=5557654321 -e TEST_STAFF_OTP=123456
appId: com.krowwithus.staff
env:
PHONE: ${TEST_STAFF_PHONE}
OTP: ${TEST_STAFF_OTP}
---
- launchApp
- assertVisible: "Log In"
- tapOn: "Log In"
- assertVisible: "Send Code"
- tapOn:
id: staff_phone_input
- inputText: ${PHONE}
- tapOn: "Send Code"
# OTP screen: Continue button visible until we finish typing
- assertVisible: "Continue"
- tapOn:
id: staff_otp_input
- inputText: ${OTP}
# OTP auto-submits when 6th digit is entered; app navigates to staff main

View File

@@ -0,0 +1,23 @@
# Staff App — Sign Up flow (Phone + OTP)
# Credentials via env: TEST_STAFF_SIGNUP_PHONE, TEST_STAFF_OTP
# Use a NEW Firebase test phone (not yet registered)
# Run: maestro test apps/mobile/apps/staff/maestro/auth/sign_up.yaml -e TEST_STAFF_SIGNUP_PHONE=... -e TEST_STAFF_OTP=123456
appId: com.krowwithus.staff
env:
PHONE: ${TEST_STAFF_SIGNUP_PHONE}
OTP: ${TEST_STAFF_OTP}
---
- launchApp
- assertVisible: "Sign Up"
- tapOn: "Sign Up"
- assertVisible: "Send Code"
- tapOn:
id: staff_phone_input
- inputText: ${PHONE}
- tapOn: "Send Code"
# OTP auto-submits when 6th digit entered
- assertVisible: "Continue"
- tapOn:
id: staff_otp_input
- inputText: ${OTP}

View File

@@ -6,6 +6,7 @@
#include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
#include <firebase_auth/firebase_auth_plugin_c_api.h>
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <geolocator_windows/geolocator_windows.h>
@@ -13,6 +14,8 @@
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FirebaseAuthPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
FirebaseCorePluginCApiRegisterWithRegistrar(

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
firebase_auth
firebase_core
geolocator_windows

View File

@@ -1,3 +1,4 @@
{
"GOOGLE_MAPS_API_KEY": "AIzaSyAyRS9I4xxoVPAX91RJvWJHszB3ZY3-IC0"
"GOOGLE_MAPS_API_KEY": "AIzaSyAyRS9I4xxoVPAX91RJvWJHszB3ZY3-IC0",
"CORE_API_BASE_URL": "https://krow-core-api-e3g6witsvq-uc.a.run.app"
}

View File

@@ -1,5 +1,7 @@
library;
export 'src/core_module.dart';
export 'src/domain/arguments/usecase_argument.dart';
export 'src/domain/usecases/usecase.dart';
export 'src/utils/date_time_utils.dart';
@@ -8,3 +10,22 @@ export 'src/presentation/mixins/bloc_error_handler.dart';
export 'src/presentation/observers/core_bloc_observer.dart';
export 'src/config/app_config.dart';
export 'src/routing/routing.dart';
export 'src/services/api_service/api_service.dart';
export 'src/services/api_service/dio_client.dart';
// Core API Services
export 'src/services/api_service/core_api_services/core_api_endpoints.dart';
export 'src/services/api_service/core_api_services/file_upload/file_upload_service.dart';
export 'src/services/api_service/core_api_services/file_upload/file_upload_response.dart';
export 'src/services/api_service/core_api_services/signed_url/signed_url_service.dart';
export 'src/services/api_service/core_api_services/signed_url/signed_url_response.dart';
export 'src/services/api_service/core_api_services/llm/llm_service.dart';
export 'src/services/api_service/core_api_services/llm/llm_response.dart';
export 'src/services/api_service/core_api_services/verification/verification_service.dart';
export 'src/services/api_service/core_api_services/verification/verification_response.dart';
// Device Services
export 'src/services/device/camera/camera_service.dart';
export 'src/services/device/gallery/gallery_service.dart';
export 'src/services/device/file/file_picker_service.dart';
export 'src/services/device/file_upload/device_file_upload_service.dart';

View File

@@ -5,5 +5,12 @@ class AppConfig {
AppConfig._();
/// The Google Maps API key.
static const String googleMapsApiKey = String.fromEnvironment('GOOGLE_MAPS_API_KEY');
static const String googleMapsApiKey = String.fromEnvironment(
'GOOGLE_MAPS_API_KEY',
);
/// The base URL for the Core API.
static const String coreApiBaseUrl = String.fromEnvironment(
'CORE_API_BASE_URL',
);
}

View File

@@ -0,0 +1,48 @@
import 'package:dio/dio.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:image_picker/image_picker.dart';
import 'package:krow_domain/krow_domain.dart';
import '../core.dart';
/// A module that provides core services and shared dependencies.
///
/// This module should be imported by the root [AppModule] to make
/// core services available globally as singletons.
class CoreModule extends Module {
@override
void exportedBinds(Injector i) {
// 1. Register the base HTTP client
i.addSingleton<Dio>(() => DioClient());
// 2. Register the base API service
i.addSingleton<BaseApiService>(() => ApiService(i.get<Dio>()));
// 3. Register Core API Services (Orchestrators)
i.addSingleton<FileUploadService>(
() => FileUploadService(i.get<BaseApiService>()),
);
i.addSingleton<SignedUrlService>(
() => SignedUrlService(i.get<BaseApiService>()),
);
i.addSingleton<VerificationService>(
() => VerificationService(i.get<BaseApiService>()),
);
i.addSingleton<LlmService>(() => LlmService(i.get<BaseApiService>()));
// 4. Register Device dependency
i.addSingleton<ImagePicker>(() => ImagePicker());
// 5. Register Device Services
i.addSingleton<CameraService>(() => CameraService(i.get<ImagePicker>()));
i.addSingleton<GalleryService>(() => GalleryService(i.get<ImagePicker>()));
i.addSingleton<FilePickerService>(FilePickerService.new);
i.addSingleton<DeviceFileUploadService>(
() => DeviceFileUploadService(
cameraService: i.get<CameraService>(),
galleryService: i.get<GalleryService>(),
apiUploadService: i.get<FileUploadService>(),
),
);
}
}

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

@@ -196,7 +196,22 @@ extension StaffNavigator on IModularNavigator {
///
/// Record sizing and appearance information for uniform allocation.
void toAttire() {
pushNamed(StaffPaths.attire);
navigate(StaffPaths.attire);
}
/// Pushes the attire capture page.
///
/// Parameters:
/// * [item] - The attire item to capture
/// * [initialPhotoUrl] - Optional initial photo URL
void toAttireCapture({required AttireItem item, String? initialPhotoUrl}) {
navigate(
StaffPaths.attireCapture,
arguments: <String, dynamic>{
'item': item,
'initialPhotoUrl': initialPhotoUrl,
},
);
}
// ==========================================================================

View File

@@ -152,6 +152,9 @@ class StaffPaths {
/// Record sizing and appearance information for uniform allocation.
static const String attire = '/worker-main/attire/';
/// Attire capture page.
static const String attireCapture = '/worker-main/attire/capture/';
// ==========================================================================
// COMPLIANCE & DOCUMENTS
// ==========================================================================

View File

@@ -0,0 +1,127 @@
import 'package:dio/dio.dart';
import 'package:krow_domain/krow_domain.dart';
/// A service that handles HTTP communication using the [Dio] client.
///
/// This class provides a wrapper around [Dio]'s methods to handle
/// response parsing and error handling in a consistent way.
class ApiService implements BaseApiService {
/// Creates an [ApiService] with the given [Dio] instance.
ApiService(this._dio);
/// The underlying [Dio] client used for network requests.
final Dio _dio;
/// Performs a GET request to the specified [endpoint].
@override
Future<ApiResponse> get(
String endpoint, {
Map<String, dynamic>? params,
}) async {
try {
final Response<dynamic> response = await _dio.get<dynamic>(
endpoint,
queryParameters: params,
);
return _handleResponse(response);
} on DioException catch (e) {
return _handleError(e);
}
}
/// Performs a POST request to the specified [endpoint].
@override
Future<ApiResponse> post(
String endpoint, {
dynamic data,
Map<String, dynamic>? params,
}) async {
try {
final Response<dynamic> response = await _dio.post<dynamic>(
endpoint,
data: data,
queryParameters: params,
);
return _handleResponse(response);
} on DioException catch (e) {
return _handleError(e);
}
}
/// Performs a PUT request to the specified [endpoint].
@override
Future<ApiResponse> put(
String endpoint, {
dynamic data,
Map<String, dynamic>? params,
}) async {
try {
final Response<dynamic> response = await _dio.put<dynamic>(
endpoint,
data: data,
queryParameters: params,
);
return _handleResponse(response);
} on DioException catch (e) {
return _handleError(e);
}
}
/// Performs a PATCH request to the specified [endpoint].
@override
Future<ApiResponse> patch(
String endpoint, {
dynamic data,
Map<String, dynamic>? params,
}) async {
try {
final Response<dynamic> response = await _dio.patch<dynamic>(
endpoint,
data: data,
queryParameters: params,
);
return _handleResponse(response);
} on DioException catch (e) {
return _handleError(e);
}
}
/// Extracts [ApiResponse] from a successful [Response].
ApiResponse _handleResponse(Response<dynamic> response) {
return ApiResponse(
code: response.statusCode?.toString() ?? '200',
message: response.data['message']?.toString() ?? 'Success',
data: response.data,
);
}
/// Extracts [ApiResponse] from a [DioException].
ApiResponse _handleError(DioException e) {
if (e.response?.data is Map<String, dynamic>) {
final Map<String, dynamic> body =
e.response!.data as Map<String, dynamic>;
return ApiResponse(
code:
body['code']?.toString() ??
e.response?.statusCode?.toString() ??
'error',
message: body['message']?.toString() ?? e.message ?? 'Error occurred',
data: body['data'],
errors: _parseErrors(body['errors']),
);
}
return ApiResponse(
code: e.response?.statusCode?.toString() ?? 'error',
message: e.message ?? 'Unknown error',
errors: <String, dynamic>{'exception': e.type.toString()},
);
}
/// Helper to parse the errors map from various possible formats.
Map<String, dynamic> _parseErrors(dynamic errors) {
if (errors is Map) {
return Map<String, dynamic>.from(errors);
}
return const <String, dynamic>{};
}
}

View File

@@ -0,0 +1,33 @@
import '../../../config/app_config.dart';
/// Constants for Core API endpoints.
class CoreApiEndpoints {
CoreApiEndpoints._();
/// The base URL for the Core API.
static const String baseUrl = AppConfig.coreApiBaseUrl;
/// Upload a file.
static const String uploadFile = '$baseUrl/core/upload-file';
/// Create a signed URL for a file.
static const String createSignedUrl = '$baseUrl/core/create-signed-url';
/// Invoke a Large Language Model.
static const String invokeLlm = '$baseUrl/core/invoke-llm';
/// Root for verification operations.
static const String verifications = '$baseUrl/core/verifications';
/// Get status of a verification job.
static String verificationStatus(String id) =>
'$baseUrl/core/verifications/$id';
/// Review a verification decision.
static String verificationReview(String id) =>
'$baseUrl/core/verifications/$id/review';
/// Retry a verification job.
static String verificationRetry(String id) =>
'$baseUrl/core/verifications/$id/retry';
}

View File

@@ -0,0 +1,54 @@
/// Response model for file upload operation.
class FileUploadResponse {
/// Creates a [FileUploadResponse].
const FileUploadResponse({
required this.fileUri,
required this.contentType,
required this.size,
required this.bucket,
required this.path,
this.requestId,
});
/// Factory to create [FileUploadResponse] from JSON.
factory FileUploadResponse.fromJson(Map<String, dynamic> json) {
return FileUploadResponse(
fileUri: json['fileUri'] as String,
contentType: json['contentType'] as String,
size: json['size'] as int,
bucket: json['bucket'] as String,
path: json['path'] as String,
requestId: json['requestId'] as String?,
);
}
/// The Cloud Storage URI of the uploaded file.
final String fileUri;
/// The MIME type of the file.
final String contentType;
/// The size of the file in bytes.
final int size;
/// The bucket where the file was uploaded.
final String bucket;
/// The path within the bucket.
final String path;
/// The unique request ID from the server.
final String? requestId;
/// Converts the response to a JSON map.
Map<String, dynamic> toJson() {
return <String, dynamic>{
'fileUri': fileUri,
'contentType': contentType,
'size': size,
'bucket': bucket,
'path': path,
'requestId': requestId,
};
}
}

View File

@@ -0,0 +1,38 @@
import 'package:dio/dio.dart';
import 'package:krow_domain/krow_domain.dart';
import '../core_api_endpoints.dart';
import 'file_upload_response.dart';
/// Service for uploading files to the Core API.
class FileUploadService extends BaseCoreService {
/// Creates a [FileUploadService].
FileUploadService(super.api);
/// Uploads a file with optional visibility and category.
///
/// [filePath] is the local path to the file.
/// [visibility] can be [FileVisibility.public] or [FileVisibility.private].
/// [category] is an optional metadata field.
Future<FileUploadResponse> uploadFile({
required String filePath,
required String fileName,
FileVisibility visibility = FileVisibility.private,
String? category,
}) async {
final ApiResponse res = await action(() async {
final FormData formData = FormData.fromMap(<String, dynamic>{
'file': await MultipartFile.fromFile(filePath, filename: fileName),
'visibility': visibility.value,
if (category != null) 'category': category,
});
return api.post(CoreApiEndpoints.uploadFile, data: formData);
});
if (res.code.startsWith('2')) {
return FileUploadResponse.fromJson(res.data as Map<String, dynamic>);
}
throw Exception(res.message);
}
}

View File

@@ -0,0 +1,42 @@
/// Response model for LLM invocation.
class LlmResponse {
/// Creates an [LlmResponse].
const LlmResponse({
required this.result,
required this.model,
required this.latencyMs,
this.requestId,
});
/// Factory to create [LlmResponse] from JSON.
factory LlmResponse.fromJson(Map<String, dynamic> json) {
return LlmResponse(
result: json['result'] as Map<String, dynamic>,
model: json['model'] as String,
latencyMs: json['latencyMs'] as int,
requestId: json['requestId'] as String?,
);
}
/// The JSON result returned by the model.
final Map<String, dynamic> result;
/// The model name used for invocation.
final String model;
/// Time taken for the request in milliseconds.
final int latencyMs;
/// The unique request ID from the server.
final String? requestId;
/// Converts the response to a JSON map.
Map<String, dynamic> toJson() {
return <String, dynamic>{
'result': result,
'model': model,
'latencyMs': latencyMs,
'requestId': requestId,
};
}
}

View File

@@ -0,0 +1,38 @@
import 'package:krow_domain/krow_domain.dart';
import '../core_api_endpoints.dart';
import 'llm_response.dart';
/// Service for invoking Large Language Models (LLM).
class LlmService extends BaseCoreService {
/// Creates an [LlmService].
LlmService(super.api);
/// Invokes the LLM with a [prompt] and optional [schema].
///
/// [prompt] is the text instruction for the model.
/// [responseJsonSchema] is an optional JSON schema to enforce structure.
/// [fileUrls] are optional URLs of files (images/PDFs) to include in context.
Future<LlmResponse> invokeLlm({
required String prompt,
Map<String, dynamic>? responseJsonSchema,
List<String>? fileUrls,
}) async {
final ApiResponse res = await action(() async {
return api.post(
CoreApiEndpoints.invokeLlm,
data: <String, dynamic>{
'prompt': prompt,
if (responseJsonSchema != null)
'responseJsonSchema': responseJsonSchema,
if (fileUrls != null) 'fileUrls': fileUrls,
},
);
});
if (res.code.startsWith('2')) {
return LlmResponse.fromJson(res.data as Map<String, dynamic>);
}
throw Exception(res.message);
}
}

View File

@@ -0,0 +1,36 @@
/// Response model for creating a signed URL.
class SignedUrlResponse {
/// Creates a [SignedUrlResponse].
const SignedUrlResponse({
required this.signedUrl,
required this.expiresAt,
this.requestId,
});
/// Factory to create [SignedUrlResponse] from JSON.
factory SignedUrlResponse.fromJson(Map<String, dynamic> json) {
return SignedUrlResponse(
signedUrl: json['signedUrl'] as String,
expiresAt: DateTime.parse(json['expiresAt'] as String),
requestId: json['requestId'] as String?,
);
}
/// The generated signed URL.
final String signedUrl;
/// The timestamp when the URL expires.
final DateTime expiresAt;
/// The unique request ID from the server.
final String? requestId;
/// Converts the response to a JSON map.
Map<String, dynamic> toJson() {
return <String, dynamic>{
'signedUrl': signedUrl,
'expiresAt': expiresAt.toIso8601String(),
'requestId': requestId,
};
}
}

View File

@@ -0,0 +1,34 @@
import 'package:krow_domain/krow_domain.dart';
import '../core_api_endpoints.dart';
import 'signed_url_response.dart';
/// Service for creating signed URLs for Cloud Storage objects.
class SignedUrlService extends BaseCoreService {
/// Creates a [SignedUrlService].
SignedUrlService(super.api);
/// Creates a signed URL for a specific [fileUri].
///
/// [fileUri] should be in gs:// format.
/// [expiresInSeconds] must be <= 900.
Future<SignedUrlResponse> createSignedUrl({
required String fileUri,
int expiresInSeconds = 300,
}) async {
final ApiResponse res = await action(() async {
return api.post(
CoreApiEndpoints.createSignedUrl,
data: <String, dynamic>{
'fileUri': fileUri,
'expiresInSeconds': expiresInSeconds,
},
);
});
if (res.code.startsWith('2')) {
return SignedUrlResponse.fromJson(res.data as Map<String, dynamic>);
}
throw Exception(res.message);
}
}

View File

@@ -0,0 +1,90 @@
/// Represents the possible statuses of a verification job.
enum VerificationStatus {
/// Job is created and waiting to be processed.
pending('PENDING'),
/// Job is currently being processed by machine or human.
processing('PROCESSING'),
/// Machine verification passed automatically.
autoPass('AUTO_PASS'),
/// Machine verification failed automatically.
autoFail('AUTO_FAIL'),
/// Machine results are inconclusive and require human review.
needsReview('NEEDS_REVIEW'),
/// Human reviewer approved the verification.
approved('APPROVED'),
/// Human reviewer rejected the verification.
rejected('REJECTED'),
/// An error occurred during processing.
error('ERROR');
const VerificationStatus(this.value);
/// The string value expected by the Core API.
final String value;
/// Creates a [VerificationStatus] from a string.
static VerificationStatus fromString(String value) {
return VerificationStatus.values.firstWhere(
(VerificationStatus e) => e.value == value,
orElse: () => VerificationStatus.error,
);
}
}
/// Response model for verification operations.
class VerificationResponse {
/// Creates a [VerificationResponse].
const VerificationResponse({
required this.verificationId,
required this.status,
this.type,
this.review,
this.requestId,
});
/// Factory to create [VerificationResponse] from JSON.
factory VerificationResponse.fromJson(Map<String, dynamic> json) {
return VerificationResponse(
verificationId: json['verificationId'] as String,
status: VerificationStatus.fromString(json['status'] as String),
type: json['type'] as String?,
review: json['review'] != null
? json['review'] as Map<String, dynamic>
: null,
requestId: json['requestId'] as String?,
);
}
/// The unique ID of the verification job.
final String verificationId;
/// Current status of the verification.
final VerificationStatus status;
/// The type of verification (e.g., attire, government_id).
final String? type;
/// Optional human review details.
final Map<String, dynamic>? review;
/// The unique request ID from the server.
final String? requestId;
/// Converts the response to a JSON map.
Map<String, dynamic> toJson() {
return <String, dynamic>{
'verificationId': verificationId,
'status': status.value,
'type': type,
'review': review,
'requestId': requestId,
};
}
}

View File

@@ -0,0 +1,94 @@
import 'package:krow_domain/krow_domain.dart';
import '../core_api_endpoints.dart';
import 'verification_response.dart';
/// Service for handling async verification jobs.
class VerificationService extends BaseCoreService {
/// Creates a [VerificationService].
VerificationService(super.api);
/// Enqueues a new verification job.
///
/// [type] can be 'attire', 'government_id', etc.
/// [subjectType] is usually 'worker'.
/// [fileUri] is the gs:// path of the uploaded file.
Future<VerificationResponse> createVerification({
required String type,
required String subjectType,
required String subjectId,
required String fileUri,
Map<String, dynamic>? rules,
}) async {
final ApiResponse res = await action(() async {
return api.post(
CoreApiEndpoints.verifications,
data: <String, dynamic>{
'type': type,
'subjectType': subjectType,
'subjectId': subjectId,
'fileUri': fileUri,
if (rules != null) 'rules': rules,
},
);
});
if (res.code.startsWith('2')) {
return VerificationResponse.fromJson(res.data as Map<String, dynamic>);
}
throw Exception(res.message);
}
/// Polls the status of a specific verification.
Future<VerificationResponse> getStatus(String verificationId) async {
final ApiResponse res = await action(() async {
return api.get(CoreApiEndpoints.verificationStatus(verificationId));
});
if (res.code.startsWith('2')) {
return VerificationResponse.fromJson(res.data as Map<String, dynamic>);
}
throw Exception(res.message);
}
/// Submits a manual review decision.
///
/// [decision] should be 'APPROVED' or 'REJECTED'.
Future<VerificationResponse> reviewVerification({
required String verificationId,
required String decision,
String? note,
String? reasonCode,
}) async {
final ApiResponse res = await action(() async {
return api.post(
CoreApiEndpoints.verificationReview(verificationId),
data: <String, dynamic>{
'decision': decision,
if (note != null) 'note': note,
if (reasonCode != null) 'reasonCode': reasonCode,
},
);
});
if (res.code.startsWith('2')) {
return VerificationResponse.fromJson(res.data as Map<String, dynamic>);
}
throw Exception(res.message);
}
/// Retries a verification job that failed or needs re-processing.
Future<VerificationResponse> retryVerification(String verificationId) async {
final ApiResponse res = await action(() async {
return api.post(CoreApiEndpoints.verificationRetry(verificationId));
});
if (res.code.startsWith('2')) {
return VerificationResponse.fromJson(res.data as Map<String, dynamic>);
}
throw Exception(res.message);
}
}

View File

@@ -0,0 +1,27 @@
import 'package:dio/dio.dart';
import 'package:krow_core/src/services/api_service/inspectors/auth_interceptor.dart';
/// A custom Dio client for the Krow project that includes basic configuration
/// and an [AuthInterceptor].
class DioClient extends DioMixin implements Dio {
DioClient([BaseOptions? baseOptions]) {
options =
baseOptions ??
BaseOptions(
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
);
// Use the default adapter
httpClientAdapter = HttpClientAdapter();
// Add interceptors
interceptors.addAll(<Interceptor>[
AuthInterceptor(),
LogInterceptor(
requestBody: true,
responseBody: true,
), // Added for better debugging
]);
}
}

View File

@@ -0,0 +1,24 @@
import 'package:dio/dio.dart';
import 'package:firebase_auth/firebase_auth.dart';
/// An interceptor that adds the Firebase Auth ID token to the Authorization header.
class AuthInterceptor extends Interceptor {
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final User? user = FirebaseAuth.instance.currentUser;
if (user != null) {
try {
final String? token = await user.getIdToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
} catch (e) {
rethrow;
}
}
return handler.next(options);
}
}

View File

@@ -0,0 +1,23 @@
import 'package:image_picker/image_picker.dart';
import 'package:krow_domain/krow_domain.dart';
/// Service for capturing photos and videos using the device camera.
class CameraService extends BaseDeviceService {
/// Creates a [CameraService].
CameraService(ImagePicker picker) : _picker = picker;
final ImagePicker _picker;
/// Captures a photo using the camera.
///
/// Returns the path to the captured image, or null if cancelled.
Future<String?> takePhoto() async {
return action(() async {
final XFile? file = await _picker.pickImage(
source: ImageSource.camera,
imageQuality: 80,
);
return file?.path;
});
}
}

View File

@@ -0,0 +1,22 @@
import 'package:file_picker/file_picker.dart';
import 'package:krow_domain/krow_domain.dart';
/// Service for picking files from the device filesystem.
class FilePickerService extends BaseDeviceService {
/// Creates a [FilePickerService].
const FilePickerService();
/// Picks a single file from the device.
///
/// Returns the path to the selected file, or null if cancelled.
Future<String?> pickFile({List<String>? allowedExtensions}) async {
return action(() async {
final FilePickerResult? result = await FilePicker.platform.pickFiles(
type: allowedExtensions != null ? FileType.custom : FileType.any,
allowedExtensions: allowedExtensions,
);
return result?.files.single.path;
});
}
}

View File

@@ -0,0 +1,60 @@
import 'package:krow_domain/krow_domain.dart';
import '../camera/camera_service.dart';
import '../gallery/gallery_service.dart';
import '../../api_service/core_api_services/file_upload/file_upload_service.dart';
import '../../api_service/core_api_services/file_upload/file_upload_response.dart';
/// Orchestrator service that combines device picking and network uploading.
///
/// This provides a simplified entry point for features to "pick and upload"
/// in a single call.
class DeviceFileUploadService extends BaseDeviceService {
/// Creates a [DeviceFileUploadService].
DeviceFileUploadService({
required this.cameraService,
required this.galleryService,
required this.apiUploadService,
});
final CameraService cameraService;
final GalleryService galleryService;
final FileUploadService apiUploadService;
/// Captures a photo from the camera and uploads it immediately.
Future<FileUploadResponse?> uploadFromCamera({
required String fileName,
FileVisibility visibility = FileVisibility.private,
String? category,
}) async {
return action(() async {
final String? path = await cameraService.takePhoto();
if (path == null) return null;
return apiUploadService.uploadFile(
filePath: path,
fileName: fileName,
visibility: visibility,
category: category,
);
});
}
/// Picks an image from the gallery and uploads it immediately.
Future<FileUploadResponse?> uploadFromGallery({
required String fileName,
FileVisibility visibility = FileVisibility.private,
String? category,
}) async {
return action(() async {
final String? path = await galleryService.pickImage();
if (path == null) return null;
return apiUploadService.uploadFile(
filePath: path,
fileName: fileName,
visibility: visibility,
category: category,
);
});
}
}

View File

@@ -0,0 +1,23 @@
import 'package:image_picker/image_picker.dart';
import 'package:krow_domain/krow_domain.dart';
/// Service for picking media from the device gallery.
class GalleryService extends BaseDeviceService {
/// Creates a [GalleryService].
GalleryService(this._picker);
final ImagePicker _picker;
/// Picks an image from the gallery.
///
/// Returns the path to the selected image, or null if cancelled.
Future<String?> pickImage() async {
return action(() async {
final XFile? file = await _picker.pickImage(
source: ImageSource.gallery,
imageQuality: 80,
);
return file?.path;
});
}
}

View File

@@ -11,10 +11,18 @@ environment:
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^8.1.0
design_system:
path: ../design_system
equatable: ^2.0.8
flutter_modular: ^6.4.1
# internal packages
krow_domain:
path: ../domain
design_system:
path: ../design_system
flutter_bloc: ^8.1.0
equatable: ^2.0.8
flutter_modular: ^6.4.1
dio: ^5.9.1
image_picker: ^1.1.2
path_provider: ^2.1.3
file_picker: ^8.1.7
firebase_auth: ^6.1.4

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

@@ -1,8 +1,7 @@
// 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, implementation_imports
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_data_connect/krow_data_connect.dart'
hide AttireVerificationStatus;
import 'package:krow_domain/krow_domain.dart';
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart' as domain;
import '../../domain/repositories/staff_connector_repository.dart';
/// Implementation of [StaffConnectorRepository].
///
@@ -12,10 +11,10 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
/// Creates a new [StaffConnectorRepositoryImpl].
///
/// Requires a [DataConnectService] instance for backend communication.
StaffConnectorRepositoryImpl({DataConnectService? service})
: _service = service ?? DataConnectService.instance;
StaffConnectorRepositoryImpl({dc.DataConnectService? service})
: _service = service ?? dc.DataConnectService.instance;
final DataConnectService _service;
final dc.DataConnectService _service;
@override
Future<bool> getProfileCompletion() async {
@@ -23,17 +22,17 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
final String staffId = await _service.getStaffId();
final QueryResult<
GetStaffProfileCompletionData,
GetStaffProfileCompletionVariables
dc.GetStaffProfileCompletionData,
dc.GetStaffProfileCompletionVariables
>
response = await _service.connector
.getStaffProfileCompletion(id: staffId)
.execute();
final GetStaffProfileCompletionStaff? staff = response.data.staff;
final List<GetStaffProfileCompletionEmergencyContacts> emergencyContacts =
response.data.emergencyContacts;
final List<GetStaffProfileCompletionTaxForms> taxForms =
final dc.GetStaffProfileCompletionStaff? staff = response.data.staff;
final List<dc.GetStaffProfileCompletionEmergencyContacts>
emergencyContacts = response.data.emergencyContacts;
final List<dc.GetStaffProfileCompletionTaxForms> taxForms =
response.data.taxForms;
return _isProfileComplete(staff, emergencyContacts, taxForms);
@@ -46,15 +45,14 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
final String staffId = await _service.getStaffId();
final QueryResult<
GetStaffPersonalInfoCompletionData,
GetStaffPersonalInfoCompletionVariables
dc.GetStaffPersonalInfoCompletionData,
dc.GetStaffPersonalInfoCompletionVariables
>
response = await _service.connector
.getStaffPersonalInfoCompletion(id: staffId)
.execute();
final GetStaffPersonalInfoCompletionStaff? staff = response.data.staff;
final dc.GetStaffPersonalInfoCompletionStaff? staff = response.data.staff;
return _isPersonalInfoComplete(staff);
});
}
@@ -65,8 +63,8 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
final String staffId = await _service.getStaffId();
final QueryResult<
GetStaffEmergencyProfileCompletionData,
GetStaffEmergencyProfileCompletionVariables
dc.GetStaffEmergencyProfileCompletionData,
dc.GetStaffEmergencyProfileCompletionVariables
>
response = await _service.connector
.getStaffEmergencyProfileCompletion(id: staffId)
@@ -82,16 +80,15 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
final String staffId = await _service.getStaffId();
final QueryResult<
GetStaffExperienceProfileCompletionData,
GetStaffExperienceProfileCompletionVariables
dc.GetStaffExperienceProfileCompletionData,
dc.GetStaffExperienceProfileCompletionVariables
>
response = await _service.connector
.getStaffExperienceProfileCompletion(id: staffId)
.execute();
final GetStaffExperienceProfileCompletionStaff? staff =
final dc.GetStaffExperienceProfileCompletionStaff? staff =
response.data.staff;
return _hasExperience(staff);
});
}
@@ -102,8 +99,8 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
final String staffId = await _service.getStaffId();
final QueryResult<
GetStaffTaxFormsProfileCompletionData,
GetStaffTaxFormsProfileCompletionVariables
dc.GetStaffTaxFormsProfileCompletionData,
dc.GetStaffTaxFormsProfileCompletionVariables
>
response = await _service.connector
.getStaffTaxFormsProfileCompletion(id: staffId)
@@ -114,148 +111,162 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
}
/// Checks if personal info is complete.
bool _isPersonalInfoComplete(GetStaffPersonalInfoCompletionStaff? staff) {
bool _isPersonalInfoComplete(dc.GetStaffPersonalInfoCompletionStaff? staff) {
if (staff == null) return false;
final String fullName = staff.fullName;
final String? email = staff.email;
final String? phone = staff.phone;
return (fullName.trim().isNotEmpty ?? false) &&
return fullName.trim().isNotEmpty &&
(email?.trim().isNotEmpty ?? false) &&
(phone?.trim().isNotEmpty ?? false);
}
/// Checks if staff has experience data (skills or industries).
bool _hasExperience(GetStaffExperienceProfileCompletionStaff? staff) {
bool _hasExperience(dc.GetStaffExperienceProfileCompletionStaff? staff) {
if (staff == null) return false;
final dynamic skills = staff.skills;
final dynamic industries = staff.industries;
return (skills is List && skills.isNotEmpty) ||
(industries is List && industries.isNotEmpty);
final List<String>? skills = staff.skills;
final List<String>? industries = staff.industries;
return (skills?.isNotEmpty ?? false) || (industries?.isNotEmpty ?? false);
}
/// Determines if the profile is complete based on all sections.
bool _isProfileComplete(
GetStaffProfileCompletionStaff? staff,
List<GetStaffProfileCompletionEmergencyContacts> emergencyContacts,
List<GetStaffProfileCompletionTaxForms> taxForms,
dc.GetStaffProfileCompletionStaff? staff,
List<dc.GetStaffProfileCompletionEmergencyContacts> emergencyContacts,
List<dc.GetStaffProfileCompletionTaxForms> taxForms,
) {
if (staff == null) return false;
final dynamic skills = staff.skills;
final dynamic industries = staff.industries;
final List<String>? skills = staff.skills;
final List<String>? industries = staff.industries;
final bool hasExperience =
(skills is List && skills.isNotEmpty) ||
(industries is List && industries.isNotEmpty);
return emergencyContacts.isNotEmpty && taxForms.isNotEmpty && hasExperience;
(skills?.isNotEmpty ?? false) || (industries?.isNotEmpty ?? false);
return (staff.fullName.trim().isNotEmpty) &&
(staff.email?.trim().isNotEmpty ?? false) &&
emergencyContacts.isNotEmpty &&
taxForms.isNotEmpty &&
hasExperience;
}
@override
Future<Staff> getStaffProfile() async {
Future<domain.Staff> getStaffProfile() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<GetStaffByIdData, GetStaffByIdVariables> response =
await _service.connector.getStaffById(id: staffId).execute();
final QueryResult<dc.GetStaffByIdData, dc.GetStaffByIdVariables>
response = await _service.connector.getStaffById(id: staffId).execute();
if (response.data.staff == null) {
throw const ServerException(technicalMessage: 'Staff not found');
final dc.GetStaffByIdStaff? staff = response.data.staff;
if (staff == null) {
throw Exception('Staff not found');
}
final GetStaffByIdStaff rawStaff = response.data.staff!;
// Map the raw data connect object to the Domain Entity
return Staff(
id: rawStaff.id,
authProviderId: rawStaff.userId,
name: rawStaff.fullName,
email: rawStaff.email ?? '',
phone: rawStaff.phone,
avatar: rawStaff.photoUrl,
status: StaffStatus.active,
address: rawStaff.addres,
totalShifts: rawStaff.totalShifts,
averageRating: rawStaff.averageRating,
onTimeRate: rawStaff.onTimeRate,
noShowCount: rawStaff.noShowCount,
cancellationCount: rawStaff.cancellationCount,
reliabilityScore: rawStaff.reliabilityScore,
return domain.Staff(
id: staff.id,
authProviderId: staff.userId,
name: staff.fullName,
email: staff.email ?? '',
phone: staff.phone,
avatar: staff.photoUrl,
status: domain.StaffStatus.active,
address: staff.addres,
totalShifts: staff.totalShifts,
averageRating: staff.averageRating,
onTimeRate: staff.onTimeRate,
noShowCount: staff.noShowCount,
cancellationCount: staff.cancellationCount,
reliabilityScore: staff.reliabilityScore,
);
});
}
@override
Future<List<Benefit>> getBenefits() async {
Future<List<domain.Benefit>> getBenefits() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<
ListBenefitsDataByStaffIdData,
ListBenefitsDataByStaffIdVariables
dc.ListBenefitsDataByStaffIdData,
dc.ListBenefitsDataByStaffIdVariables
>
response = await _service.connector
.listBenefitsDataByStaffId(staffId: staffId)
.execute();
return response.data.benefitsDatas.map((data) {
final plan = data.vendorBenefitPlan;
return Benefit(
title: plan.title,
entitlementHours: plan.total?.toDouble() ?? 0.0,
usedHours: data.current.toDouble(),
);
}).toList();
return response.data.benefitsDatas
.map(
(dc.ListBenefitsDataByStaffIdBenefitsDatas e) => domain.Benefit(
title: e.vendorBenefitPlan.title,
entitlementHours: e.vendorBenefitPlan.total?.toDouble() ?? 0,
usedHours: e.current.toDouble(),
),
)
.toList();
});
}
@override
Future<List<AttireItem>> getAttireOptions() async {
Future<List<domain.AttireItem>> getAttireOptions() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
// Fetch all options
final QueryResult<ListAttireOptionsData, void> optionsResponse =
await _service.connector.listAttireOptions().execute();
final List<QueryResult<Object, Object?>> results =
await Future.wait<QueryResult<Object, Object?>>(
<Future<QueryResult<Object, Object?>>>[
_service.connector.listAttireOptions().execute(),
_service.connector.getStaffAttire(staffId: staffId).execute(),
],
);
// Fetch user's attire status
final QueryResult<GetStaffAttireData, GetStaffAttireVariables>
attiresResponse = await _service.connector
.getStaffAttire(staffId: staffId)
.execute();
final QueryResult<dc.ListAttireOptionsData, void> optionsRes =
results[0] as QueryResult<dc.ListAttireOptionsData, void>;
final QueryResult<dc.GetStaffAttireData, dc.GetStaffAttireVariables>
staffAttireRes =
results[1]
as QueryResult<dc.GetStaffAttireData, dc.GetStaffAttireVariables>;
final Map<String, GetStaffAttireStaffAttires> attireMap = {
for (final item in attiresResponse.data.staffAttires)
item.attireOptionId: item,
};
final List<dc.GetStaffAttireStaffAttires> staffAttire =
staffAttireRes.data.staffAttires;
return optionsResponse.data.attireOptions.map((e) {
final GetStaffAttireStaffAttires? userAttire = attireMap[e.id];
return AttireItem(
id: e.itemId,
label: e.label,
description: e.description,
imageUrl: e.imageUrl,
isMandatory: e.isMandatory ?? false,
verificationStatus: _mapAttireStatus(
userAttire?.verificationStatus?.stringValue,
return optionsRes.data.attireOptions.map((
dc.ListAttireOptionsAttireOptions opt,
) {
final dc.GetStaffAttireStaffAttires currentAttire = staffAttire
.firstWhere(
(dc.GetStaffAttireStaffAttires a) => a.attireOptionId == opt.id,
orElse: () => dc.GetStaffAttireStaffAttires(
attireOptionId: opt.id,
verificationPhotoUrl: null,
verificationId: null,
verificationStatus: null,
),
photoUrl: userAttire?.verificationPhotoUrl,
);
return domain.AttireItem(
id: opt.id,
code: opt.itemId,
label: opt.label,
description: opt.description,
imageUrl: opt.imageUrl,
isMandatory: opt.isMandatory ?? false,
photoUrl: currentAttire.verificationPhotoUrl,
verificationId: currentAttire.verificationId,
verificationStatus: currentAttire.verificationStatus != null
? _mapFromDCStatus(currentAttire.verificationStatus!)
: null,
);
}).toList();
});
}
AttireVerificationStatus? _mapAttireStatus(String? status) {
if (status == null) return null;
return AttireVerificationStatus.values.firstWhere(
(e) => e.name.toUpperCase() == status.toUpperCase(),
orElse: () => AttireVerificationStatus.pending,
);
}
@override
Future<void> upsertStaffAttire({
required String attireOptionId,
required String photoUrl,
String? verificationId,
domain.AttireVerificationStatus? verificationStatus,
}) async {
await _service.run(() async {
final String staffId = await _service.getStaffId();
@@ -263,7 +274,68 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
await _service.connector
.upsertStaffAttire(staffId: staffId, attireOptionId: attireOptionId)
.verificationPhotoUrl(photoUrl)
// .verificationId(verificationId) // Uncomment after SDK regeneration
.verificationId(verificationId)
.verificationStatus(
verificationStatus != null
? dc.AttireVerificationStatus.values.firstWhere(
(dc.AttireVerificationStatus e) =>
e.name == verificationStatus.value.toUpperCase(),
orElse: () => dc.AttireVerificationStatus.PENDING,
)
: null,
)
.execute();
});
}
domain.AttireVerificationStatus _mapFromDCStatus(
dc.EnumValue<dc.AttireVerificationStatus> status,
) {
if (status is dc.Unknown) {
return domain.AttireVerificationStatus.error;
}
final String name =
(status as dc.Known<dc.AttireVerificationStatus>).value.name;
switch (name) {
case 'PENDING':
return domain.AttireVerificationStatus.pending;
case 'PROCESSING':
return domain.AttireVerificationStatus.processing;
case 'AUTO_PASS':
return domain.AttireVerificationStatus.autoPass;
case 'AUTO_FAIL':
return domain.AttireVerificationStatus.autoFail;
case 'NEEDS_REVIEW':
return domain.AttireVerificationStatus.needsReview;
case 'APPROVED':
return domain.AttireVerificationStatus.approved;
case 'REJECTED':
return domain.AttireVerificationStatus.rejected;
case 'ERROR':
return domain.AttireVerificationStatus.error;
default:
return domain.AttireVerificationStatus.error;
}
}
@override
Future<void> saveStaffProfile({
String? firstName,
String? lastName,
String? bio,
String? profilePictureUrl,
}) async {
await _service.run(() async {
final String staffId = await _service.getStaffId();
final String? fullName = (firstName != null || lastName != null)
? '${firstName ?? ''} ${lastName ?? ''}'.trim()
: null;
await _service.connector
.updateStaff(id: staffId)
.fullName(fullName)
.bio(bio)
.photoUrl(profilePictureUrl)
.execute();
});
}

View File

@@ -55,6 +55,7 @@ abstract interface class StaffConnectorRepository {
required String attireOptionId,
required String photoUrl,
String? verificationId,
AttireVerificationStatus? verificationStatus,
});
/// Signs out the current user.
@@ -63,4 +64,12 @@ abstract interface class StaffConnectorRepository {
///
/// Throws an exception if the sign-out fails.
Future<void> signOut();
/// Saves the staff profile information.
Future<void> saveStaffProfile({
String? firstName,
String? lastName,
String? bio,
String? profilePictureUrl,
});
}

View File

@@ -11,6 +11,7 @@ class UiTextField extends StatelessWidget {
const UiTextField({
super.key,
this.semanticsIdentifier,
this.label,
this.hintText,
this.onChanged,
@@ -29,6 +30,8 @@ class UiTextField extends StatelessWidget {
this.onTap,
this.validator,
});
/// Optional semantics identifier for E2E testing (e.g. Maestro).
final String? semanticsIdentifier;
/// The label text to display above the text field.
final String? label;
@@ -90,7 +93,9 @@ class UiTextField extends StatelessWidget {
Text(label!, style: UiTypography.body4m.textSecondary),
const SizedBox(height: UiConstants.space1),
],
TextFormField(
Builder(
builder: (BuildContext context) {
final Widget field = TextFormField(
controller: controller,
onChanged: onChanged,
keyboardType: keyboardType,
@@ -113,6 +118,15 @@ class UiTextField extends StatelessWidget {
? Icon(suffixIcon, size: 20, color: UiColors.iconSecondary)
: suffix,
),
);
if (semanticsIdentifier != null) {
return Semantics(
identifier: semanticsIdentifier!,
child: field,
);
}
return field;
},
),
],
);

View File

@@ -6,6 +6,15 @@
/// Note: Repository Interfaces are now located in their respective Feature packages.
library;
// Core
export 'src/core/services/api_services/api_response.dart';
export 'src/core/services/api_services/base_api_service.dart';
export 'src/core/services/api_services/base_core_service.dart';
export 'src/core/services/api_services/file_visibility.dart';
// Device
export 'src/core/services/device/base_device_service.dart';
// Users & Membership
export 'src/entities/users/user.dart';
export 'src/entities/users/staff.dart';
@@ -19,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 @@
/// Represents a standardized response from the API.
class ApiResponse {
/// Creates an [ApiResponse].
const ApiResponse({
required this.code,
required this.message,
this.data,
this.errors = const <String, dynamic>{},
});
/// The response code (e.g., '200', '404', or custom error code).
final String code;
/// A descriptive message about the response.
final String message;
/// The payload returned by the API.
final dynamic data;
/// A map of field-specific error messages, if any.
final Map<String, dynamic> errors;
}

View File

@@ -0,0 +1,30 @@
import 'api_response.dart';
/// Abstract base class for API services.
///
/// This defines the contract for making HTTP requests.
abstract class BaseApiService {
/// Performs a GET request to the specified [endpoint].
Future<ApiResponse> get(String endpoint, {Map<String, dynamic>? params});
/// Performs a POST request to the specified [endpoint].
Future<ApiResponse> post(
String endpoint, {
dynamic data,
Map<String, dynamic>? params,
});
/// Performs a PUT request to the specified [endpoint].
Future<ApiResponse> put(
String endpoint, {
dynamic data,
Map<String, dynamic>? params,
});
/// Performs a PATCH request to the specified [endpoint].
Future<ApiResponse> patch(
String endpoint, {
dynamic data,
Map<String, dynamic>? params,
});
}

View File

@@ -0,0 +1,29 @@
import 'api_response.dart';
import 'base_api_service.dart';
/// Abstract base class for core business services.
///
/// This provides a common [action] wrapper for standardized execution
/// and error catching across all core service implementations.
abstract class BaseCoreService {
/// Creates a [BaseCoreService] with the given [api] client.
const BaseCoreService(this.api);
/// The API client used to perform requests.
final BaseApiService api;
/// Standardized wrapper to execute API actions.
///
/// This handles generic error normalization for unexpected non-HTTP errors.
Future<ApiResponse> action(Future<ApiResponse> Function() execution) async {
try {
return await execution();
} catch (e) {
return ApiResponse(
code: 'CORE_INTERNAL_ERROR',
message: e.toString(),
errors: <String, dynamic>{'exception': e.runtimeType.toString()},
);
}
}
}

View File

@@ -0,0 +1,14 @@
/// Represents the accessibility level of an uploaded file.
enum FileVisibility {
/// File is accessible only to authenticated owners/authorized users.
private('private'),
/// File is accessible publicly via its URL.
public('public');
/// Creates a [FileVisibility].
const FileVisibility(this.value);
/// The string value expected by the backend.
final String value;
}

View File

@@ -0,0 +1,22 @@
/// Abstract base class for device-related services.
///
/// Device services handle native hardware/platform interactions
/// like Camera, Gallery, Location, or Biometrics.
abstract class BaseDeviceService {
const BaseDeviceService();
/// Standardized wrapper to execute device actions.
///
/// This can be used for common handling like logging device interactions
/// or catching native platform exceptions.
Future<T> action<T>(Future<T> Function() execution) async {
try {
return await execution();
} catch (e) {
// Re-throw or handle based on project preference.
// For device services, we might want to throw specific
// DeviceExceptions later.
rethrow;
}
}
}

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

@@ -9,6 +9,7 @@ class AttireItem extends Equatable {
/// Creates an [AttireItem].
const AttireItem({
required this.id,
required this.code,
required this.label,
this.description,
this.imageUrl,
@@ -18,9 +19,12 @@ class AttireItem extends Equatable {
this.verificationId,
});
/// Unique identifier of the attire item.
/// Unique identifier of the attire item (UUID).
final String id;
/// String code for the attire item (e.g. BLACK_TSHIRT).
final String code;
/// Display name of the item.
final String label;
@@ -45,6 +49,7 @@ class AttireItem extends Equatable {
@override
List<Object?> get props => <Object?>[
id,
code,
label,
description,
imageUrl,
@@ -53,4 +58,29 @@ class AttireItem extends Equatable {
photoUrl,
verificationId,
];
/// Creates a copy of this [AttireItem] with the given fields replaced.
AttireItem copyWith({
String? id,
String? code,
String? label,
String? description,
String? imageUrl,
bool? isMandatory,
AttireVerificationStatus? verificationStatus,
String? photoUrl,
String? verificationId,
}) {
return AttireItem(
id: id ?? this.id,
code: code ?? this.code,
label: label ?? this.label,
description: description ?? this.description,
imageUrl: imageUrl ?? this.imageUrl,
isMandatory: isMandatory ?? this.isMandatory,
verificationStatus: verificationStatus ?? this.verificationStatus,
photoUrl: photoUrl ?? this.photoUrl,
verificationId: verificationId ?? this.verificationId,
);
}
}

View File

@@ -1,11 +1,39 @@
/// Represents the verification status of an attire item photo.
enum AttireVerificationStatus {
/// The photo is waiting for review.
pending,
/// Job is created and waiting to be processed.
pending('PENDING'),
/// The photo was rejected.
failed,
/// Job is currently being processed by machine or human.
processing('PROCESSING'),
/// The photo was approved.
success,
/// Machine verification passed automatically.
autoPass('AUTO_PASS'),
/// Machine verification failed automatically.
autoFail('AUTO_FAIL'),
/// Machine results are inconclusive and require human review.
needsReview('NEEDS_REVIEW'),
/// Human reviewer approved the verification.
approved('APPROVED'),
/// Human reviewer rejected the verification.
rejected('REJECTED'),
/// An error occurred during processing.
error('ERROR');
const AttireVerificationStatus(this.value);
/// The string value expected by the Core API.
final String value;
/// Creates a [AttireVerificationStatus] from a string.
static AttireVerificationStatus fromString(String value) {
return AttireVerificationStatus.values.firstWhere(
(AttireVerificationStatus e) => e.value == value,
orElse: () => AttireVerificationStatus.error,
);
}
}

View File

@@ -52,6 +52,7 @@ class _ClientSignInFormState extends State<ClientSignInForm> {
children: <Widget>[
// Email Field
UiTextField(
semanticsIdentifier: 'sign_in_email',
label: i18n.email_label,
hintText: i18n.email_hint,
controller: _emailController,
@@ -61,6 +62,7 @@ class _ClientSignInFormState extends State<ClientSignInForm> {
// Password Field
UiTextField(
semanticsIdentifier: 'sign_in_password',
label: i18n.password_label,
hintText: i18n.password_hint,
controller: _passwordController,

View File

@@ -70,6 +70,7 @@ class _ClientSignUpFormState extends State<ClientSignUpForm> {
children: <Widget>[
// Company Name Field
UiTextField(
semanticsIdentifier: 'sign_up_company',
label: i18n.company_label,
hintText: i18n.company_hint,
controller: _companyController,
@@ -79,6 +80,7 @@ class _ClientSignUpFormState extends State<ClientSignUpForm> {
// Email Field
UiTextField(
semanticsIdentifier: 'sign_up_email',
label: i18n.email_label,
hintText: i18n.email_hint,
controller: _emailController,
@@ -89,6 +91,7 @@ class _ClientSignUpFormState extends State<ClientSignUpForm> {
// Password Field
UiTextField(
semanticsIdentifier: 'sign_up_password',
label: i18n.password_label,
hintText: i18n.password_hint,
controller: _passwordController,
@@ -108,6 +111,7 @@ class _ClientSignUpFormState extends State<ClientSignUpForm> {
// Confirm Password Field
UiTextField(
semanticsIdentifier: 'sign_up_confirm_password',
label: i18n.confirm_password_label,
hintText: i18n.confirm_password_hint,
controller: _confirmPasswordController,

View File

@@ -46,7 +46,7 @@ class SavingsCard extends StatelessWidget {
const SizedBox(height: UiConstants.space1),
Text(
// Using a hardcoded 180 here to match prototype mock or derived value
t.client_billing.rate_optimization_body(amount: 180),
"180",
style: UiTypography.footnote2r.textSecondary,
),
const SizedBox(height: UiConstants.space2),

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;
@@ -35,6 +36,9 @@ class CreateHubArguments extends UseCaseArgument {
final String? country;
final String? zipCode;
/// The cost center of the hub.
final String? costCenterId;
@override
List<Object?> get props => <Object?>[
name,
@@ -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,7 +151,8 @@ class ClientHubsPage extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Column(
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
@@ -180,6 +167,20 @@ class ClientHubsPage extends StatelessWidget {
),
],
),
),
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;
// 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,
),
);
}
},
onSave: _onSave,
isSaving: isSaving,
isEdit: widget.hub != null,
),
),
],
),
),
// ── 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
@@ -64,27 +71,29 @@ class _HubFormDialogState extends State<HubFormDialog> {
Widget build(BuildContext context) {
final bool isEditing = widget.hub != null;
final String title = isEditing
? 'Edit Hub' // TODO: localize
? 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(
return Center(
child: Container(
width: MediaQuery.of(context).size.width * 0.9,
padding: const EdgeInsets.all(UiConstants.space5),
margin: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
decoration: BoxDecoration(
color: UiColors.bgPopup,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
boxShadow: const <BoxShadow>[
BoxShadow(color: UiColors.popupShadow, blurRadius: 20),
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 3),
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.15),
blurRadius: 30,
offset: const Offset(0, 10),
),
],
),
padding: const EdgeInsets.all(UiConstants.space6),
child: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
@@ -93,16 +102,22 @@ class _HubFormDialogState extends State<HubFormDialog> {
children: <Widget>[
Text(
title,
style: UiTypography.headline3m.textPrimary,
style: UiTypography.headline3m.textPrimary.copyWith(
fontSize: 20,
),
),
const SizedBox(height: UiConstants.space5),
_buildFieldLabel(t.client_hubs.add_hub_dialog.name_label),
// ── 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 'Name is required';
return t.client_hubs.add_hub_dialog.name_required;
}
return null;
},
@@ -110,21 +125,90 @@ class _HubFormDialogState extends State<HubFormDialog> {
t.client_hubs.add_hub_dialog.name_hint,
),
),
const SizedBox(height: UiConstants.space4),
_buildFieldLabel(t.client_hubs.add_hub_dialog.address_label),
// ── 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),
// ── 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,
),
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,
),
@@ -132,16 +216,31 @@ class _HubFormDialogState extends State<HubFormDialog> {
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: 'Address is required', type: UiSnackbarType.error);
UiSnackbar.show(
context,
message: t.client_hubs.add_hub_dialog.address_required,
type: UiSnackbarType.error,
);
return;
}
widget.onSave(
_nameController.text,
_addressController.text,
name: _nameController.text.trim(),
address: _addressController.text.trim(),
costCenterId: _selectedCostCenterId,
placeId: _selectedPrediction?.placeId,
latitude: double.tryParse(
_selectedPrediction?.lat ?? '',
@@ -162,39 +261,90 @@ 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];
}

Some files were not shown because too many files have changed in this diff Show More