seed data

This commit is contained in:
2026-02-27 15:41:19 +05:30
247 changed files with 17887 additions and 2097 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,11 +43,11 @@ void main() async {
/// The main application module.
class AppModule extends Module {
@override
List<Module> get imports =>
<Module>[
core_localization.LocalizationModule(),
staff_authentication.StaffAuthenticationModule(),
];
List<Module> get imports => <Module>[
CoreModule(),
core_localization.LocalizationModule(),
staff_authentication.StaffAuthenticationModule(),
];
@override
void routes(RouteManager r) {

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

@@ -1,4 +1,5 @@
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_domain/krow_domain.dart';
import 'route_paths.dart';
@@ -134,6 +135,11 @@ extension ClientNavigator on IModularNavigator {
pushNamed(ClientPaths.settings);
}
/// Pushes the edit profile page.
void toClientEditProfile() {
pushNamed('${ClientPaths.settings}/edit-profile');
}
// ==========================================================================
// HUBS MANAGEMENT
// ==========================================================================
@@ -145,6 +151,25 @@ extension ClientNavigator on IModularNavigator {
await pushNamed(ClientPaths.hubs);
}
/// Navigates to the details of a specific hub.
Future<bool?> toHubDetails(Hub hub) {
return pushNamed<bool?>(
ClientPaths.hubDetails,
arguments: <String, dynamic>{'hub': hub},
);
}
/// Navigates to the page to add a new hub or edit an existing one.
Future<bool?> toEditHub({Hub? hub}) async {
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.
);
}
// ==========================================================================
// ORDER CREATION
// ==========================================================================

View File

@@ -16,7 +16,7 @@ class ClientPaths {
/// Generate child route based on the given route and parent route
///
/// This is useful for creating nested routes within modules.
static String childRoute(String parent, String child) {
static String childRoute(String parent, String child) {
final String childPath = child.replaceFirst(parent, '');
// check if the child path is empty
@@ -82,10 +82,12 @@ class ClientPaths {
static const String billing = '/client-main/billing';
/// Completion review page - review shift completion records.
static const String completionReview = '/client-main/billing/completion-review';
static const String completionReview =
'/client-main/billing/completion-review';
/// Full list of invoices awaiting approval.
static const String awaitingApproval = '/client-main/billing/awaiting-approval';
static const String awaitingApproval =
'/client-main/billing/awaiting-approval';
/// Invoice ready page - view status of approved invoices.
static const String invoiceReady = '/client-main/billing/invoice-ready';
@@ -118,6 +120,12 @@ class ClientPaths {
/// View and manage physical locations/hubs where staff are deployed.
static const String hubs = '/client-hubs';
/// Specific hub details.
static const String hubDetails = '/client-hubs/details';
/// Page for adding or editing a hub.
static const String editHub = '/client-hubs/edit';
// ==========================================================================
// ORDER CREATION & MANAGEMENT
// ==========================================================================

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",
@@ -1039,7 +1094,7 @@
}
},
"staff_profile_attire": {
"title": "Attire",
"title": "Verify Attire",
"info_card": {
"title": "Your Wardrobe",
"description": "Select the attire items you own. This helps us match you with shifts that fit your wardrobe."

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",
@@ -1039,7 +1094,7 @@
}
},
"staff_profile_attire": {
"title": "Vestimenta",
"title": "Verificar Vestimenta",
"info_card": {
"title": "Tu Vestuario",
"description": "Selecciona los art\u00edculos de vestimenta que posees. Esto nos ayuda a asignarte turnos que se ajusten a tu vestuario."

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,7 +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';
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].
///
@@ -11,27 +11,28 @@ 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 {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<GetStaffProfileCompletionData,
GetStaffProfileCompletionVariables> response =
await _service.connector
.getStaffProfileCompletion(id: staffId)
.execute();
final QueryResult<
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);
@@ -43,14 +44,15 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<GetStaffPersonalInfoCompletionData,
GetStaffPersonalInfoCompletionVariables> response =
await _service.connector
.getStaffPersonalInfoCompletion(id: staffId)
.execute();
final GetStaffPersonalInfoCompletionStaff? staff = response.data.staff;
final QueryResult<
dc.GetStaffPersonalInfoCompletionData,
dc.GetStaffPersonalInfoCompletionVariables
>
response = await _service.connector
.getStaffPersonalInfoCompletion(id: staffId)
.execute();
final dc.GetStaffPersonalInfoCompletionStaff? staff = response.data.staff;
return _isPersonalInfoComplete(staff);
});
}
@@ -60,11 +62,13 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<GetStaffEmergencyProfileCompletionData,
GetStaffEmergencyProfileCompletionVariables> response =
await _service.connector
.getStaffEmergencyProfileCompletion(id: staffId)
.execute();
final QueryResult<
dc.GetStaffEmergencyProfileCompletionData,
dc.GetStaffEmergencyProfileCompletionVariables
>
response = await _service.connector
.getStaffEmergencyProfileCompletion(id: staffId)
.execute();
return response.data.emergencyContacts.isNotEmpty;
});
@@ -75,15 +79,16 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<GetStaffExperienceProfileCompletionData,
GetStaffExperienceProfileCompletionVariables> response =
await _service.connector
.getStaffExperienceProfileCompletion(id: staffId)
.execute();
final QueryResult<
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);
});
}
@@ -93,113 +98,248 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<GetStaffTaxFormsProfileCompletionData,
GetStaffTaxFormsProfileCompletionVariables> response =
await _service.connector
.getStaffTaxFormsProfileCompletion(id: staffId)
.execute();
final QueryResult<
dc.GetStaffTaxFormsProfileCompletionData,
dc.GetStaffTaxFormsProfileCompletionVariables
>
response = await _service.connector
.getStaffTaxFormsProfileCompletion(id: staffId)
.execute();
return response.data.taxForms.isNotEmpty;
});
}
/// 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 &&
(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> response =
await _service.connector
.listBenefitsDataByStaffId(staffId: staffId)
.execute();
final QueryResult<
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(),
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<domain.AttireItem>> getAttireOptions() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
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(),
],
);
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 List<dc.GetStaffAttireStaffAttires> staffAttire =
staffAttireRes.data.staffAttires;
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,
),
);
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();
});
}
@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();
await _service.connector
.upsertStaffAttire(staffId: staffId, attireOptionId: attireOptionId)
.verificationPhotoUrl(photoUrl)
.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();
});
}
@override
Future<void> signOut() async {
try {
@@ -210,4 +350,3 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
}
}
}

View File

@@ -45,10 +45,31 @@ abstract interface class StaffConnectorRepository {
/// Returns a list of [Benefit] entities.
Future<List<Benefit>> getBenefits();
/// Fetches the attire options for the current authenticated user.
///
/// Returns a list of [AttireItem] entities.
Future<List<AttireItem>> getAttireOptions();
/// Upserts staff attire photo information.
Future<void> upsertStaffAttire({
required String attireOptionId,
required String photoUrl,
String? verificationId,
AttireVerificationStatus? verificationStatus,
});
/// Signs out the current user.
///
/// Clears the user's session and authentication state.
///
/// 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

@@ -276,4 +276,7 @@ class UiIcons {
/// Help circle icon for FAQs
static const IconData helpCircle = _IconLib.helpCircle;
/// Gallery icon for gallery
static const IconData gallery = _IconLib.galleryVertical;
}

View File

@@ -374,7 +374,7 @@ class UiTypography {
/// Body 4 Regular - Font: Instrument Sans, Size: 14, Height: 1.5, Spacing: 0.05 (#121826)
static final TextStyle body4r = _primaryBase.copyWith(
fontWeight: FontWeight.w400,
fontSize: 12,
fontSize: 10,
height: 1.5,
letterSpacing: 0.05,
color: UiColors.textPrimary,

View File

@@ -1,10 +1,6 @@
import 'package:design_system/design_system.dart';
import 'package:design_system/src/ui_typography.dart';
import 'package:flutter/material.dart';
import '../ui_icons.dart';
import 'ui_icon_button.dart';
/// A custom AppBar for the Krow UI design system.
///
/// This widget provides a consistent look and feel for top app bars across the application.
@@ -12,6 +8,7 @@ class UiAppBar extends StatelessWidget implements PreferredSizeWidget {
const UiAppBar({
super.key,
this.title,
this.subtitle,
this.titleWidget,
this.leading,
this.actions,
@@ -25,6 +22,9 @@ class UiAppBar extends StatelessWidget implements PreferredSizeWidget {
/// The title text to display in the app bar.
final String? title;
/// The subtitle text to display in the app bar.
final String? subtitle;
/// A widget to display instead of the title text.
final Widget? titleWidget;
@@ -57,7 +57,19 @@ class UiAppBar extends StatelessWidget implements PreferredSizeWidget {
return AppBar(
title:
titleWidget ??
(title != null ? Text(title!, style: UiTypography.headline4b) : null),
(title != null
? Column(
crossAxisAlignment: centerTitle
? CrossAxisAlignment.center
: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(title!, style: UiTypography.headline4b),
if (subtitle != null)
Text(subtitle!, style: UiTypography.body3r.textSecondary),
],
)
: null),
leading:
leading ??
(showBackButton

View File

@@ -5,6 +5,9 @@ import '../ui_typography.dart';
/// Sizes for the [UiChip] widget.
enum UiChipSize {
// X-Small size (e.g. for tags in tight spaces).
xSmall,
/// Small size (e.g. for tags in tight spaces).
small,
@@ -25,6 +28,9 @@ enum UiChipVariant {
/// Accent style with highlight background.
accent,
/// Desructive style with red background.
destructive,
}
/// A custom chip widget with supports for different sizes, themes, and icons.
@@ -119,6 +125,8 @@ class UiChip extends StatelessWidget {
return UiColors.tagInProgress;
case UiChipVariant.accent:
return UiColors.accent;
case UiChipVariant.destructive:
return UiColors.iconError.withValues(alpha: 0.1);
}
}
@@ -134,11 +142,15 @@ class UiChip extends StatelessWidget {
return UiColors.primary;
case UiChipVariant.accent:
return UiColors.accentForeground;
case UiChipVariant.destructive:
return UiColors.iconError;
}
}
TextStyle _getTextStyle() {
switch (size) {
case UiChipSize.xSmall:
return UiTypography.body4r;
case UiChipSize.small:
return UiTypography.body3r;
case UiChipSize.medium:
@@ -150,6 +162,8 @@ class UiChip extends StatelessWidget {
EdgeInsets _getPadding() {
switch (size) {
case UiChipSize.xSmall:
return const EdgeInsets.symmetric(horizontal: 6, vertical: 4);
case UiChipSize.small:
return const EdgeInsets.symmetric(horizontal: 10, vertical: 6);
case UiChipSize.medium:
@@ -161,6 +175,8 @@ class UiChip extends StatelessWidget {
double _getIconSize() {
switch (size) {
case UiChipSize.xSmall:
return 10;
case UiChipSize.small:
return 12;
case UiChipSize.medium:
@@ -172,6 +188,8 @@ class UiChip extends StatelessWidget {
double _getGap() {
switch (size) {
case UiChipSize.xSmall:
return UiConstants.space1;
case UiChipSize.small:
return UiConstants.space1;
case UiChipSize.medium:

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';
@@ -68,6 +78,7 @@ export 'src/adapters/financial/bank_account/bank_account_adapter.dart';
// Profile
export 'src/entities/profile/staff_document.dart';
export 'src/entities/profile/attire_item.dart';
export 'src/entities/profile/attire_verification_status.dart';
export 'src/entities/profile/relationship_type.dart';
export 'src/entities/profile/industry.dart';
export 'src/entities/profile/tax_form.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

@@ -1,26 +1,35 @@
import 'package:equatable/equatable.dart';
import 'attire_verification_status.dart';
/// Represents an attire item that a staff member might need or possess.
///
/// Attire items are specific clothing or equipment required for jobs.
class AttireItem extends Equatable {
/// Creates an [AttireItem].
const AttireItem({
required this.id,
required this.code,
required this.label,
this.iconName,
this.description,
this.imageUrl,
this.isMandatory = false,
this.verificationStatus,
this.photoUrl,
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;
/// Name of the icon to display (mapped in UI).
final String? iconName;
/// Optional description for the attire item.
final String? description;
/// URL of the reference image.
final String? imageUrl;
@@ -28,6 +37,50 @@ class AttireItem extends Equatable {
/// Whether this item is mandatory for onboarding.
final bool isMandatory;
/// The current verification status of the uploaded photo.
final AttireVerificationStatus? verificationStatus;
/// The URL of the photo uploaded by the staff member.
final String? photoUrl;
/// The ID of the verification record.
final String? verificationId;
@override
List<Object?> get props => <Object?>[id, label, iconName, imageUrl, isMandatory];
List<Object?> get props => <Object?>[
id,
code,
label,
description,
imageUrl,
isMandatory,
verificationStatus,
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

@@ -0,0 +1,39 @@
/// Represents the verification status of an attire item photo.
enum AttireVerificationStatus {
/// 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 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,10 +9,16 @@ 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';
import 'src/presentation/blocs/edit_hub/edit_hub_bloc.dart';
import 'src/presentation/blocs/hub_details/hub_details_bloc.dart';
import 'src/presentation/pages/client_hubs_page.dart';
import 'src/presentation/pages/edit_hub_page.dart';
import 'src/presentation/pages/hub_details_page.dart';
import 'package:krow_domain/krow_domain.dart';
export 'src/presentation/pages/client_hubs_page.dart';
@@ -27,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);
@@ -34,10 +42,47 @@ class ClientHubsModule extends Module {
// BLoCs
i.add<ClientHubsBloc>(ClientHubsBloc.new);
i.add<EditHubBloc>(EditHubBloc.new);
i.add<HubDetailsBloc>(HubDetailsBloc.new);
}
@override
void routes(RouteManager r) {
r.child(ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.hubs), child: (_) => const ClientHubsPage());
r.child(
ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.hubs),
child: (_) => const ClientHubsPage(),
);
r.child(
ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.hubDetails),
child: (_) {
final Map<String, dynamic> data = r.args.data as Map<String, dynamic>;
return HubDetailsPage(
hub: data['hub'] as Hub,
bloc: Modular.get<HubDetailsBloc>(),
);
},
);
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(
hub: data['hub'] as Hub?,
bloc: Modular.get<EditHubBloc>(),
);
},
);
}
}

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

@@ -2,69 +2,25 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/arguments/assign_nfc_tag_arguments.dart';
import '../../domain/arguments/create_hub_arguments.dart';
import '../../domain/arguments/delete_hub_arguments.dart';
import '../../domain/usecases/assign_nfc_tag_usecase.dart';
import '../../domain/usecases/create_hub_usecase.dart';
import '../../domain/usecases/delete_hub_usecase.dart';
import '../../domain/usecases/get_hubs_usecase.dart';
import '../../domain/usecases/update_hub_usecase.dart';
import 'client_hubs_event.dart';
import 'client_hubs_state.dart';
/// BLoC responsible for managing the state of the Client Hubs feature.
///
/// It orchestrates the flow between the UI and the domain layer by invoking
/// specific use cases for fetching, creating, deleting, and assigning tags to hubs.
/// specific use cases for fetching hubs.
class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
with BlocErrorHandler<ClientHubsState>
implements Disposable {
ClientHubsBloc({
required GetHubsUseCase getHubsUseCase,
required CreateHubUseCase createHubUseCase,
required DeleteHubUseCase deleteHubUseCase,
required AssignNfcTagUseCase assignNfcTagUseCase,
required UpdateHubUseCase updateHubUseCase,
}) : _getHubsUseCase = getHubsUseCase,
_createHubUseCase = createHubUseCase,
_deleteHubUseCase = deleteHubUseCase,
_assignNfcTagUseCase = assignNfcTagUseCase,
_updateHubUseCase = updateHubUseCase,
super(const ClientHubsState()) {
ClientHubsBloc({required GetHubsUseCase getHubsUseCase})
: _getHubsUseCase = getHubsUseCase,
super(const ClientHubsState()) {
on<ClientHubsFetched>(_onFetched);
on<ClientHubsAddRequested>(_onAddRequested);
on<ClientHubsUpdateRequested>(_onUpdateRequested);
on<ClientHubsDeleteRequested>(_onDeleteRequested);
on<ClientHubsNfcTagAssignRequested>(_onNfcTagAssignRequested);
on<ClientHubsMessageCleared>(_onMessageCleared);
on<ClientHubsAddDialogToggled>(_onAddDialogToggled);
on<ClientHubsIdentifyDialogToggled>(_onIdentifyDialogToggled);
}
final GetHubsUseCase _getHubsUseCase;
final CreateHubUseCase _createHubUseCase;
final DeleteHubUseCase _deleteHubUseCase;
final AssignNfcTagUseCase _assignNfcTagUseCase;
final UpdateHubUseCase _updateHubUseCase;
void _onAddDialogToggled(
ClientHubsAddDialogToggled event,
Emitter<ClientHubsState> emit,
) {
emit(state.copyWith(showAddHubDialog: event.visible));
}
void _onIdentifyDialogToggled(
ClientHubsIdentifyDialogToggled event,
Emitter<ClientHubsState> emit,
) {
if (event.hub == null) {
emit(state.copyWith(clearHubToIdentify: true));
} else {
emit(state.copyWith(hubToIdentify: event.hub));
}
}
Future<void> _onFetched(
ClientHubsFetched event,
@@ -75,7 +31,7 @@ class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
await handleError(
emit: emit.call,
action: () async {
final List<Hub> hubs = await _getHubsUseCase();
final List<Hub> hubs = await _getHubsUseCase.call();
emit(state.copyWith(status: ClientHubsStatus.success, hubs: hubs));
},
onError: (String errorKey) => state.copyWith(
@@ -85,141 +41,6 @@ class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
);
}
Future<void> _onAddRequested(
ClientHubsAddRequested event,
Emitter<ClientHubsState> emit,
) async {
emit(state.copyWith(status: ClientHubsStatus.actionInProgress));
await handleError(
emit: emit.call,
action: () async {
await _createHubUseCase(
CreateHubArguments(
name: event.name,
address: event.address,
placeId: event.placeId,
latitude: event.latitude,
longitude: event.longitude,
city: event.city,
state: event.state,
street: event.street,
country: event.country,
zipCode: event.zipCode,
),
);
final List<Hub> hubs = await _getHubsUseCase();
emit(
state.copyWith(
status: ClientHubsStatus.actionSuccess,
hubs: hubs,
successMessage: 'Hub created successfully',
showAddHubDialog: false,
),
);
},
onError: (String errorKey) => state.copyWith(
status: ClientHubsStatus.actionFailure,
errorMessage: errorKey,
),
);
}
Future<void> _onUpdateRequested(
ClientHubsUpdateRequested event,
Emitter<ClientHubsState> emit,
) async {
emit(state.copyWith(status: ClientHubsStatus.actionInProgress));
await handleError(
emit: emit.call,
action: () async {
await _updateHubUseCase(
UpdateHubArguments(
id: event.id,
name: event.name,
address: event.address,
placeId: event.placeId,
latitude: event.latitude,
longitude: event.longitude,
city: event.city,
state: event.state,
street: event.street,
country: event.country,
zipCode: event.zipCode,
),
);
final List<Hub> hubs = await _getHubsUseCase();
emit(
state.copyWith(
status: ClientHubsStatus.actionSuccess,
hubs: hubs,
successMessage: 'Hub updated successfully!',
),
);
},
onError: (String errorKey) => state.copyWith(
status: ClientHubsStatus.actionFailure,
errorMessage: errorKey,
),
);
}
Future<void> _onDeleteRequested(
ClientHubsDeleteRequested event,
Emitter<ClientHubsState> emit,
) async {
emit(state.copyWith(status: ClientHubsStatus.actionInProgress));
await handleError(
emit: emit.call,
action: () async {
await _deleteHubUseCase(DeleteHubArguments(hubId: event.hubId));
final List<Hub> hubs = await _getHubsUseCase();
emit(
state.copyWith(
status: ClientHubsStatus.actionSuccess,
hubs: hubs,
successMessage: 'Hub deleted successfully',
),
);
},
onError: (String errorKey) => state.copyWith(
status: ClientHubsStatus.actionFailure,
errorMessage: errorKey,
),
);
}
Future<void> _onNfcTagAssignRequested(
ClientHubsNfcTagAssignRequested event,
Emitter<ClientHubsState> emit,
) async {
emit(state.copyWith(status: ClientHubsStatus.actionInProgress));
await handleError(
emit: emit.call,
action: () async {
await _assignNfcTagUseCase(
AssignNfcTagArguments(hubId: event.hubId, nfcTagId: event.nfcTagId),
);
final List<Hub> hubs = await _getHubsUseCase();
emit(
state.copyWith(
status: ClientHubsStatus.actionSuccess,
hubs: hubs,
successMessage: 'NFC tag assigned successfully',
clearHubToIdentify: true,
),
);
},
onError: (String errorKey) => state.copyWith(
status: ClientHubsStatus.actionFailure,
errorMessage: errorKey,
),
);
}
void _onMessageCleared(
ClientHubsMessageCleared event,
Emitter<ClientHubsState> emit,
@@ -229,8 +50,8 @@ class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
clearErrorMessage: true,
clearSuccessMessage: true,
status:
state.status == ClientHubsStatus.actionSuccess ||
state.status == ClientHubsStatus.actionFailure
state.status == ClientHubsStatus.success ||
state.status == ClientHubsStatus.failure
? ClientHubsStatus.success
: state.status,
),

View File

@@ -1,5 +1,4 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
/// Base class for all client hubs events.
abstract class ClientHubsEvent extends Equatable {
@@ -14,136 +13,7 @@ class ClientHubsFetched extends ClientHubsEvent {
const ClientHubsFetched();
}
/// Event triggered to add a new hub.
class ClientHubsAddRequested extends ClientHubsEvent {
const ClientHubsAddRequested({
required this.name,
required this.address,
this.placeId,
this.latitude,
this.longitude,
this.city,
this.state,
this.street,
this.country,
this.zipCode,
});
final String name;
final String address;
final String? placeId;
final double? latitude;
final double? longitude;
final String? city;
final String? state;
final String? street;
final String? country;
final String? zipCode;
@override
List<Object?> get props => <Object?>[
name,
address,
placeId,
latitude,
longitude,
city,
state,
street,
country,
zipCode,
];
}
/// Event triggered to update an existing hub.
class ClientHubsUpdateRequested extends ClientHubsEvent {
const ClientHubsUpdateRequested({
required this.id,
required this.name,
required this.address,
this.placeId,
this.latitude,
this.longitude,
this.city,
this.state,
this.street,
this.country,
this.zipCode,
});
final String id;
final String name;
final String address;
final String? placeId;
final double? latitude;
final double? longitude;
final String? city;
final String? state;
final String? street;
final String? country;
final String? zipCode;
@override
List<Object?> get props => <Object?>[
id,
name,
address,
placeId,
latitude,
longitude,
city,
state,
street,
country,
zipCode,
];
}
/// Event triggered to delete a hub.
class ClientHubsDeleteRequested extends ClientHubsEvent {
const ClientHubsDeleteRequested(this.hubId);
final String hubId;
@override
List<Object?> get props => <Object?>[hubId];
}
/// Event triggered to assign an NFC tag to a hub.
class ClientHubsNfcTagAssignRequested extends ClientHubsEvent {
const ClientHubsNfcTagAssignRequested({
required this.hubId,
required this.nfcTagId,
});
final String hubId;
final String nfcTagId;
@override
List<Object?> get props => <Object?>[hubId, nfcTagId];
}
/// Event triggered to clear any error or success messages.
class ClientHubsMessageCleared extends ClientHubsEvent {
const ClientHubsMessageCleared();
}
/// Event triggered to toggle the visibility of the "Add Hub" dialog.
class ClientHubsAddDialogToggled extends ClientHubsEvent {
const ClientHubsAddDialogToggled({required this.visible});
final bool visible;
@override
List<Object?> get props => <Object?>[visible];
}
/// Event triggered to toggle the visibility of the "Identify NFC" dialog.
class ClientHubsIdentifyDialogToggled extends ClientHubsEvent {
const ClientHubsIdentifyDialogToggled({this.hub});
final Hub? hub;
@override
List<Object?> get props => <Object?>[hub];
}

View File

@@ -2,47 +2,27 @@ import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
/// Enum representing the status of the client hubs state.
enum ClientHubsStatus {
initial,
loading,
success,
failure,
actionInProgress,
actionSuccess,
actionFailure,
}
enum ClientHubsStatus { initial, loading, success, failure }
/// State class for the ClientHubs BLoC.
class ClientHubsState extends Equatable {
const ClientHubsState({
this.status = ClientHubsStatus.initial,
this.hubs = const <Hub>[],
this.errorMessage,
this.successMessage,
this.showAddHubDialog = false,
this.hubToIdentify,
});
final ClientHubsStatus status;
final List<Hub> hubs;
final String? errorMessage;
final String? successMessage;
/// Whether the "Add Hub" dialog should be visible.
final bool showAddHubDialog;
/// The hub currently being identified/assigned an NFC tag.
/// If null, the identification dialog is closed.
final Hub? hubToIdentify;
ClientHubsState copyWith({
ClientHubsStatus? status,
List<Hub>? hubs,
String? errorMessage,
String? successMessage,
bool? showAddHubDialog,
Hub? hubToIdentify,
bool clearHubToIdentify = false,
bool clearErrorMessage = false,
bool clearSuccessMessage = false,
}) {
@@ -55,10 +35,6 @@ class ClientHubsState extends Equatable {
successMessage: clearSuccessMessage
? null
: (successMessage ?? this.successMessage),
showAddHubDialog: showAddHubDialog ?? this.showAddHubDialog,
hubToIdentify: clearHubToIdentify
? null
: (hubToIdentify ?? this.hubToIdentify),
);
}
@@ -68,7 +44,5 @@ class ClientHubsState extends Equatable {
hubs,
errorMessage,
successMessage,
showAddHubDialog,
hubToIdentify,
];
}

View File

@@ -0,0 +1,120 @@
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';
/// Bloc for creating and updating hubs.
class EditHubBloc extends Bloc<EditHubEvent, EditHubState>
with BlocErrorHandler<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,
Emitter<EditHubState> emit,
) async {
emit(state.copyWith(status: EditHubStatus.loading));
await handleError(
emit: emit.call,
action: () async {
await _createHubUseCase.call(
CreateHubArguments(
name: event.name,
address: event.address,
placeId: event.placeId,
latitude: event.latitude,
longitude: event.longitude,
city: event.city,
state: event.state,
street: event.street,
country: event.country,
zipCode: event.zipCode,
costCenterId: event.costCenterId,
),
);
emit(
state.copyWith(
status: EditHubStatus.success,
successKey: 'created',
),
);
},
onError: (String errorKey) =>
state.copyWith(status: EditHubStatus.failure, errorMessage: errorKey),
);
}
Future<void> _onUpdateRequested(
EditHubUpdateRequested event,
Emitter<EditHubState> emit,
) async {
emit(state.copyWith(status: EditHubStatus.loading));
await handleError(
emit: emit.call,
action: () async {
await _updateHubUseCase.call(
UpdateHubArguments(
id: event.id,
name: event.name,
address: event.address,
placeId: event.placeId,
latitude: event.latitude,
longitude: event.longitude,
city: event.city,
state: event.state,
street: event.street,
country: event.country,
zipCode: event.zipCode,
costCenterId: event.costCenterId,
),
);
emit(
state.copyWith(
status: EditHubStatus.success,
successKey: 'updated',
),
);
},
onError: (String errorKey) =>
state.copyWith(status: EditHubStatus.failure, errorMessage: errorKey),
);
}
}

View File

@@ -0,0 +1,105 @@
import 'package:equatable/equatable.dart';
/// Base class for all edit hub events.
abstract class EditHubEvent extends Equatable {
const EditHubEvent();
@override
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({
required this.name,
required this.address,
this.placeId,
this.latitude,
this.longitude,
this.city,
this.state,
this.street,
this.country,
this.zipCode,
this.costCenterId,
});
final String name;
final String address;
final String? placeId;
final double? latitude;
final double? longitude;
final String? city;
final String? state;
final String? street;
final String? country;
final String? zipCode;
final String? costCenterId;
@override
List<Object?> get props => <Object?>[
name,
address,
placeId,
latitude,
longitude,
city,
state,
street,
country,
zipCode,
costCenterId,
];
}
/// Event triggered to update an existing hub.
class EditHubUpdateRequested extends EditHubEvent {
const EditHubUpdateRequested({
required this.id,
required this.name,
required this.address,
this.placeId,
this.latitude,
this.longitude,
this.city,
this.state,
this.street,
this.country,
this.zipCode,
this.costCenterId,
});
final String id;
final String name;
final String address;
final String? placeId;
final double? latitude;
final double? longitude;
final String? city;
final String? state;
final String? street;
final String? country;
final String? zipCode;
final String? costCenterId;
@override
List<Object?> get props => <Object?>[
id,
name,
address,
placeId,
latitude,
longitude,
city,
state,
street,
country,
zipCode,
costCenterId,
];
}

View File

@@ -0,0 +1,69 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
/// Status of the edit hub operation.
enum EditHubStatus {
/// Initial state.
initial,
/// Operation in progress.
loading,
/// Operation succeeded.
success,
/// Operation failed.
failure,
}
/// State for the edit hub operation.
class EditHubState extends Equatable {
const EditHubState({
this.status = EditHubStatus.initial,
this.errorMessage,
this.successMessage,
this.successKey,
this.costCenters = const <CostCenter>[],
});
/// The status of the operation.
final EditHubStatus status;
/// The error message if the operation failed.
final String? errorMessage;
/// 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,
successKey,
costCenters,
];
}

View File

@@ -0,0 +1,75 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import '../../../domain/arguments/assign_nfc_tag_arguments.dart';
import '../../../domain/arguments/delete_hub_arguments.dart';
import '../../../domain/usecases/assign_nfc_tag_usecase.dart';
import '../../../domain/usecases/delete_hub_usecase.dart';
import 'hub_details_event.dart';
import 'hub_details_state.dart';
/// Bloc for managing hub details and operations like delete and NFC assignment.
class HubDetailsBloc extends Bloc<HubDetailsEvent, HubDetailsState>
with BlocErrorHandler<HubDetailsState> {
HubDetailsBloc({
required DeleteHubUseCase deleteHubUseCase,
required AssignNfcTagUseCase assignNfcTagUseCase,
}) : _deleteHubUseCase = deleteHubUseCase,
_assignNfcTagUseCase = assignNfcTagUseCase,
super(const HubDetailsState()) {
on<HubDetailsDeleteRequested>(_onDeleteRequested);
on<HubDetailsNfcTagAssignRequested>(_onNfcTagAssignRequested);
}
final DeleteHubUseCase _deleteHubUseCase;
final AssignNfcTagUseCase _assignNfcTagUseCase;
Future<void> _onDeleteRequested(
HubDetailsDeleteRequested event,
Emitter<HubDetailsState> emit,
) async {
emit(state.copyWith(status: HubDetailsStatus.loading));
await handleError(
emit: emit.call,
action: () async {
await _deleteHubUseCase.call(DeleteHubArguments(hubId: event.id));
emit(
state.copyWith(
status: HubDetailsStatus.deleted,
successKey: 'deleted',
),
);
},
onError: (String errorKey) => state.copyWith(
status: HubDetailsStatus.failure,
errorMessage: errorKey,
),
);
}
Future<void> _onNfcTagAssignRequested(
HubDetailsNfcTagAssignRequested event,
Emitter<HubDetailsState> emit,
) async {
emit(state.copyWith(status: HubDetailsStatus.loading));
await handleError(
emit: emit.call,
action: () async {
await _assignNfcTagUseCase.call(
AssignNfcTagArguments(hubId: event.hubId, nfcTagId: event.nfcTagId),
);
emit(
state.copyWith(
status: HubDetailsStatus.success,
successMessage: 'NFC tag assigned successfully',
),
);
},
onError: (String errorKey) => state.copyWith(
status: HubDetailsStatus.failure,
errorMessage: errorKey,
),
);
}
}

View File

@@ -0,0 +1,32 @@
import 'package:equatable/equatable.dart';
/// Base class for all hub details events.
abstract class HubDetailsEvent extends Equatable {
const HubDetailsEvent();
@override
List<Object?> get props => <Object?>[];
}
/// Event triggered to delete a hub.
class HubDetailsDeleteRequested extends HubDetailsEvent {
const HubDetailsDeleteRequested(this.id);
final String id;
@override
List<Object?> get props => <Object?>[id];
}
/// Event triggered to assign an NFC tag to a hub.
class HubDetailsNfcTagAssignRequested extends HubDetailsEvent {
const HubDetailsNfcTagAssignRequested({
required this.hubId,
required this.nfcTagId,
});
final String hubId;
final String nfcTagId;
@override
List<Object?> get props => <Object?>[hubId, nfcTagId];
}

View File

@@ -0,0 +1,59 @@
import 'package:equatable/equatable.dart';
/// Status of the hub details operation.
enum HubDetailsStatus {
/// Initial state.
initial,
/// Operation in progress.
loading,
/// Operation succeeded.
success,
/// Operation failed.
failure,
/// Hub was deleted.
deleted,
}
/// State for the hub details operation.
class HubDetailsState extends Equatable {
const HubDetailsState({
this.status = HubDetailsStatus.initial,
this.errorMessage,
this.successMessage,
this.successKey,
});
/// The status of the operation.
final HubDetailsStatus status;
/// The error message if the operation failed.
final String? errorMessage;
/// 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, successKey];
}

View File

@@ -8,11 +8,10 @@ import 'package:krow_domain/krow_domain.dart';
import '../blocs/client_hubs_bloc.dart';
import '../blocs/client_hubs_event.dart';
import '../blocs/client_hubs_state.dart';
import '../widgets/add_hub_dialog.dart';
import '../widgets/hub_card.dart';
import '../widgets/hub_empty_state.dart';
import '../widgets/hub_info_card.dart';
import '../widgets/identify_nfc_dialog.dart';
/// The main page for the client hubs feature.
///
@@ -43,7 +42,8 @@ class ClientHubsPage extends StatelessWidget {
context,
).add(const ClientHubsMessageCleared());
}
if (state.successMessage != null && state.successMessage!.isNotEmpty) {
if (state.successMessage != null &&
state.successMessage!.isNotEmpty) {
UiSnackbar.show(
context,
message: state.successMessage!,
@@ -57,105 +57,54 @@ class ClientHubsPage extends StatelessWidget {
builder: (BuildContext context, ClientHubsState state) {
return Scaffold(
backgroundColor: UiColors.bgMenu,
floatingActionButton: FloatingActionButton(
onPressed: () => BlocProvider.of<ClientHubsBloc>(
context,
).add(const ClientHubsAddDialogToggled(visible: true)),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: const Icon(UiIcons.add),
),
body: Stack(
children: <Widget>[
CustomScrollView(
slivers: <Widget>[
_buildAppBar(context),
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
vertical: UiConstants.space5,
).copyWith(bottom: 100),
sliver: SliverList(
delegate: SliverChildListDelegate(<Widget>[
if (state.status == ClientHubsStatus.loading)
const Center(child: CircularProgressIndicator())
else if (state.hubs.isEmpty)
HubEmptyState(
onAddPressed: () =>
BlocProvider.of<ClientHubsBloc>(context).add(
const ClientHubsAddDialogToggled(
visible: true,
),
),
)
else ...<Widget>[
...state.hubs.map(
(Hub hub) => HubCard(
hub: hub,
onNfcPressed: () =>
BlocProvider.of<ClientHubsBloc>(
context,
).add(
ClientHubsIdentifyDialogToggled(hub: hub),
),
onDeletePressed: () => _confirmDeleteHub(
context,
hub,
),
),
),
],
const SizedBox(height: UiConstants.space5),
const HubInfoCard(),
]),
body: CustomScrollView(
slivers: <Widget>[
_buildAppBar(context),
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
vertical: UiConstants.space5,
).copyWith(bottom: 100),
sliver: SliverList(
delegate: SliverChildListDelegate(<Widget>[
const Padding(
padding: EdgeInsets.only(bottom: UiConstants.space5),
child: HubInfoCard(),
),
),
],
if (state.status == ClientHubsStatus.loading)
const Center(child: CircularProgressIndicator())
else if (state.hubs.isEmpty)
HubEmptyState(
onAddPressed: () async {
final bool? success = await Modular.to.toEditHub();
if (success == true && context.mounted) {
BlocProvider.of<ClientHubsBloc>(
context,
).add(const ClientHubsFetched());
}
},
)
else ...<Widget>[
...state.hubs.map(
(Hub hub) => HubCard(
hub: hub,
onTap: () async {
final bool? success = await Modular.to
.toHubDetails(hub);
if (success == true && context.mounted) {
BlocProvider.of<ClientHubsBloc>(
context,
).add(const ClientHubsFetched());
}
},
),
),
],
const SizedBox(height: UiConstants.space5),
]),
),
),
if (state.showAddHubDialog)
AddHubDialog(
onCreate: (
String name,
String address, {
String? placeId,
double? latitude,
double? longitude,
}) {
BlocProvider.of<ClientHubsBloc>(context).add(
ClientHubsAddRequested(
name: name,
address: address,
placeId: placeId,
latitude: latitude,
longitude: longitude,
),
);
},
onCancel: () => BlocProvider.of<ClientHubsBloc>(
context,
).add(const ClientHubsAddDialogToggled(visible: false)),
),
if (state.hubToIdentify != null)
IdentifyNfcDialog(
hub: state.hubToIdentify!,
onAssign: (String tagId) {
BlocProvider.of<ClientHubsBloc>(context).add(
ClientHubsNfcTagAssignRequested(
hubId: state.hubToIdentify!.id,
nfcTagId: tagId,
),
);
},
onCancel: () => BlocProvider.of<ClientHubsBloc>(
context,
).add(const ClientHubsIdentifyDialogToggled()),
),
if (state.status == ClientHubsStatus.actionInProgress)
Container(
color: UiColors.black.withValues(alpha: 0.1),
child: const Center(child: CircularProgressIndicator()),
),
],
),
);
@@ -166,7 +115,7 @@ class ClientHubsPage extends StatelessWidget {
Widget _buildAppBar(BuildContext context) {
return SliverAppBar(
backgroundColor: UiColors.foreground, // Dark Slate equivalent
backgroundColor: UiColors.foreground,
automaticallyImplyLeading: false,
expandedHeight: 140,
pinned: true,
@@ -202,20 +151,35 @@ class ClientHubsPage extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
t.client_hubs.title,
style: UiTypography.headline1m.white,
),
Text(
t.client_hubs.subtitle,
style: UiTypography.body2r.copyWith(
color: UiColors.switchInactive,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
t.client_hubs.title,
style: UiTypography.headline1m.white,
),
),
],
Text(
t.client_hubs.subtitle,
style: UiTypography.body2r.copyWith(
color: UiColors.switchInactive,
),
),
],
),
),
UiButton.primary(
onPressed: () async {
final bool? success = await Modular.to.toEditHub();
if (success == true && context.mounted) {
BlocProvider.of<ClientHubsBloc>(
context,
).add(const ClientHubsFetched());
}
},
text: t.client_hubs.add_hub,
leadingIcon: UiIcons.add,
size: UiButtonSize.small,
),
],
),
@@ -225,51 +189,4 @@ class ClientHubsPage extends StatelessWidget {
),
);
}
Future<void> _confirmDeleteHub(BuildContext context, Hub hub) async {
final String hubName = hub.name.isEmpty ? t.client_hubs.title : hub.name;
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: Text(t.client_hubs.delete_dialog.title),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(t.client_hubs.delete_dialog.message(hubName: hubName)),
const SizedBox(height: UiConstants.space2),
Text(t.client_hubs.delete_dialog.undo_warning),
const SizedBox(height: UiConstants.space2),
Text(
t.client_hubs.delete_dialog.dependency_warning,
style: UiTypography.footnote1r.copyWith(
color: UiColors.textSecondary,
),
),
],
),
actions: <Widget>[
TextButton(
onPressed: () => Modular.to.pop(),
child: Text(t.client_hubs.delete_dialog.cancel),
),
TextButton(
onPressed: () {
BlocProvider.of<ClientHubsBloc>(
context,
).add(ClientHubsDeleteRequested(hub.id));
Modular.to.pop();
},
style: TextButton.styleFrom(
foregroundColor: UiColors.destructive,
),
child: Text(t.client_hubs.delete_dialog.delete),
),
],
);
},
);
}
}

View File

@@ -2,99 +2,54 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:google_places_flutter/model/prediction.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/client_hubs_bloc.dart';
import '../blocs/client_hubs_event.dart';
import '../blocs/client_hubs_state.dart';
import '../widgets/hub_address_autocomplete.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/hub_form_dialog.dart';
/// A dedicated full-screen page for editing an existing hub.
///
/// Takes the parent [ClientHubsBloc] via [BlocProvider.value] so the
/// updated hub list is reflected on the hubs list page when the user
/// saves and navigates back.
/// A wrapper page that shows the hub form in a modal-style layout.
class EditHubPage extends StatefulWidget {
const EditHubPage({
required this.hub,
required this.bloc,
super.key,
});
const EditHubPage({this.hub, required this.bloc, super.key});
final Hub hub;
final ClientHubsBloc bloc;
final Hub? hub;
final EditHubBloc bloc;
@override
State<EditHubPage> createState() => _EditHubPageState();
}
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();
}
@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;
}
ReadContext(context).read<ClientHubsBloc>().add(
ClientHubsUpdateRequested(
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
Widget build(BuildContext context) {
return BlocProvider<ClientHubsBloc>.value(
return BlocProvider<EditHubBloc>.value(
value: widget.bloc,
child: BlocListener<ClientHubsBloc, ClientHubsState>(
listenWhen: (ClientHubsState prev, ClientHubsState curr) =>
prev.status != curr.status || prev.successMessage != curr.successMessage,
listener: (BuildContext context, ClientHubsState state) {
if (state.status == ClientHubsStatus.actionSuccess &&
state.successMessage != null) {
child: BlocListener<EditHubBloc, EditHubState>(
listenWhen: (EditHubState prev, EditHubState curr) =>
prev.status != curr.status || prev.successKey != curr.successKey,
listener: (BuildContext context, EditHubState state) {
if (state.status == EditHubStatus.success &&
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 details page with updated hub
Navigator.of(context).pop(true);
Modular.to.pop(true);
}
if (state.status == ClientHubsStatus.actionFailure &&
if (state.status == EditHubStatus.failure &&
state.errorMessage != null) {
UiSnackbar.show(
context,
@@ -103,89 +58,64 @@ class _EditHubPageState extends State<EditHubPage> {
);
}
},
child: BlocBuilder<ClientHubsBloc, ClientHubsState>(
builder: (BuildContext context, ClientHubsState state) {
final bool isSaving =
state.status == ClientHubsStatus.actionInProgress;
child: BlocBuilder<EditHubBloc, EditHubState>(
builder: (BuildContext context, EditHubState state) {
final bool isSaving = state.status == EditHubStatus.loading;
return Scaffold(
backgroundColor: UiColors.bgMenu,
appBar: AppBar(
backgroundColor: UiColors.foreground,
leading: IconButton(
icon: const Icon(UiIcons.arrowLeft, color: UiColors.white),
onPressed: () => Navigator.of(context).pop(),
),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
t.client_hubs.edit_hub.title,
style: UiTypography.headline3m.white,
),
Text(
t.client_hubs.edit_hub.subtitle,
style: UiTypography.footnote1r.copyWith(
color: UiColors.white.withValues(alpha: 0.7),
),
),
],
),
),
backgroundColor: UiColors.bgOverlay,
body: Stack(
children: <Widget>[
SingleChildScrollView(
padding: const EdgeInsets.all(UiConstants.space5),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
// ── Name field ──────────────────────────────────
_FieldLabel(t.client_hubs.edit_hub.name_label),
TextFormField(
controller: _nameController,
style: UiTypography.body1r.textPrimary,
textInputAction: TextInputAction.next,
validator: (String? value) {
if (value == null || value.trim().isEmpty) {
return 'Name is required';
}
return null;
},
decoration: _inputDecoration(
t.client_hubs.edit_hub.name_hint,
// 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,
),
),
const SizedBox(height: UiConstants.space4),
// ── Address field ────────────────────────────────
_FieldLabel(t.client_hubs.edit_hub.address_label),
HubAddressAutocomplete(
controller: _addressController,
hintText: t.client_hubs.edit_hub.address_hint,
focusNode: _addressFocusNode,
onSelected: (Prediction prediction) {
_selectedPrediction = prediction;
},
),
const SizedBox(height: UiConstants.space8),
// ── Save button ──────────────────────────────────
UiButton.primary(
onPressed: isSaving ? null : _onSave,
text: t.client_hubs.edit_hub.save_button,
),
const SizedBox(height: 40),
],
),
);
} else {
widget.bloc.add(
EditHubUpdateRequested(
id: widget.hub!.id,
name: name,
address: address,
costCenterId: costCenterId,
placeId: placeId,
latitude: latitude,
longitude: longitude,
),
);
}
},
),
),
// ── Loading overlay ──────────────────────────────────────
// Global loading overlay if saving
if (isSaving)
Container(
color: UiColors.black.withValues(alpha: 0.1),
@@ -199,42 +129,4 @@ class _EditHubPageState extends State<EditHubPage> {
),
);
}
InputDecoration _inputDecoration(String hint) {
return InputDecoration(
hintText: hint,
hintStyle: UiTypography.body2r.textPlaceholder,
filled: true,
fillColor: UiColors.input,
contentPadding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: 14,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
borderSide: const BorderSide(color: UiColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
borderSide: const BorderSide(color: UiColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
borderSide: const BorderSide(color: UiColors.ring, width: 2),
),
);
}
}
class _FieldLabel extends StatelessWidget {
const _FieldLabel(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space2),
child: Text(text, style: UiTypography.body2m.textPrimary),
);
}
}

View File

@@ -1,137 +1,146 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/client_hubs_bloc.dart';
import 'edit_hub_page.dart';
import '../blocs/hub_details/hub_details_bloc.dart';
import '../blocs/hub_details/hub_details_event.dart';
import '../blocs/hub_details/hub_details_state.dart';
import '../widgets/hub_details/hub_details_bottom_actions.dart';
import '../widgets/hub_details/hub_details_header.dart';
import '../widgets/hub_details/hub_details_item.dart';
/// A read-only details page for a single [Hub].
///
/// Shows hub name, address, and NFC tag assignment.
/// Tapping the edit button navigates to [EditHubPage] (a dedicated page,
/// not a dialog), satisfying the "separate edit hub page" acceptance criterion.
class HubDetailsPage extends StatelessWidget {
const HubDetailsPage({
required this.hub,
required this.bloc,
super.key,
});
const HubDetailsPage({required this.hub, required this.bloc, super.key});
final Hub hub;
final ClientHubsBloc bloc;
final HubDetailsBloc bloc;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(hub.name),
backgroundColor: UiColors.foreground,
leading: IconButton(
icon: const Icon(UiIcons.arrowLeft, color: UiColors.white),
onPressed: () => Navigator.of(context).pop(),
),
actions: <Widget>[
TextButton.icon(
onPressed: () => _navigateToEditPage(context),
icon: const Icon(UiIcons.edit, color: UiColors.white, size: 16),
label: Text(
t.client_hubs.hub_details.edit_button,
style: const TextStyle(color: UiColors.white),
),
),
],
),
backgroundColor: UiColors.bgMenu,
body: Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_buildDetailItem(
label: t.client_hubs.hub_details.name_label,
value: hub.name,
icon: UiIcons.home,
),
const SizedBox(height: UiConstants.space4),
_buildDetailItem(
label: t.client_hubs.hub_details.address_label,
value: hub.address,
icon: UiIcons.mapPin,
),
const SizedBox(height: UiConstants.space4),
_buildDetailItem(
label: t.client_hubs.hub_details.nfc_label,
value: hub.nfcTagId ?? t.client_hubs.hub_details.nfc_not_assigned,
icon: UiIcons.nfc,
isHighlight: hub.nfcTagId != null,
),
],
),
),
);
}
return BlocProvider<HubDetailsBloc>.value(
value: bloc,
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: message,
type: UiSnackbarType.success,
);
Modular.to.pop(true); // Return true to indicate change
}
if (state.status == HubDetailsStatus.failure &&
state.errorMessage != null) {
UiSnackbar.show(
context,
message: state.errorMessage!,
type: UiSnackbarType.error,
);
}
},
child: BlocBuilder<HubDetailsBloc, HubDetailsState>(
builder: (BuildContext context, HubDetailsState state) {
final bool isLoading = state.status == HubDetailsStatus.loading;
Widget _buildDetailItem({
required String label,
required String value,
required IconData icon,
bool isHighlight = false,
}) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
boxShadow: const <BoxShadow>[
BoxShadow(
color: UiColors.popupShadow,
blurRadius: 10,
offset: Offset(0, 4),
),
],
),
child: Row(
children: <Widget>[
Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: isHighlight ? UiColors.tagInProgress : UiColors.bgInputField,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
child: Icon(
icon,
color: isHighlight ? UiColors.iconSuccess : UiColors.iconPrimary,
size: 20,
),
),
const SizedBox(width: UiConstants.space4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(label, style: UiTypography.footnote1r.textSecondary),
const SizedBox(height: UiConstants.space1),
Text(value, style: UiTypography.body1m.textPrimary),
],
),
),
],
return Scaffold(
appBar: const UiAppBar(showBackButton: true),
bottomNavigationBar: HubDetailsBottomActions(
isLoading: isLoading,
onDelete: () => _confirmDeleteHub(context),
onEdit: () => _navigateToEditPage(context),
),
backgroundColor: UiColors.bgMenu,
body: Stack(
children: <Widget>[
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
// ── Header ──────────────────────────────────────────
HubDetailsHeader(hub: hub),
const Divider(height: 1, thickness: 0.5),
Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
HubDetailsItem(
label: t.client_hubs.hub_details.nfc_label,
value:
hub.nfcTagId ??
t.client_hubs.hub_details.nfc_not_assigned,
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,
),
],
),
),
],
),
),
if (isLoading)
Container(
color: UiColors.black.withValues(alpha: 0.1),
child: const Center(child: CircularProgressIndicator()),
),
],
),
);
},
),
),
);
}
Future<void> _navigateToEditPage(BuildContext context) async {
// Navigate to the dedicated edit page and await result.
// If the page returns `true` (save succeeded), pop the details page too so
// the user sees the refreshed hub list (the BLoC already holds updated data).
final bool? saved = await Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(
builder: (_) => EditHubPage(hub: hub, bloc: bloc),
final bool? saved = await Modular.to.toEditHub(hub: hub);
if (saved == true && context.mounted) {
Modular.to.pop(true); // Return true to indicate change
}
}
Future<void> _confirmDeleteHub(BuildContext context) async {
final bool? confirm = await showDialog<bool>(
context: context,
builder: (BuildContext context) => AlertDialog(
title: Text(t.client_hubs.delete_dialog.title),
content: Text(t.client_hubs.delete_dialog.message(hubName: hub.name)),
actions: <Widget>[
UiButton.text(
onPressed: () => Navigator.of(context).pop(false),
child: Text(t.client_hubs.delete_dialog.cancel),
),
UiButton.text(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: UiColors.destructive),
child: Text(t.client_hubs.delete_dialog.delete),
),
],
),
);
if (saved == true && context.mounted) {
Navigator.of(context).pop();
if (confirm == true) {
bloc.add(HubDetailsDeleteRequested(hub.id));
}
}
}

View File

@@ -1,190 +0,0 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:core_localization/core_localization.dart';
import 'package:google_places_flutter/model/prediction.dart';
import 'hub_address_autocomplete.dart';
/// A dialog for adding a new hub.
class AddHubDialog extends StatefulWidget {
/// Creates an [AddHubDialog].
const AddHubDialog({
required this.onCreate,
required this.onCancel,
super.key,
});
/// Callback when the "Create Hub" button is pressed.
final void Function(
String name,
String address, {
String? placeId,
double? latitude,
double? longitude,
}) onCreate;
/// Callback when the dialog is cancelled.
final VoidCallback onCancel;
@override
State<AddHubDialog> createState() => _AddHubDialogState();
}
class _AddHubDialogState extends State<AddHubDialog> {
late final TextEditingController _nameController;
late final TextEditingController _addressController;
late final FocusNode _addressFocusNode;
Prediction? _selectedPrediction;
@override
void initState() {
super.initState();
_nameController = TextEditingController();
_addressController = TextEditingController();
_addressFocusNode = FocusNode();
}
@override
void dispose() {
_nameController.dispose();
_addressController.dispose();
_addressFocusNode.dispose();
super.dispose();
}
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return Container(
color: UiColors.bgOverlay,
child: Center(
child: SingleChildScrollView(
child: Container(
width: MediaQuery.of(context).size.width * 0.9,
padding: const EdgeInsets.all(UiConstants.space5),
decoration: BoxDecoration(
color: UiColors.bgPopup,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
boxShadow: const <BoxShadow>[
BoxShadow(color: UiColors.popupShadow, blurRadius: 20),
],
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
t.client_hubs.add_hub_dialog.title,
style: UiTypography.headline3m.textPrimary,
),
const SizedBox(height: UiConstants.space5),
_buildFieldLabel(t.client_hubs.add_hub_dialog.name_label),
TextFormField(
controller: _nameController,
style: UiTypography.body1r.textPrimary,
validator: (String? value) {
if (value == null || value.trim().isEmpty) {
return 'Name is required';
}
return null;
},
decoration: _buildInputDecoration(
t.client_hubs.add_hub_dialog.name_hint,
),
),
const SizedBox(height: UiConstants.space4),
_buildFieldLabel(t.client_hubs.add_hub_dialog.address_label),
// Assuming HubAddressAutocomplete is a custom widget wrapper.
// If it doesn't expose a validator, we might need to modify it or manually check _addressController.
// For now, let's just make sure we validate name. Address is tricky if it's a wrapper.
HubAddressAutocomplete(
controller: _addressController,
hintText: t.client_hubs.add_hub_dialog.address_hint,
focusNode: _addressFocusNode,
onSelected: (Prediction prediction) {
_selectedPrediction = prediction;
},
),
const SizedBox(height: UiConstants.space8),
Row(
children: <Widget>[
Expanded(
child: UiButton.secondary(
onPressed: widget.onCancel,
text: t.common.cancel,
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: UiButton.primary(
onPressed: () {
if (_formKey.currentState!.validate()) {
// Manually check address if needed, or assume manual entry is ok.
if (_addressController.text.trim().isEmpty) {
// Show manual error or scaffold
UiSnackbar.show(context, message: 'Address is required', type: UiSnackbarType.error);
return;
}
widget.onCreate(
_nameController.text,
_addressController.text,
placeId: _selectedPrediction?.placeId,
latitude: double.tryParse(
_selectedPrediction?.lat ?? '',
),
longitude: double.tryParse(
_selectedPrediction?.lng ?? '',
),
);
}
},
text: t.client_hubs.add_hub_dialog.create_button,
),
),
],
),
],
),
),
),
),
),
);
}
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,
filled: true,
fillColor: UiColors.input,
contentPadding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: 14,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
borderSide: const BorderSide(color: UiColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
borderSide: const BorderSide(color: UiColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
borderSide: const BorderSide(color: UiColors.ring, width: 2),
),
);
}
}

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