Merge 592-migrate-frontend-applications-to-v2-backend-and-database into feature/session-persistence-new

This commit is contained in:
2026-03-18 12:51:23 +05:30
660 changed files with 18935 additions and 21383 deletions

View File

@@ -52,4 +52,175 @@
- BenefitsOverviewPage also has CircularProgressIndicator (not shimmer-ified yet)
- ShiftDetailsPage has a dialog-level spinner in the "applying" dialog -- this is intentional, not a page loading state
- Hub details/edit pages use CircularProgressIndicator as action overlays (save/delete) -- keep as-is, not initial load
- Client home page has no loading spinner; it renders with default empty dashboard data
- Client home page uses shimmer skeleton during loading (ClientHomePageSkeleton + ClientHomeHeaderSkeleton)
## V2 API Migration Patterns
- `BaseApiService` is registered in `CoreModule` as a lazy singleton (injected as `i.get<BaseApiService>()`)
- `BaseApiService` type lives in `krow_domain`; `ApiService` impl lives in `krow_core`
- V2 endpoints: `V2ApiEndpoints.staffDashboard` etc. from `krow_core/core.dart`
- V2 domain shift entities: `TodayShift`, `AssignedShift`, `OpenShift` (separate from core `Shift`)
- V2 `Benefit`: uses `targetHours`/`trackedHours`/`remainingHours` (int) -- old used `entitlementHours`/`usedHours` (double)
- Staff dashboard endpoint returns all home data in one call (todaysShifts, tomorrowsShifts, recommendedShifts, benefits, staffName)
- Navigator has `toShiftDetailsById(String shiftId)` for cases where only the ID is available
- `StaffDashboard` entity updated to use typed lists: `List<TodayShift>`, `List<AssignedShift>`, `List<OpenShift>`
- Staff home feature migrated (Phase 2): removed krow_data_connect, firebase_data_connect, staff_shifts deps
- [V2 Profile Migration](project_v2_profile_migration.md) -- entity mappings and DI patterns for all profile sub-packages
- Staff clock-in migrated (Phase 3): repo impl → V2 API, removed Data Connect deps
- V2 `Shift` entity: `startsAt`/`endsAt` (DateTime), `locationName` (String?), no `startTime`/`endTime`/`clientName`/`hourlyRate`/`location`
- V2 `AttendanceStatus`: `isClockedIn` getter (not `isCheckedIn`), `clockInAt` (not `checkInTime`), no `checkOutTime`/`activeApplicationId`
- `AttendanceStatus` constructor requires `attendanceStatus: AttendanceStatusType.notClockedIn` for default
- Clock-out uses `shiftId` (not `applicationId`) -- V2 API resolves assignment from shiftId
- `listTodayShifts` endpoint returns `{ items: [...] }` with TodayShift-like shape (no lat/lng, hourlyRate, clientName)
- `getCurrentAttendanceStatus` returns flat object `{ activeShiftId, attendanceStatus, clockInAt }`
- Clock-in/out POST endpoints return `{ attendanceEventId, assignmentId, sessionId, status, validationStatus }` -- repo re-fetches status after
- Geofence: lat/lng not available from listTodayShifts or shiftDetail endpoints (lives on clock_points table, not returned by BE)
- `BaseApiService` not exported from `krow_core/core.dart` -- must import from `krow_domain/krow_domain.dart`
## Staff Shifts Feature Migration (Phase 3 -- completed)
- Migrated from `krow_data_connect` + `DataConnectService` to `BaseApiService` + `V2ApiEndpoints`
- Removed deps: `krow_data_connect`, `firebase_auth`, `firebase_data_connect`, `geolocator`, `google_maps_flutter`, `meta`
- State uses 5 typed lists: `List<AssignedShift>`, `List<OpenShift>`, `List<PendingAssignment>`, `List<CancelledShift>`, `List<CompletedShift>`
- ShiftDetail (not Shift) used for detail page -- loaded by BLoC via API, not passed as route argument
- Money: `hourlyRateCents` (int) / 100 for display -- all V2 shift entities use cents
- Dates: All V2 entities have `DateTime` fields (not `String`) -- no more `DateTime.parse()` in widgets
- AssignmentStatus enum drives bottom bar logic (accepted=clock-in, assigned=accept/decline, null=apply)
- Old `Shift` entity still exists in domain but only used by clock-in feature -- shift list/detail pages use V2 entities
- ShiftDetailsModule route no longer receives `Shift` data argument -- uses `shiftId` param only
- `toShiftDetailsById(String)` is the standard navigation for V2 (no entity passing)
- Profile completion: moved into feature repo impl via `V2ApiEndpoints.staffProfileCompletion` + `ProfileCompletion.fromJson`
- Find Shifts tab: removed geolocator distance filter and multi-day grouping (V2 API handles server-side)
- Renamed use cases: `GetMyShiftsUseCase``GetAssignedShiftsUseCase`, `GetAvailableShiftsUseCase``GetOpenShiftsUseCase`, `GetHistoryShiftsUseCase``GetCompletedShiftsUseCase`, `GetShiftDetailsUseCase``GetShiftDetailUseCase`
## Client Home Feature Migration (Phase 4 -- completed)
- Migrated from `krow_data_connect` + `DataConnectService` to `BaseApiService` + `V2ApiEndpoints`
- Removed deps: `krow_data_connect`, `firebase_data_connect`, `intl`
- V2 entities: `ClientDashboard` (replaces `HomeDashboardData`), `RecentOrder` (replaces `ReorderItem`)
- `ClientDashboard` contains nested `SpendingSummary`, `CoverageMetrics`, `LiveActivityMetrics`
- Money: `weeklySpendCents`, `projectedNext7DaysCents`, `averageShiftCostCents` (int) / 100 for display
- Two API calls: `GET /client/dashboard` (all metrics + user/biz info) and `GET /client/reorders` (returns `{ items: [...] }`)
- Removed `GetUserSessionDataUseCase` -- user/business info now part of `ClientDashboard`
- `LiveActivityWidget` rewritten from StatefulWidget with direct DC calls to StatelessWidget consuming BLoC state
- Dead code removed: `ShiftOrderFormSheet`, `ClientHomeSheets`, `CoverageDashboard` (all unused)
- `RecentOrder` entity: `id`, `title`, `date` (DateTime?), `hubName` (String?), `positionCount` (int), `orderType` (OrderType)
- Module imports `CoreModule()` (not `DataConnectModule()`), injects `BaseApiService` into repo
- State has `dashboard` (ClientDashboard?) with computed getters `businessName`, `userName`
- No photoUrl in V2 dashboard response -- header shows letter avatar only
## Client Billing Feature Migration (Phase 4 -- completed)
- Migrated from `krow_data_connect` + `BillingConnectorRepository` to `BaseApiService` + `V2ApiEndpoints`
- Removed deps: `krow_data_connect`, `firebase_data_connect`
- Deleted old presentation models: `BillingInvoice`, `BillingWorkerRecord`, `SpendingBreakdownItem`
- V2 domain entities used directly: `Invoice`, `BillingAccount`, `SpendItem`, `CurrentBill`, `Savings`
- Old domain types removed: `BusinessBankAccount`, `InvoiceItem`, `InvoiceWorker`, `BillingPeriod` enum
- Money: all amounts in cents (int). State has computed `currentBillDollars`, `savingsDollars`, `spendTotalCents` getters
- `Invoice` V2 entity: `invoiceId`, `invoiceNumber`, `amountCents` (int), `status` (InvoiceStatus enum), `dueDate`, `paymentDate`, `vendorId`, `vendorName`
- `BillingAccount` V2 entity: `accountId`, `bankName`, `providerReference`, `last4`, `isPrimary`, `accountType` (AccountType enum)
- `SpendItem` V2 entity: `category`, `amountCents` (int), `percentage` (double) -- server-side aggregation by role
- Spend breakdown: replaced `BillingPeriod` enum with `BillingPeriodTab` (local) + `SpendBreakdownParams` (startDate/endDate ISO strings)
- API response shapes: list endpoints return `{ items: [...] }`, scalar endpoints spread data (`{ currentBillCents, requestId }`)
- Approve/dispute: POST to `V2ApiEndpoints.clientInvoiceApprove(id)` / `clientInvoiceDispute(id)`
- Completion review page: `BillingInvoice` replaced with `Invoice` -- worker-level data not available in V2 (widget placeholder)
- `InvoiceStatus` enum has `.value` property for display and `fromJson` factory with safe fallback to `unknown`
## Client Reports Feature Migration (Phase 4 -- completed)
- Migrated from `krow_data_connect` + `ReportsConnectorRepository` to `BaseApiService` + `V2ApiEndpoints`
- Removed deps: `krow_data_connect`
- 7 report endpoints: summary, daily-ops, spend, coverage, forecast, performance, no-show
- Old `ReportsSummary` entity replaced with V2 `ReportSummary` (different fields: totalShifts, totalSpendCents, averageCoveragePercentage, averagePerformanceScore, noShowCount, forecastAccuracyPercentage)
- `businessId` removed from all events/repo -- V2 API resolves from auth token
- DailyOps: old `DailyOpsShift` replaced with `ShiftWithWorkers` (from coverage_domain). `TimeRange` has `startsAt`/`endsAt` (not `start`/`end`)
- Spend: `SpendReport` uses `totalSpendCents` (int), `chart` (List<SpendDataPoint> with `bucket`/`amountCents`), `breakdown` (List<SpendItem> with `category`/`amountCents`/`percentage`)
- Coverage: `CoverageReport` uses `averageCoveragePercentage`, `filledWorkers`, `neededWorkers`, `chart` (List<CoverageDayPoint> with `day`/`needed`/`filled`/`coveragePercentage`)
- Forecast: `ForecastReport` uses `forecastSpendCents`, `averageWeeklySpendCents`, `totalWorkerHours`, `weeks` (List<ForecastWeek> with `week`/`shiftCount`/`workerHours`/`forecastSpendCents`/`averageShiftCostCents`)
- Performance: V2 uses int percentages (`fillRatePercentage`, `completionRatePercentage`, `onTimeRatePercentage`) and `averageFillTimeMinutes` (double) -- convert to hours: `/60`
- NoShow: `NoShowReport` uses `totalNoShowCount`, `noShowRatePercentage`, `workersWhoNoShowed`, `items` (List<NoShowWorkerItem> with `staffId`/`staffName`/`incidentCount`/`riskStatus`/`incidents`)
- Module injects `BaseApiService` via `i.get<BaseApiService>()` -- no more `DataConnectModule` import
## Client Hubs Feature Migration (Phase 5 -- completed)
- Migrated from `krow_data_connect` + `HubsConnectorRepository` + `DataConnectService` to `BaseApiService` + `V2ApiEndpoints`
- Removed deps: `krow_data_connect`, `firebase_auth`, `firebase_data_connect`, `http`
- V2 `Hub` entity: `hubId` (not `id`), `fullAddress` (not `address`), `costCenterId`/`costCenterName` (flat, not nested `CostCenter` object)
- V2 `CostCenter` entity: `costCenterId` (not `id`), `name` only (no `code` field)
- V2 `HubManager` entity: `managerAssignmentId`, `businessMembershipId`, `managerId`, `name`
- API response shapes: `GET /client/hubs` returns `{ items: [...] }`, `GET /client/cost-centers` returns `{ items: [...] }`
- Create/update return `{ hubId, created: true }` / `{ hubId, updated: true }` -- repo returns hubId String
- Delete: soft-delete (sets status=INACTIVE). Backend rejects if hub has active orders (409 HUB_DELETE_BLOCKED)
- Assign NFC: `POST /client/hubs/:hubId/assign-nfc` with `{ nfcTagId }`
- Module no longer imports `DataConnectModule()` -- `BaseApiService` available from parent `CoreModule()`
- `UpdateHubArguments.id` renamed to `UpdateHubArguments.hubId`; `CreateHubArguments.address` renamed to `.fullAddress`
- `HubDetailsDeleteRequested.id` renamed to `.hubId`; `EditHubAddRequested.address` renamed to `.fullAddress`
- Navigator still passes full `Hub` entity via route args (not just hubId)
## Client Orders Feature Migration (Phase 5 -- completed)
- 3 sub-packages migrated: `orders_common`, `view_orders`, `create_order`
- Removed deps: `krow_data_connect`, `firebase_data_connect`, `firebase_auth` from all; kept `intl` in create_order and orders_common
- V2 `OrderItem` entity: `itemId`, `orderId`, `orderType` (OrderType enum), `roleName`, `date` (DateTime), `startsAt`/`endsAt` (DateTime), `requiredWorkerCount`, `filledCount`, `hourlyRateCents`, `totalCostCents` (int cents), `locationName` (String?), `status` (ShiftStatus enum), `workers` (List<AssignedWorkerSummary>)
- Old entities deleted: `OneTimeOrder`, `RecurringOrder`, `PermanentOrder`, `ReorderData`, `OneTimeOrderHubDetails`, `RecurringOrderHubDetails`
- `AssignedWorkerSummary`: `applicationId` (String?), `workerName` (String? -- nullable!), `role` (String?), `confirmationStatus` (ApplicationStatus?)
- V2 `Vendor` entity: field is `companyName` (not `name`) -- old code used `vendor.name`
- V2 `ShiftStatus` enum: only has `draft`, `open`, `pendingConfirmation`, `assigned`, `active`, `completed`, `cancelled`, `unknown` -- no `filled`/`confirmed`/`pending`
- `OrderType` enum has `unknown` variant -- must handle in switch statements
- View orders: removed `GetAcceptedApplicationsForDayUseCase` -- V2 returns workers inline with order items
- View orders cubit: date filtering now uses `_isSameDay(DateTime, DateTime)` instead of string comparison
- Create order BLoCs: build `Map<String, dynamic>` V2 payloads instead of old entity objects
- V2 create endpoints: `POST /client/orders/one-time` (requires `orderDate`), `/recurring` (requires `startDate`/`endDate`/`recurrenceDays`), `/permanent` (requires `startDate`/`daysOfWeek`)
- V2 edit endpoint: `POST /client/orders/:orderId/edit` -- creates edited copy, cancels original
- V2 cancel endpoint: `POST /client/orders/:orderId/cancel` with optional `reason`
- Reorder uses `OrderPreview` (from `V2ApiEndpoints.clientOrderReorderPreview`) instead of old `ReorderData`
- `OrderPreview` has nested `OrderPreviewShift` > `OrderPreviewRole` structure
- Query repo: `getHubs()` replaces `getHubsByOwner(businessId)` -- V2 resolves business from auth token
- `OneTimeOrderPosition` is now a typedef for `OrderPositionUiModel` from `orders_common`
- `OrderEditSheet` (1700 lines) fully rewritten: delegates to `IViewOrdersRepository` instead of direct DC calls
## Staff Authentication Feature Migration (Phase 6 -- completed)
- Migrated from `krow_data_connect` + `DataConnectService` + `firebase_data_connect` to `BaseApiService` + `V2ApiEndpoints`
- Removed deps: `krow_data_connect`, `firebase_data_connect`, `firebase_core`
- KEPT `firebase_auth` -- V2 backend `startStaffPhoneAuth` returns `CLIENT_FIREBASE_SDK` mode for mobile, meaning phone verification stays client-side via Firebase SDK
- Auth flow: Firebase SDK phone verify (client-side) -> get idToken -> `POST /auth/staff/phone/verify` with `{ idToken, mode }` -> V2 hydrates session (upserts user, loads actor context)
- V2 verify response: `{ sessionToken, refreshToken, expiresInSeconds, user: { id, email, displayName, phone }, staff: { staffId, tenantId, fullName, ... }, tenant, requiresProfileSetup }`
- `requiresProfileSetup` boolean replaces old signup logic (create user/staff via DC mutations)
- Profile setup: `POST /staff/profile/setup` with `{ fullName, bio, preferredLocations, maxDistanceMiles, industries, skills }`
- Sign out: `POST /auth/sign-out` (server-side token revocation) + local `FirebaseAuth.signOut()`
- `AuthInterceptor` in `DioClient` stays as-is -- attaches Firebase Bearer tokens to all V2 API requests
- `AuthInterceptor` in `DioClient` stays as-is -- attaches Firebase Bearer tokens to all V2 API requests
- Pre-existing issue: `ExperienceSkill` and `Industry` enums deleted from domain but still referenced in `profile_setup_experience.dart`
## Client Authentication Feature Migration (Phase 6 -- completed)
- Migrated from `krow_data_connect` + `DataConnectService` + `firebase_data_connect` to `BaseApiService` + `V2ApiEndpoints`
- Removed deps: `firebase_data_connect`, `firebase_core` from pubspec
- KEPT `firebase_auth` -- client-side sign-in needed so `AuthInterceptor` can attach Bearer tokens
- KEPT `krow_data_connect` -- only for `ClientSessionStore`/`ClientSession`/`ClientBusinessSession` (not yet extracted)
- Auth flow (Option A -- hybrid):
1. Firebase Auth client-side `signInWithEmailAndPassword` (sets `FirebaseAuth.instance.currentUser`)
2. `GET /auth/session` via V2 API (returns user + business + tenant context)
3. Populate `ClientSessionStore` from V2 session response
- Sign-up flow:
1. `POST /auth/client/sign-up` via V2 API (server-side: creates Firebase account + user/tenant/business/memberships in one transaction)
2. Local `signInWithEmailAndPassword` (sets local auth state)
3. `GET /auth/session` to load context + populate session store
- V2 session response shape: `{ user: { userId, email, displayName, phone, status }, business: { businessId, businessName, businessSlug, role, tenantId, membershipId }, tenant: {...}, vendor: null, staff: null }`
- Sign-out: `POST /auth/client/sign-out` (server-side revocation) + `FirebaseAuth.instance.signOut()` + `ClientSessionStore.instance.clear()`
- V2 sign-up error codes: `AUTH_PROVIDER_ERROR` with message containing `EMAIL_EXISTS` or `WEAK_PASSWORD`, `FORBIDDEN` for role mismatch
- Old Data Connect calls removed: `getUserById`, `getBusinessesByUserId`, `createBusiness`, `createUser`, `updateUser`, `deleteBusiness`
- Old rollback logic removed -- V2 API handles rollback server-side in one transaction
- Domain `User` entity: V2 uses `status: UserStatus` (not `role: String`) -- constructor: `User(id:, email:, displayName:, phone:, status:)`
- Module: `CoreModule()` (not `DataConnectModule()`), injects `BaseApiService` into `AuthRepositoryImpl`
## Client Settings Feature Migration (Phase 6 -- completed)
- Migrated sign-out from `DataConnectService.signOut()` to V2 API + local Firebase Auth
- Removed `DataConnectModule` import from module, replaced with `CoreModule()`
- `SettingsRepositoryImpl` now takes `BaseApiService` (not `DataConnectService`)
- Sign-out: `POST /auth/client/sign-out` + `FirebaseAuth.instance.signOut()` + `ClientSessionStore.instance.clear()`
- `settings_profile_header.dart` still reads from `ClientSessionStore` (now from `krow_core`)
## V2SessionService (Final Phase -- completed)
- `V2SessionService` singleton at `packages/core/lib/src/services/session/v2_session_service.dart`
- Replaces `DataConnectService` for session state management in both apps
- Uses `SessionHandlerMixin` from core (same interface as old DC version)
- `fetchUserRole()` calls `GET /auth/session` via `BaseApiService` (not DC connector)
- `signOut()` calls `POST /auth/sign-out` + `FirebaseAuth.signOut()` + `handleSignOut()`
- Registered in `CoreModule` via `i.addLazySingleton<V2SessionService>()` -- calls `setApiService()`
- Both `main.dart` files use `V2SessionService.instance.initializeAuthListener()` instead of `DataConnectService`
- Both `SessionListener` widgets subscribe to `V2SessionService.instance.onSessionStateChanged`
- `staff_main` package migrated: local repo/usecase via `V2ApiEndpoints.staffProfileCompletion` + `ProfileCompletion.fromJson`
- `krow_data_connect` removed from: staff app, client app, staff_main package pubspecs
- Session stores (`StaffSessionStore`, `ClientSessionStore`) now live in core, not data_connect

View File

@@ -0,0 +1,33 @@
---
name: V2 Profile Migration Status
description: Staff profile sub-packages migrated from Data Connect to V2 REST API - entity mappings and patterns
type: project
---
## Phase 2 Profile Migration (completed 2026-03-16)
All staff profile read features migrated from Firebase Data Connect to V2 REST API.
**Why:** Data Connect is being deprecated in favor of V2 REST API for all mobile backend access.
**How to apply:** When working on any profile feature, use `ApiService.get(V2ApiEndpoints.staffXxx)` not Data Connect connectors.
### Entity Mappings (old -> V2)
- `Staff` (old with name/avatar/totalShifts) -> `Staff` (V2 with fullName/metadata) + `StaffPersonalInfo` for profile form
- `EmergencyContact` (old with name/phone/relationship enum) -> `EmergencyContact` (V2 with fullName/phone/relationshipType string)
- `AttireItem` (removed) -> `AttireChecklist` (V2)
- `StaffDocument` (removed) -> `ProfileDocument` (V2)
- `StaffCertificate` (old with ComplianceType enum) -> `StaffCertificate` (V2 with certificateType string)
- `TaxForm` (old with I9TaxForm/W4TaxForm subclasses) -> `TaxForm` (V2 with formType string + fields map)
- `StaffBankAccount` (removed) -> `BankAccount` (V2)
- `TimeCard` (removed) -> `TimeCardEntry` (V2 with minutesWorked/totalPayCents)
- `PrivacySettings` (new V2 entity)
### Profile Main Page
- Old: 7+ individual completion use cases from data_connect connectors
- New: Single `ProfileRepositoryImpl.getProfileSections()` call returning `ProfileSectionStatus`
- Stats fields (totalShifts, onTimeRate, etc.) no longer on V2 Staff entity -- hardcoded to 0 pending dashboard API
### DI Pattern
- All repos inject `BaseApiService` from `CoreModule` (registered as `i.get<BaseApiService>()`)
- Modules import `CoreModule()` instead of `DataConnectModule()`

View File

@@ -2,3 +2,5 @@
## Project Context
- [project_clock_in_feature_issues.md](project_clock_in_feature_issues.md) — Critical bugs in staff clock_in feature: BLoC lifecycle leak, stale geofence override, dead lunch break data, non-functional date selector
- [project_client_v2_migration_issues.md](project_client_v2_migration_issues.md) — Critical bugs in client app V2 migration: reports BLoCs missing BlocErrorHandler, firebase_auth in features, no executeProtected, hardcoded strings, double sign-in
- [project_v2_migration_qa_findings.md](project_v2_migration_qa_findings.md) — Critical bugs in staff app V2 migration: cold-start session logout, geofence bypass, auth navigation race, token expiry inversion, shifts response shape mismatch

View File

@@ -0,0 +1,22 @@
---
name: Client V2 Migration QA Findings
description: Critical bugs and patterns found in the client app V2 API migration — covers auth, billing, coverage, home, hubs, orders, reports, settings
type: project
---
Client V2 migration QA analysis completed 2026-03-16. Key systemic issues found:
1. **Reports BLoCs missing BlocErrorHandler** — All 7 report BLoCs (spend, coverage, daily_ops, forecast, no_show, performance, summary) use raw try/catch instead of BlocErrorHandler mixin, risking StateError crashes if user navigates away during loading.
2. **firebase_auth in feature packages** — Both `client_authentication` and `client_settings` have `firebase_auth` in pubspec.yaml and import it in their repository implementations. Architecture rule says Firebase packages belong ONLY in `core`.
3. **No repository-level `executeProtected()` usage** — Zero client feature repos wrap API calls with `ApiErrorHandler.executeProtected()`. All rely solely on BLoC-level `handleError`. Timeout and network errors may surface as raw exceptions.
4. **Hardcoded strings scattered across home widgets**`live_activity_widget.dart`, `reorder_widget.dart`, `client_home_error_state.dart` contain English strings ("Today's Status", "Running Late", "positions", "An error occurred", "Retry") instead of localized keys.
5. **Double sign-in in auth flow** — signInWithEmail does V2 POST then Firebase signInWithEmailAndPassword. If V2 succeeds but Firebase fails (e.g. user disabled locally), the server thinks user is signed in but client throws.
6. **`context.t` vs `t` inconsistency** — Coverage feature uses `context.t.client_coverage.*` throughout, while home/billing use global `t.*`. Both work in Slang but inconsistency confuses maintainers.
**Why:** Migration from Data Connect to V2 REST API was a large-scale change touching all features simultaneously.
**How to apply:** When reviewing client features post-migration, check these specific patterns. Reports BLoCs are highest-risk for user-facing crashes.

View File

@@ -0,0 +1,27 @@
---
name: V2 API migration QA findings (staff app)
description: Critical bugs found during V2 API migration review of the staff mobile app — session cold-start logout, geofence bypass, auth race condition, token expiry inversion
type: project
---
V2 API migration introduced several critical bugs across the staff app (reviewed 2026-03-16).
**Why:** The migration from Firebase Data Connect to V2 REST API required rewiring every repository, session service, and entity. Some integration gaps were missed.
**Key findings (severity order):**
1. **Cold-start session logout**`V2SessionService.initializeAuthListener()` is called in `main.dart` before `CoreModule` injects `ApiService`. On cold start, `fetchUserRole` finds `_apiService == null`, returns null, and emits `unauthenticated`, logging the user out.
2. **Geofence coordinates always null**`ClockInRepositoryImpl._mapTodayShiftJsonToShift` defaults latitude/longitude to null because the V2 endpoint doesn't return them. Geofence validation is completely bypassed for all shifts.
3. **Auth navigation race** — After OTP verify, both `PhoneVerificationPage` BlocListener and `SessionListener` try to navigate (one to profile setup, the other to home). Creates unpredictable navigation.
4. **Token expiry check inverted**`session_handler_mixin.dart` line 215: `now.difference(expiryTime)` should be `expiryTime.difference(now)`. Tokens are only "refreshed" after they've already expired.
5. **Shifts response shape mismatch**`shifts_repository_impl.dart` casts `response.data as List<dynamic>` but other repos use `response.data['items']`. Needs validation against actual V2 contract.
6. **Attire blocking poll**`attire_repository_impl.dart` polls verification status for up to 10 seconds on main isolate with no UI feedback.
7. **`firebase_auth` in feature package** — `auth_repository_impl.dart` directly imports firebase_auth. Architecture rules require firebase_auth only in core.
**How to apply:** When reviewing future V2 migration PRs, check: (a) session init ordering, (b) response shape matches between repos and API, (c) nullable field defaults in entity mapping, (d) navigation race conditions between SessionListener and feature BlocListeners.

View File

@@ -26,6 +26,7 @@ and load any additional skills as needed for specific review challenges.
- Ensuring business logic lives in use cases (not BLoCs/widgets)
- Flagging design system violations (hardcoded colors, TextStyle, spacing, icons)
- Validating BLoC pattern usage (SessionHandlerMixin, BlocErrorHandler, singleton registration)
- **Verifying every feature module that uses `BaseApiService` (or any CoreModule binding) declares `List<Module> get imports => <Module>[CoreModule()];`** — missing this causes `UnregisteredInstance` runtime crashes
- Ensuring safe navigation extensions are used (no direct Navigator usage)
- Verifying test coverage for business logic
- Checking documentation on public APIs
@@ -205,6 +206,7 @@ Produce a structured report in this exact format:
|------|--------|---------|
| Design System | ✅/❌ | [details] |
| Architecture Boundaries | ✅/❌ | [details] |
| DI / CoreModule Imports | ✅/❌ | [Every module using BaseApiService must import CoreModule] |
| State Management | ✅/❌ | [details] |
| Navigation | ✅/❌ | [details] |
| Testing Coverage | ✅/❌ | [estimated %] |

View File

@@ -43,10 +43,11 @@ If any of these files are missing or unreadable, notify the user before proceedi
- Import icon libraries directly — use `UiIcons`
- Use `Navigator.push` directly — use Modular safe extensions
- Navigate without home fallback
- Call DataConnect directly from BLoCs — go through repository
- Call API directly from BLoCs — go through repository
- Skip tests for business logic
### ALWAYS:
- **Add `CoreModule` import to every feature module that uses `BaseApiService` or any other `CoreModule` binding** (e.g., `FileUploadService`, `DeviceFileUploadService`, `CameraService`). Without this, the DI container throws `UnregisteredInstance` at runtime. Add: `@override List<Module> get imports => <Module>[CoreModule()];`
- **Use `package:` imports everywhere inside `lib/`** for consistency and robustness. Use relative imports only in `test/` and `bin/` directories. Example: `import 'package:staff_clock_in/src/presentation/bloc/clock_in/clock_in_bloc.dart';` not `import '../bloc/clock_in/clock_in_bloc.dart';`
- Place reusable utility functions (math, geo, formatting, etc.) in `apps/mobile/packages/core/lib/src/utils/` and export from `core.dart` — never keep them as private methods in feature packages
- Use feature-first packaging: `domain/`, `data/`, `presentation/`
@@ -67,6 +68,84 @@ If any of these files are missing or unreadable, notify the user before proceedi
}
```
## V2 API Migration Rules (Active Migration)
The mobile apps are migrating from Firebase Data Connect (direct DB) to V2 REST API. Follow these rules for ALL new and migrated features:
### Backend Access
- **Use `ApiService.get/post/put/delete`** for ALL backend calls
- Import `ApiService` from `package:krow_core/core.dart`
- Use `V2ApiEndpoints` from `package:krow_core/core.dart` for endpoint URLs
- V2 API docs are at `docs/BACKEND/API_GUIDES/V2/` — check response shapes before writing code
### Domain Entities
- Domain entities live in `packages/domain/lib/src/entities/` with `fromJson`/`toJson` directly on the class
- No separate DTO or adapter layer — entities are self-serializing
- Entities are shared across all features via `package:krow_domain/krow_domain.dart`
- When migrating: check if the entity already exists and update its `fromJson` to match V2 API response shape
### Feature Structure
- **RepoImpl lives in the feature package** at `data/repositories/`
- **Feature-level domain layer is optional** — only add `domain/` when the feature has use cases, validators, or feature-specific interfaces
- **Simple features** (read-only, no business logic) = just `data/` + `presentation/`
- Do NOT import from `packages/data_connect/` — deleted
### Status & Type Enums
All status/type fields from the V2 API must use Dart enums, NOT raw strings. Parse at the `fromJson` boundary with a safe fallback:
```dart
enum ShiftStatus {
open, assigned, active, completed, cancelled;
static ShiftStatus fromJson(String value) {
switch (value) {
case 'OPEN': return ShiftStatus.open;
case 'ASSIGNED': return ShiftStatus.assigned;
case 'ACTIVE': return ShiftStatus.active;
case 'COMPLETED': return ShiftStatus.completed;
case 'CANCELLED': return ShiftStatus.cancelled;
default: return ShiftStatus.open;
}
}
String toJson() {
switch (this) {
case ShiftStatus.open: return 'OPEN';
case ShiftStatus.assigned: return 'ASSIGNED';
case ShiftStatus.active: return 'ACTIVE';
case ShiftStatus.completed: return 'COMPLETED';
case ShiftStatus.cancelled: return 'CANCELLED';
}
}
}
```
Place shared enums (used by multiple entities) in `packages/domain/lib/src/entities/enums/`. Feature-specific enums can live in the entity file.
### RepoImpl Pattern
```dart
class FeatureRepositoryImpl implements FeatureRepositoryInterface {
FeatureRepositoryImpl({required ApiService apiService})
: _apiService = apiService;
final ApiService _apiService;
Future<List<Shift>> getShifts() async {
final ApiResponse response = await _apiService.get(V2ApiEndpoints.staffShiftsAssigned);
final List<dynamic> items = response.data['shifts'] as List<dynamic>;
return items.map((dynamic json) => Shift.fromJson(json as Map<String, dynamic>)).toList();
}
}
```
### DI Registration
```dart
// Inject ApiService (available from CoreModule)
i.add<FeatureRepositoryImpl>(() => FeatureRepositoryImpl(
apiService: i.get<ApiService>(),
));
```
---
## Standard Workflow
Follow these steps in order for every feature implementation:
@@ -91,8 +170,8 @@ Follow these steps in order for every feature implementation:
- Create barrel file exporting the domain public API
### 4. Data Layer
- Create models with `fromJson`/`toJson` methods
- Implement repository classes using `DataConnectService`
- Implement repository classes using `ApiService` with `V2ApiEndpoints`
- Parse V2 API JSON responses into domain entities via `Entity.fromJson()`
- Map errors to domain `Failure` types
- Create barrel file for data layer
@@ -188,7 +267,7 @@ After completing implementation, prepare a handoff summary including:
As you work on features, update your agent memory with discoveries about:
- Existing feature patterns and conventions in the codebase
- Session store usage patterns and available stores
- DataConnect query/mutation names and their locations
- V2 API endpoint patterns and response shapes
- Design token values and component patterns actually in use
- Module registration patterns and route conventions
- Recurring issues found during `melos analyze`

View File

@@ -22,8 +22,8 @@ You are working within a Flutter monorepo (where features are organized into pac
- **State Management**: Flutter BLoC/Cubit. BLoCs registered with `i.add()` (transient), never `i.addSingleton()`. `BlocProvider.value()` for shared BLoCs.
- **DI & Routing**: Flutter Modular. Safe navigation via `safeNavigate()`, `safePush()`, `popSafe()`. Never `Navigator.push()` directly (except when popping a dialog).
- **Error Handling**: `BlocErrorHandler` mixin with `_safeEmit()` to prevent StateError on disposed BLoCs.
- **Backend**: Firebase Data Connect through `data_connect` package Connectors. `_service.run(() => connector.<query>().execute())` for auth/token management.
- **Session Management**: `SessionHandlerMixin` + `SessionListener` widget.
- **Backend**: V2 REST API via `ApiService` with `V2ApiEndpoints`. Domain entities have `fromJson`/`toJson`. Status fields use typed enums from `krow_domain`. Money values are `int` in cents.
- **Session Management**: `V2SessionService` + `SessionHandlerMixin` + `SessionListener` widget. Session stores (`StaffSessionStore`, `ClientSessionStore`) in `core`.
- **Localization**: Slang (`t.section.key`), not `context.strings`.
- **Design System**: Tokens from `UiColors`, `UiTypography`, `UiConstants`. No hardcoded values.
@@ -55,7 +55,7 @@ Detect potential bugs including:
- **API integration issues**: Missing error handling, incorrect data mapping, async issues
- **Performance concerns**: Inefficient algorithms, unnecessary rebuilds, memory problems
- **Security vulnerabilities**: Hardcoded credentials, insecure data storage, authentication gaps
- **Architecture violations**: Features importing other features, business logic in BLoCs/widgets, Firebase packages outside `data_connect`
- **Architecture violations**: Features importing other features, business logic in BLoCs/widgets, Firebase packages outside `core`, direct Dio usage instead of `ApiService`
- **Data persistence issues**: Cache invalidation, concurrent access
## Analysis Methodology
@@ -64,7 +64,7 @@ Detect potential bugs including:
1. Map the feature's architecture and key screens
2. Identify critical user flows and navigation paths
3. Review state management implementation (BLoC states, events, transitions)
4. Understand data models and API contracts via Data Connect connectors
4. Understand data models and API contracts via V2 API endpoints
5. Document assumptions and expected behaviors
### Phase 2: Use Case Extraction
@@ -108,7 +108,7 @@ Analyze code for:
- Missing error handling in `.then()` chains
- Mounted checks missing in async callbacks
- Race conditions in concurrent requests
- Missing `_service.run()` wrapper for Data Connect calls
- Missing `ApiErrorHandler.executeProtected()` wrapper for API calls
### Background Tasks & WorkManager
When reviewing code that uses WorkManager or background task scheduling, check these edge cases:
@@ -140,7 +140,9 @@ When reviewing code that uses WorkManager or background task scheduling, check t
### Architecture Rules
- Features importing other features directly
- Business logic in BLoCs or widgets instead of Use Cases
- Firebase packages used outside `data_connect` package
- Firebase packages (`firebase_auth`) used outside `core` package
- Direct Dio/HTTP usage instead of `ApiService` with `V2ApiEndpoints`
- Importing deleted `krow_data_connect` package
- `context.read<T>()` instead of `ReadContext(context).read<T>()`
## Output Format

View File

@@ -1,6 +1,6 @@
---
name: krow-mobile-architecture
description: KROW mobile app Clean Architecture implementation including package structure, dependency rules, feature isolation, BLoC lifecycle management, session handling, and Data Connect connectors pattern. Use this when architecting new mobile features, debugging state management issues, preventing prop drilling, managing BLoC disposal, implementing session stores, or setting up connector repositories. Essential for maintaining architectural integrity across staff and client apps.
description: KROW mobile app Clean Architecture implementation including package structure, dependency rules, feature isolation, BLoC lifecycle management, session handling, and V2 REST API integration. Use this when architecting new mobile features, debugging state management issues, preventing prop drilling, managing BLoC disposal, implementing session stores, or setting up API repository patterns. Essential for maintaining architectural integrity across staff and client apps.
---
# KROW Mobile Architecture
@@ -13,7 +13,7 @@ This skill defines the authoritative mobile architecture for the KROW platform.
- Debugging state management or BLoC lifecycle issues
- Preventing prop drilling in UI code
- Managing session state and authentication
- Implementing Data Connect connector repositories
- Implementing V2 API repository patterns
- Setting up feature modules and dependency injection
- Understanding package boundaries and dependencies
- Refactoring legacy code to Clean Architecture
@@ -46,13 +46,14 @@ KROW follows **Clean Architecture** in a **Melos Monorepo**. Dependencies flow *
│ both depend on
┌─────────────────▼───────────────────────────────────────┐
│ Services (Interface Adapters) │
│ • data_connect: Backend integration, session mgmt
• core: Extensions, base classes, utilities
│ • core: API service, session management, device
services, utilities, extensions, base classes │
└─────────────────┬───────────────────────────────────────┘
both depend on
│ depends on
┌─────────────────▼───────────────────────────────────────┐
│ Domain (Stable Core) │
│ • Entities (immutable data models)
│ • Entities (data models with fromJson/toJson)
│ • Enums (shared enumerations) │
│ • Failures (domain-specific errors) │
│ • Pure Dart only, zero Flutter dependencies │
└─────────────────────────────────────────────────────────┘
@@ -69,9 +70,9 @@ KROW follows **Clean Architecture** in a **Melos Monorepo**. Dependencies flow *
**Responsibilities:**
- Initialize Flutter Modular
- Assemble features into navigation tree
- Inject concrete implementations (from `data_connect`) into features
- Inject concrete implementations into features
- Configure environment-specific settings (dev/stage/prod)
- Initialize session management
- Initialize session management via `V2SessionService`
**Structure:**
```
@@ -119,21 +120,22 @@ features/staff/profile/
**Key Principles:**
- **Presentation:** UI Pages and Widgets, BLoCs/Cubits for state
- **Application:** Use Cases (business logic orchestration)
- **Data:** Repository implementations (backend integration)
- **Data:** Repository implementations using `ApiService` with `V2ApiEndpoints`
- **Pages as StatelessWidget:** Move state to BLoCs for better performance and testability
- **Feature-level domain is optional:** Only needed when the feature has business logic (use cases, validators). Simple features can have just `data/` + `presentation/`.
**RESTRICTION:** Features MUST NOT import other features. Communication happens via:
- Shared domain entities
- Session stores (`StaffSessionStore`, `ClientSessionStore`)
- Navigation via Modular
- Data Connect connector repositories
### 2.3 Domain (`apps/mobile/packages/domain`)
**Role:** The stable, pure heart of the system
**Responsibilities:**
- Define **Entities** (immutable data models using Data Classes or Freezed)
- Define **Entities** (data models with `fromJson`/`toJson` for V2 API serialization)
- Define **Enums** (shared enumerations in `entities/enums/`)
- Define **Failures** (domain-specific error types)
**Structure:**
@@ -144,11 +146,17 @@ domain/
│ ├── entities/
│ │ ├── user.dart
│ │ ├── staff.dart
│ │ ── shift.dart
└── failures/
│ ├── failure.dart # Base class
├── auth_failure.dart
└── network_failure.dart
│ │ ── shift.dart
│ └── enums/
├── staff_status.dart
└── order_type.dart
├── failures/
│ │ ├── failure.dart # Base class
│ │ ├── auth_failure.dart
│ │ └── network_failure.dart
│ └── core/
│ └── services/api_services/
│ └── base_api_service.dart
└── pubspec.yaml
```
@@ -169,6 +177,22 @@ class Staff extends Equatable {
required this.status,
});
factory Staff.fromJson(Map<String, dynamic> json) {
return Staff(
id: json['id'] as String,
name: json['name'] as String,
email: json['email'] as String,
status: StaffStatus.values.byName(json['status'] as String),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'email': email,
'status': status.name,
};
@override
List<Object?> get props => [id, name, email, status];
}
@@ -176,53 +200,89 @@ class Staff extends Equatable {
**RESTRICTION:**
- NO Flutter dependencies (no `import 'package:flutter/material.dart'`)
- NO `json_annotation` or serialization code
- Only `equatable` for value equality
- Pure Dart only
- `fromJson`/`toJson` live directly on entities (no separate DTOs or adapters)
### 2.4 Data Connect (`apps/mobile/packages/data_connect`)
### 2.4 Core (`apps/mobile/packages/core`)
**Role:** Interface Adapter for Backend Access
**Role:** Cross-cutting concerns, API infrastructure, session management, device services, and utilities
**Responsibilities:**
- Centralized connector repositories (see Data Connect Connectors Pattern section)
- Implement Firebase Data Connect service layer
- Map Domain Entities ↔ Data Connect generated code
- Handle Firebase exceptions → domain failures
- Provide `DataConnectService` with session management
- `ApiService` — HTTP client wrapper around Dio with consistent response/error handling
- `V2ApiEndpoints` — All V2 REST API endpoint constants
- `DioClient` — Pre-configured Dio with `AuthInterceptor` and `IdempotencyInterceptor`
- `AuthInterceptor` — Automatically attaches Firebase Auth ID token to requests
- `IdempotencyInterceptor` — Adds `Idempotency-Key` header to POST/PUT/DELETE requests
- `ApiErrorHandler` mixin — Maps API errors to domain failures
- `SessionHandlerMixin` — Handles auth state, token refresh, role validation
- `V2SessionService` — Manages session lifecycle, replaces legacy DataConnectService
- Session stores (`StaffSessionStore`, `ClientSessionStore`)
- Device services (camera, gallery, location, notifications, storage, etc.)
- Extension methods (`NavigationExtensions`, `ListExtensions`, etc.)
- Base classes (`UseCase`, `Failure`, `BlocErrorHandler`)
- Logger configuration
- `AppConfig` — Environment-specific configuration (API base URLs, keys)
**Structure:**
```
data_connect/
core/
├── lib/
│ ├── src/
│ ├── services/
│ │ ├── data_connect_service.dart # Core service
── mixins/
│ │ └── session_handler_mixin.dart
├── connectors/ # Connector pattern (see below)
│ ├── staff/
│ │ ├── domain/
│ │ │ │ ├── repositories/
│ │ │ │ │ ── staff_connector_repository.dart
│ │ │ ── usecases/
│ │ │ └── get_profile_completion_usecase.dart
│ │ ── data/
│ │ │ │ └── repositories/
│ │ │ │ └── staff_connector_repository_impl.dart
│ │ │ ── order/
│ │ │ └── shifts/
│ └── session/
│ │ ├── staff_session_store.dart
│ │ ── client_session_store.dart
└── krow_data_connect.dart # Exports
│ ├── core.dart # Barrel exports
└── src/
├── config/
── app_config.dart # Env-specific config (V2_API_BASE_URL, etc.)
└── app_environment.dart
├── services/
│ ├── api_service/
│ │ ├── api_service.dart # ApiService (get/post/put/patch/delete)
│ │ ├── dio_client.dart # Pre-configured Dio
│ │ ── inspectors/
│ │ │ ── auth_interceptor.dart
│ │ │ └── idempotency_interceptor.dart
│ │ ── mixins/
│ │ │ ├── api_error_handler.dart
│ │ │ └── session_handler_mixin.dart
│ │ ── core_api_services/
│ │ ├── v2_api_endpoints.dart
│ │ ├── core_api_endpoints.dart
│ │ ├── file_upload/
│ │ ── signed_url/
│ │ ├── llm/
│ │ │ ├── verification/
│ │ │ └── rapid_order/
│ │ ├── session/
│ │ │ ├── v2_session_service.dart
│ │ │ ├── staff_session_store.dart
│ │ │ └── client_session_store.dart
│ │ └── device/
│ │ ├── camera/
│ │ ├── gallery/
│ │ ├── location/
│ │ ├── notification/
│ │ ├── storage/
│ │ └── background_task/
│ ├── presentation/
│ │ ├── mixins/
│ │ │ └── bloc_error_handler.dart
│ │ └── observers/
│ │ └── core_bloc_observer.dart
│ ├── routing/
│ │ └── routing.dart
│ ├── domain/
│ │ ├── arguments/
│ │ └── usecases/
│ └── utils/
│ ├── date_time_utils.dart
│ ├── geo_utils.dart
│ └── time_utils.dart
└── pubspec.yaml
```
**RESTRICTION:**
- NO feature-specific logic
- Connectors are domain-neutral and reusable
- All queries follow Clean Architecture (domain interfaces → data implementations)
- Core services are domain-neutral and reusable
- All V2 API access goes through `ApiService` — never use raw Dio directly in features
### 2.5 Design System (`apps/mobile/packages/design_system`)
@@ -274,13 +334,13 @@ design_system/
**Feature Integration:**
```dart
// CORRECT: Access via Slang's global `t` accessor
// CORRECT: Access via Slang's global `t` accessor
import 'package:core_localization/core_localization.dart';
Text(t.client_create_order.review.invalid_arguments)
Text(t.errors.order.creation_failed)
// FORBIDDEN: Hardcoded user-facing strings
// FORBIDDEN: Hardcoded user-facing strings
Text('Invalid review arguments') // Must use localized key
Text('Order created!') // Must use localized key
```
@@ -313,62 +373,86 @@ BlocProvider<LocaleBloc>(
)
```
### 2.7 Core (`apps/mobile/packages/core`)
**Role:** Cross-cutting concerns
**Responsibilities:**
- Extension methods (NavigationExtensions, ListExtensions, etc.)
- Base classes (UseCase, Failure, BlocErrorHandler)
- Logger configuration
- Result types for functional error handling
## 3. Dependency Direction Rules
1. **Domain Independence:** `domain` knows NOTHING about outer layers
- Defines *what* needs to be done, not *how*
- Pure Dart, zero Flutter dependencies
- Stable contracts that rarely change
- Entities include `fromJson`/`toJson` for practical V2 API serialization
2. **UI Agnosticism:** Features depend on `design_system` for UI and `domain` for logic
- Features do NOT know about Firebase or backend details
- Features do NOT know about HTTP/Dio details
- Backend changes don't affect feature implementation
3. **Data Isolation:** `data_connect` depends on `domain` to know interfaces
- Implements domain repository interfaces
- Maps backend models to domain entities
3. **Data Isolation:** Feature `data/` layer depends on `core` for API access and `domain` for entities
- RepoImpl uses `ApiService` with `V2ApiEndpoints`
- Maps JSON responses to domain entities via `Entity.fromJson()`
- Does NOT know about UI
**Dependency Flow:**
```
Apps → Features → Design System
→ Core Localization
Data Connect → Domain
→ Core
Core → Domain
```
## 4. Data Connect Service & Session Management
## 4. V2 API Service & Session Management
### 4.1 Session Handler Mixin
### 4.1 ApiService
**Location:** `apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart`
**Location:** `apps/mobile/packages/core/lib/src/services/api_service/api_service.dart`
**Responsibilities:**
- Automatic token refresh (triggered when <5 minutes to expiry)
- Firebase auth state listening
- Role-based access validation
- Session state stream emissions
- 3-attempt retry with exponential backoff (1s → 2s → 4s)
- Wraps Dio HTTP methods (GET, POST, PUT, PATCH, DELETE)
- Consistent response parsing via `ApiResponse`
- Consistent error handling (maps `DioException` to `ApiResponse` with V2 error envelope)
**Key Usage:**
```dart
final ApiService apiService;
// GET request
final response = await apiService.get(
V2ApiEndpoints.staffDashboard,
params: {'date': '2026-01-15'},
);
// POST request
final response = await apiService.post(
V2ApiEndpoints.staffClockIn,
data: {'shiftId': shiftId, 'latitude': lat, 'longitude': lng},
);
```
### 4.2 DioClient & Interceptors
**Location:** `apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart`
**Pre-configured with:**
- `AuthInterceptor` — Automatically attaches Firebase Auth ID token as `Bearer` token
- `IdempotencyInterceptor` — Adds `Idempotency-Key` (UUID v4) to POST/PUT/DELETE requests
- `LogInterceptor` — Logs request/response bodies for debugging
### 4.3 V2SessionService
**Location:** `apps/mobile/packages/core/lib/src/services/session/v2_session_service.dart`
**Responsibilities:**
- Manages session lifecycle (initialize, refresh, invalidate)
- Fetches session data from V2 API on auth state change
- Populates session stores with user/role data
- Provides session state stream for `SessionListener`
**Key Method:**
```dart
// Call once on app startup
DataConnectService.instance.initializeAuthListener(
V2SessionService.instance.initializeAuthListener(
allowedRoles: ['STAFF', 'BOTH'], // or ['CLIENT', 'BUSINESS', 'BOTH']
);
```
### 4.2 Session Listener Widget
### 4.4 Session Listener Widget
**Location:** `apps/mobile/apps/<app>/lib/src/widgets/session_listener.dart`
@@ -381,13 +465,13 @@ DataConnectService.instance.initializeAuthListener(
```dart
// main.dart
runApp(
SessionListener( // Critical wrapper
SessionListener( // Critical wrapper
child: ModularApp(module: AppModule(), child: AppWidget()),
),
);
```
### 4.3 Repository Pattern with Data Connect
### 4.5 Repository Pattern with V2 API
**Step 1:** Define interface in feature domain:
```dart
@@ -397,32 +481,33 @@ abstract interface class ProfileRepositoryInterface {
}
```
**Step 2:** Implement using `DataConnectService.run()`:
**Step 2:** Implement using `ApiService` with `V2ApiEndpoints`:
```dart
// features/staff/profile/lib/src/data/repositories_impl/
class ProfileRepositoryImpl implements ProfileRepositoryInterface {
final DataConnectService _service = DataConnectService.instance;
final ApiService _apiService;
ProfileRepositoryImpl({required ApiService apiService})
: _apiService = apiService;
@override
Future<Staff> getProfile(String id) async {
return await _service.run(() async {
final response = await _service.connector
.getStaffById(id: id)
.execute();
return _mapToStaff(response.data.staff);
});
final response = await _apiService.get(
V2ApiEndpoints.staffSession,
params: {'staffId': id},
);
return Staff.fromJson(response.data as Map<String, dynamic>);
}
}
```
**Benefits of `_service.run()`:**
- Auto validates user is authenticated
- ✅ Refreshes token if <5 min to expiry
- ✅ Executes the query
- ✅ 3-attempt retry with exponential backoff
- ✅ Maps exceptions to domain failures
**Benefits of `ApiService` + interceptors:**
- AuthInterceptor auto-attaches Firebase Auth token
- IdempotencyInterceptor prevents duplicate writes
- Consistent error handling via `ApiResponse`
- No manual token management in features
### 4.4 Session Store Pattern
### 4.6 Session Store Pattern
After successful auth, populate session stores:
@@ -451,9 +536,10 @@ ClientSessionStore.instance.setSession(
```dart
final session = StaffSessionStore.instance.session;
if (session?.staff == null) {
final staff = await getStaffById(session!.user.uid);
final response = await apiService.get(V2ApiEndpoints.staffSession);
final staff = Staff.fromJson(response.data as Map<String, dynamic>);
StaffSessionStore.instance.setSession(
session.copyWith(staff: staff),
session!.copyWith(staff: staff),
);
}
```
@@ -463,12 +549,12 @@ if (session?.staff == null) {
### Zero Direct Imports
```dart
// FORBIDDEN
// FORBIDDEN
import 'package:staff_profile/staff_profile.dart'; // in another feature
// ALLOWED
// ALLOWED
import 'package:krow_domain/krow_domain.dart'; // shared domain
import 'package:krow_core/krow_core.dart'; // shared utilities
import 'package:krow_core/krow_core.dart'; // shared utilities + API
import 'package:design_system/design_system.dart'; // shared UI
```
@@ -522,13 +608,13 @@ extension StaffNavigator on IModularNavigator {
**Usage in Features:**
```dart
// CORRECT
// CORRECT
Modular.to.toStaffHome();
Modular.to.toShiftDetails(shiftId: '123');
Modular.to.popSafe();
// AVOID
Modular.to.navigate('/home'); // No safety
// AVOID
Modular.to.navigate('/profile'); // No safety
Navigator.push(...); // No Modular integration
```
@@ -536,9 +622,9 @@ Navigator.push(...); // No Modular integration
Features don't share state directly. Use:
1. **Domain Repositories:** Centralized data sources
1. **Domain Repositories:** Centralized data sources via `ApiService`
2. **Session Stores:** `StaffSessionStore`, `ClientSessionStore` for app-wide context
3. **Event Streams:** If needed, via `DataConnectService` streams
3. **Event Streams:** If needed, via `V2SessionService` streams
4. **Navigation Arguments:** Pass IDs, not full objects
## 6. App-Specific Session Management
@@ -550,7 +636,7 @@ Features don't share state directly. Use:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
DataConnectService.instance.initializeAuthListener(
V2SessionService.instance.initializeAuthListener(
allowedRoles: ['STAFF', 'BOTH'],
);
@@ -564,11 +650,11 @@ void main() async {
**Session Store:** `StaffSessionStore`
- Fields: `user`, `staff`, `ownerId`
- Lazy load: `getStaffById()` if staff is null
- Lazy load: fetch from `V2ApiEndpoints.staffSession` if staff is null
**Navigation:**
- Authenticated `Modular.to.toStaffHome()`
- Unauthenticated `Modular.to.toInitialPage()`
- Authenticated -> `Modular.to.toStaffHome()`
- Unauthenticated -> `Modular.to.toInitialPage()`
### Client App
@@ -577,7 +663,7 @@ void main() async {
void main() async {
WidgetsFlutterBinding.ensureInitialized();
DataConnectService.instance.initializeAuthListener(
V2SessionService.instance.initializeAuthListener(
allowedRoles: ['CLIENT', 'BUSINESS', 'BOTH'],
);
@@ -591,137 +677,138 @@ void main() async {
**Session Store:** `ClientSessionStore`
- Fields: `user`, `business`
- Lazy load: `getBusinessById()` if business is null
- Lazy load: fetch from `V2ApiEndpoints.clientSession` if business is null
**Navigation:**
- Authenticated `Modular.to.toClientHome()`
- Unauthenticated `Modular.to.toInitialPage()`
- Authenticated -> `Modular.to.toClientHome()`
- Unauthenticated -> `Modular.to.toInitialPage()`
## 7. Data Connect Connectors Pattern
## 7. V2 API Repository Pattern
**Problem:** Without connectors, each feature duplicates backend queries.
**Problem:** Without a consistent pattern, each feature handles HTTP differently.
**Solution:** Centralize all backend queries in `data_connect/connectors/`.
**Solution:** Feature RepoImpl uses `ApiService` with `V2ApiEndpoints`, returning domain entities via `Entity.fromJson()`.
### Structure
Mirror backend connector structure:
Repository implementations live in the feature package:
```
data_connect/lib/src/connectors/
├── staff/
features/staff/profile/
├── lib/src/
│ ├── domain/
│ │ ── repositories/
│ │ └── staff_connector_repository.dart # Interface
│ └── usecases/
│ │ └── get_profile_completion_usecase.dart
└── data/
└── repositories/
└── staff_connector_repository_impl.dart # Implementation
├── order/
├── shifts/
└── user/
│ │ ── repositories/
│ │ └── profile_repository_interface.dart # Interface
├── data/
│ │ └── repositories_impl/
│ └── profile_repository_impl.dart # Implementation
│ └── presentation/
└── blocs/
│ └── profile_cubit.dart
```
**Maps to backend:**
```
backend/dataconnect/connector/
├── staff/
├── order/
├── shifts/
└── user/
```
### Repository Interface
### Clean Architecture in Connectors
**Domain Interface:**
```dart
// staff_connector_repository.dart
abstract interface class StaffConnectorRepository {
Future<bool> getProfileCompletion();
Future<Staff> getStaffById(String id);
// profile_repository_interface.dart
abstract interface class ProfileRepositoryInterface {
Future<Staff> getProfile();
Future<void> updatePersonalInfo(Map<String, dynamic> data);
Future<List<ProfileSection>> getProfileSections();
}
```
**Use Case:**
### Repository Implementation
```dart
// get_profile_completion_usecase.dart
class GetProfileCompletionUseCase {
final StaffConnectorRepository _repository;
// profile_repository_impl.dart
class ProfileRepositoryImpl implements ProfileRepositoryInterface {
final ApiService _apiService;
GetProfileCompletionUseCase({required StaffConnectorRepository repository})
: _repository = repository;
Future<bool> call() => _repository.getProfileCompletion();
}
```
**Data Implementation:**
```dart
// staff_connector_repository_impl.dart
class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
final DataConnectService _service;
ProfileRepositoryImpl({required ApiService apiService})
: _apiService = apiService;
@override
Future<bool> getProfileCompletion() async {
return _service.run(() async {
final staffId = await _service.getStaffId();
final response = await _service.connector
.getStaffProfileCompletion(id: staffId)
.execute();
Future<Staff> getProfile() async {
final response = await _apiService.get(V2ApiEndpoints.staffSession);
final data = response.data as Map<String, dynamic>;
return Staff.fromJson(data['staff'] as Map<String, dynamic>);
}
return _isProfileComplete(response);
});
@override
Future<void> updatePersonalInfo(Map<String, dynamic> data) async {
await _apiService.put(
V2ApiEndpoints.staffPersonalInfo,
data: data,
);
}
@override
Future<List<ProfileSection>> getProfileSections() async {
final response = await _apiService.get(V2ApiEndpoints.staffProfileSections);
final list = response.data['sections'] as List<dynamic>;
return list
.map((e) => ProfileSection.fromJson(e as Map<String, dynamic>))
.toList();
}
}
```
### Feature Integration
### Feature Module Integration
**Step 1:** Feature registers connector repository:
```dart
// staff_main_module.dart
class StaffMainModule extends Module {
// profile_module.dart
class ProfileModule extends Module {
@override
void binds(Injector i) {
i.addLazySingleton<StaffConnectorRepository>(
StaffConnectorRepositoryImpl.new,
i.addLazySingleton<ProfileRepositoryInterface>(
() => ProfileRepositoryImpl(apiService: i.get<ApiService>()),
);
i.addLazySingleton(
() => GetProfileCompletionUseCase(
repository: i.get<StaffConnectorRepository>(),
() => GetProfileUseCase(
repository: i.get<ProfileRepositoryInterface>(),
),
);
i.addLazySingleton(
() => StaffMainCubit(
getProfileCompletionUsecase: i.get(),
() => ProfileCubit(
getProfileUseCase: i.get(),
),
);
}
}
```
**Step 2:** BLoC uses it:
```dart
class StaffMainCubit extends Cubit<StaffMainState> {
final GetProfileCompletionUseCase _getProfileCompletionUsecase;
### BLoC Usage
Future<void> loadProfileCompletion() async {
final isComplete = await _getProfileCompletionUsecase();
emit(state.copyWith(isProfileComplete: isComplete));
```dart
class ProfileCubit extends Cubit<ProfileState> with BlocErrorHandler<ProfileState> {
final GetProfileUseCase _getProfileUseCase;
Future<void> loadProfile() async {
emit(state.copyWith(status: ProfileStatus.loading));
await handleError(
emit: emit,
action: () async {
final profile = await _getProfileUseCase();
emit(state.copyWith(status: ProfileStatus.loaded, profile: profile));
},
onError: (errorKey) => state.copyWith(status: ProfileStatus.error),
);
}
}
```
### Benefits
**No Duplication** - Query implemented once, used by many features
**Single Source of Truth** - Backend change → update one place
**Reusability** - Any feature can use any connector
**Testability** - Mock connector repo to test features
**Scalability** - Easy to add connectors as backend grows
- **No Duplication** — Endpoint constants defined once in `V2ApiEndpoints`
- **Consistent Auth** — `AuthInterceptor` handles token attachment automatically
- **Idempotent Writes** — `IdempotencyInterceptor` prevents duplicate mutations
- **Domain Purity** — Entities use `fromJson`/`toJson` directly, no mapping layers
- **Testability** — Mock `ApiService` to test RepoImpl in isolation
- **Scalability** — Add new endpoints to `V2ApiEndpoints`, implement in feature RepoImpl
## 8. Avoiding Prop Drilling: Direct BLoC Access
@@ -730,16 +817,16 @@ class StaffMainCubit extends Cubit<StaffMainState> {
Passing data through intermediate widgets creates maintenance burden:
```dart
// BAD: Prop drilling
// BAD: Prop drilling
ProfilePage(status: status)
ProfileHeader(status: status)
ProfileLevelBadge(status: status) // Only widget that needs it
-> ProfileHeader(status: status)
-> ProfileLevelBadge(status: status) // Only widget that needs it
```
### The Solution: BlocBuilder in Leaf Widgets
```dart
// GOOD: Direct BLoC access
// GOOD: Direct BLoC access
class ProfileLevelBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
@@ -765,9 +852,9 @@ class ProfileLevelBadge extends StatelessWidget {
**Decision Tree:**
```
Does this widget need data?
├─ YES, leaf widget Use BlocBuilder
├─ YES, container Use BlocBuilder in child
└─ NO Don't add prop
├─ YES, leaf widget -> Use BlocBuilder
├─ YES, container -> Use BlocBuilder in child
└─ NO -> Don't add prop
```
## 9. BLoC Lifecycle & State Emission Safety
@@ -780,7 +867,7 @@ StateError: Cannot emit new states after calling close
```
**Root Causes:**
1. Transient BLoCs created with `BlocProvider(create:)` disposed prematurely
1. Transient BLoCs created with `BlocProvider(create:)` -> disposed prematurely
2. Multiple BlocProviders disposing same singleton
3. User navigates away during async operation
@@ -789,26 +876,26 @@ StateError: Cannot emit new states after calling close
#### Step 1: Register as Singleton
```dart
// GOOD: Singleton registration
// GOOD: Singleton registration
i.addLazySingleton<ProfileCubit>(
() => ProfileCubit(useCase1, useCase2),
);
// BAD: Creates new instance each time
// BAD: Creates new instance each time
i.add(ProfileCubit.new);
```
#### Step 2: Use BlocProvider.value()
```dart
// GOOD: Reuse singleton
// GOOD: Reuse singleton
final cubit = Modular.get<ProfileCubit>();
BlocProvider<ProfileCubit>.value(
value: cubit,
child: MyWidget(),
)
// BAD: Creates duplicate
// BAD: Creates duplicate
BlocProvider<ProfileCubit>(
create: (_) => Modular.get<ProfileCubit>(),
child: MyWidget(),
@@ -845,7 +932,7 @@ class ProfileCubit extends Cubit<ProfileState> with BlocErrorHandler<ProfileStat
action: () async {
final profile = await getProfile();
emit(state.copyWith(status: ProfileStatus.loaded, profile: profile));
// Safe even if BLoC disposed
// Safe even if BLoC disposed
},
onError: (errorKey) => state.copyWith(status: ProfileStatus.error),
);
@@ -863,43 +950,48 @@ class ProfileCubit extends Cubit<ProfileState> with BlocErrorHandler<ProfileStat
## 10. Anti-Patterns to Avoid
**Feature imports feature**
- **Feature imports feature**
```dart
import 'package:staff_profile/staff_profile.dart'; // in another feature
```
**Business logic in BLoC**
- **Business logic in BLoC**
```dart
on<LoginRequested>((event, emit) {
if (event.email.isEmpty) { // Use case responsibility
if (event.email.isEmpty) { // Use case responsibility
emit(AuthError('Email required'));
}
});
```
**Direct Data Connect in features**
- **Direct HTTP/Dio in features (use ApiService)**
```dart
final response = await FirebaseDataConnect.instance.query(); // Use repository
final response = await Dio().get('https://api.example.com/staff'); // Use ApiService
```
**Global state variables**
- **Importing krow_data_connect (deprecated package)**
```dart
User? currentUser; // Use SessionStore
import 'package:krow_data_connect/krow_data_connect.dart'; // Use krow_core instead
```
**Direct Navigator.push**
- **Global state variables**
```dart
Navigator.push(context, MaterialPageRoute(...)); // Use Modular
User? currentUser; // Use SessionStore
```
**Hardcoded navigation**
- **Direct Navigator.push**
```dart
Modular.to.navigate('/profile'); // Use safe extensions
Navigator.push(context, MaterialPageRoute(...)); // Use Modular
```
**Hardcoded user-facing strings**
- **Hardcoded navigation**
```dart
Text('Order created successfully!'); // Use t.section.key from core_localization
Modular.to.navigate('/profile'); // Use safe extensions
```
- **Hardcoded user-facing strings**
```dart
Text('Order created successfully!'); // Use t.section.key from core_localization
```
## Summary
@@ -907,17 +999,20 @@ Text('Order created successfully!'); // ← Use t.section.key from core_localiz
The architecture enforces:
- **Clean Architecture** with strict layer boundaries
- **Feature Isolation** via zero cross-feature imports
- **Session Management** via DataConnectService and SessionListener
- **Connector Pattern** for reusable backend queries
- **V2 REST API** integration via `ApiService`, `V2ApiEndpoints`, and interceptors
- **Session Management** via `V2SessionService`, session stores, and `SessionListener`
- **Repository Pattern** with feature-local RepoImpl using `ApiService`
- **BLoC Lifecycle** safety with singletons and safe emit
- **Navigation Safety** with typed navigators and fallbacks
When implementing features:
1. Follow package structure strictly
2. Use connector repositories for backend access
3. Register BLoCs as singletons with `.value()`
4. Use safe navigation extensions
5. Avoid prop drilling with direct BLoC access
6. Keep domain pure and stable
2. Use `ApiService` with `V2ApiEndpoints` for all backend access
3. Domain entities use `fromJson`/`toJson` for V2 API serialization
4. RepoImpl lives in the feature `data/` layer, not a shared package
5. Register BLoCs as singletons with `.value()`
6. Use safe navigation extensions
7. Avoid prop drilling with direct BLoC access
8. Keep domain pure and stable
Architecture is not negotiable. When in doubt, refer to existing well-structured features or ask for clarification.

View File

@@ -1,6 +1,6 @@
---
name: krow-mobile-development-rules
description: Enforce KROW mobile app development standards including file structure, naming conventions, logic placement boundaries, localization, Data Connect integration, and prototype migration rules. Use this skill whenever working on KROW Flutter mobile features, creating new packages, implementing BLoCs, integrating with backend, or migrating from prototypes. Critical for maintaining clean architecture and preventing architectural degradation.
description: Enforce KROW mobile app development standards including file structure, naming conventions, logic placement boundaries, localization, V2 REST API integration, and prototype migration rules. Use this skill whenever working on KROW Flutter mobile features, creating new packages, implementing BLoCs, integrating with backend, or migrating from prototypes. Critical for maintaining clean architecture and preventing architectural degradation.
---
# KROW Mobile Development Rules
@@ -11,7 +11,7 @@ These rules are **NON-NEGOTIABLE** enforcement guidelines for the KROW mobile ap
- Creating new mobile features or packages
- Implementing BLoCs, Use Cases, or Repositories
- Integrating with Firebase Data Connect backend
- Integrating with V2 REST API backend
- Migrating code from prototypes
- Reviewing mobile code for compliance
- Setting up new feature modules
@@ -186,15 +186,17 @@ class _LoginPageState extends State<LoginPage> {
```dart
// profile_repository_impl.dart
class ProfileRepositoryImpl implements ProfileRepositoryInterface {
ProfileRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService;
final BaseApiService _apiService;
@override
Future<Staff> getProfile(String id) async {
final response = await dataConnect.getStaffById(id: id).execute();
// Data transformation happens here
return Staff(
id: response.data.staff.id,
name: response.data.staff.name,
// Map Data Connect model to Domain entity
final ApiResponse response = await _apiService.get(
V2ApiEndpoints.staffProfile(id),
);
// Data transformation happens here
return Staff.fromJson(response.data as Map<String, dynamic>);
}
}
```
@@ -252,7 +254,7 @@ Modular.to.pop(); // ← Can crash if stack is empty
**PATTERN:** All navigation MUST have fallback to Home page. Safe extensions automatically handle this.
### Session Management → DataConnectService + SessionHandlerMixin
### Session Management → V2SessionService + SessionHandlerMixin
**✅ CORRECT:**
```dart
@@ -261,7 +263,7 @@ void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize session listener (pick allowed roles for app)
DataConnectService.instance.initializeAuthListener(
V2SessionService.instance.initializeAuthListener(
allowedRoles: ['STAFF', 'BOTH'], // for staff app
);
@@ -274,28 +276,24 @@ void main() async {
// In repository:
class ProfileRepositoryImpl implements ProfileRepositoryInterface {
final DataConnectService _service = DataConnectService.instance;
ProfileRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService;
final BaseApiService _apiService;
@override
Future<Staff> getProfile(String id) async {
// _service.run() handles:
// - Auth validation
// - Token refresh (if <5 min to expiry)
// - Error handling with 3 retries
return await _service.run(() async {
final response = await _service.connector
.getStaffById(id: id)
.execute();
return _mapToStaff(response.data.staff);
});
final ApiResponse response = await _apiService.get(
V2ApiEndpoints.staffProfile(id),
);
return Staff.fromJson(response.data as Map<String, dynamic>);
}
}
```
**PATTERN:**
- **SessionListener** widget wraps app and shows dialogs for session errors
- **SessionHandlerMixin** in `DataConnectService` provides automatic token refresh
- **3-attempt retry logic** with exponential backoff (1s → 2s → 4s)
- **V2SessionService** provides automatic token refresh and auth management
- **ApiService** handles HTTP requests with automatic auth headers
- **Role validation** configurable per app
## 4. Localization Integration (core_localization)
@@ -372,7 +370,7 @@ class AppModule extends Module {
@override
List<Module> get imports => [
LocalizationModule(), // ← Required
DataConnectModule(),
CoreModule(),
];
}
@@ -387,44 +385,51 @@ runApp(
);
```
## 5. Data Connect Integration
## 5. V2 API Integration
All backend access goes through `DataConnectService`.
All backend access goes through `ApiService` with `V2ApiEndpoints`.
### Repository Pattern
**Step 1:** Define interface in feature domain:
**Step 1:** Define interface in feature domain (optional — feature-level domain layer is optional if entities from `krow_domain` suffice):
```dart
// domain/repositories/profile_repository_interface.dart
abstract interface class ProfileRepositoryInterface {
Future<Staff> getProfile(String id);
Future<bool> updateProfile(Staff profile);
// domain/repositories/shifts_repository_interface.dart
abstract interface class ShiftsRepositoryInterface {
Future<List<AssignedShift>> getAssignedShifts();
Future<AssignedShift> getShiftById(String id);
}
```
**Step 2:** Implement using `DataConnectService.run()`:
**Step 2:** Implement using `ApiService` + `V2ApiEndpoints`:
```dart
// data/repositories_impl/profile_repository_impl.dart
class ProfileRepositoryImpl implements ProfileRepositoryInterface {
final DataConnectService _service = DataConnectService.instance;
// data/repositories_impl/shifts_repository_impl.dart
class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
ShiftsRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService;
final BaseApiService _apiService;
@override
Future<Staff> getProfile(String id) async {
return await _service.run(() async {
final response = await _service.connector
.getStaffById(id: id)
.execute();
return _mapToStaff(response.data.staff);
});
Future<List<AssignedShift>> getAssignedShifts() async {
final ApiResponse response = await _apiService.get(V2ApiEndpoints.staffShiftsAssigned);
final List<dynamic> items = response.data['items'] as List<dynamic>;
return items.map((dynamic json) => AssignedShift.fromJson(json as Map<String, dynamic>)).toList();
}
@override
Future<AssignedShift> getShiftById(String id) async {
final ApiResponse response = await _apiService.get(V2ApiEndpoints.staffShift(id));
return AssignedShift.fromJson(response.data as Map<String, dynamic>);
}
}
```
**Benefits of `_service.run()`:**
- ✅ Automatic auth validation
- ✅ Token refresh if needed
- ✅ 3-attempt retry with exponential backoff
- ✅ Consistent error handling
### Key Conventions
- **Domain entities** have `fromJson` / `toJson` factory methods for serialization
- **Status fields** use enums from `krow_domain` (e.g., `ShiftStatus`, `OrderStatus`)
- **Money** is represented in cents as `int` (never `double`)
- **Timestamps** are `DateTime` objects (parsed from ISO 8601 strings)
- **Feature-level domain layer** is optional when `krow_domain` entities cover the need
### Session Store Pattern
@@ -448,7 +453,7 @@ ClientSessionStore.instance.setSession(
);
```
**Lazy Loading:** If session is null, fetch via `getStaffById()` or `getBusinessById()` and update store.
**Lazy Loading:** If session is null, fetch via the appropriate `ApiService.get()` endpoint and update store.
## 6. Prototype Migration Rules
@@ -462,7 +467,7 @@ When migrating from `prototypes/`:
### ❌ MUST REJECT & REFACTOR
- `GetX`, `Provider`, or `MVC` patterns
- Any state management not using BLoC
- Direct HTTP calls (must use Data Connect)
- Direct HTTP calls (must use ApiService with V2ApiEndpoints)
- Hardcoded colors/typography (must use design system)
- Global state variables
- Navigation without Modular
@@ -491,13 +496,12 @@ If requirements are unclear:
### DO NOT
- Add 3rd party packages without checking `apps/mobile/packages/core` first
- Add `firebase_auth` or `firebase_data_connect` to Feature packages (they belong in `data_connect` only)
- Use `addSingleton` for BLoCs (always use `add` method in Modular)
- Add `firebase_auth` or `firebase_data_connect` to Feature packages (they belong in `core` only)
### DO
- Use `DataConnectService.instance` for backend operations
- Use `ApiService` with `V2ApiEndpoints` for backend operations
- Use Flutter Modular for dependency injection
- Register BLoCs with `i.addSingleton<CubitType>(() => CubitType(...))`
- Register BLoCs with `i.add<CubitType>(() => CubitType(...))` (transient)
- Register Use Cases as factories or singletons as needed
## 9. Error Handling Pattern
@@ -516,15 +520,12 @@ class InvalidCredentialsFailure extends AuthFailure {
### Repository Error Mapping
```dart
// Map Data Connect exceptions to Domain failures
// Map API errors to Domain failures using ApiErrorHandler
try {
final response = await dataConnect.query();
return Right(response);
} on DataConnectException catch (e) {
if (e.message.contains('unauthorized')) {
return Left(InvalidCredentialsFailure());
}
return Left(ServerFailure(e.message));
final response = await _apiService.get(V2ApiEndpoints.staffProfile(id));
return Right(Staff.fromJson(response.data as Map<String, dynamic>));
} catch (e) {
return Left(ApiErrorHandler.mapToFailure(e));
}
```
@@ -579,7 +580,7 @@ testWidgets('shows loading indicator when logging in', (tester) async {
```
### Integration Tests
- Test full feature flows end-to-end with Data Connect
- Test full feature flows end-to-end with V2 API
- Use dependency injection to swap implementations if needed
## 11. Clean Code Principles
@@ -635,12 +636,12 @@ Before merging any mobile feature code:
- [ ] Zero analyzer warnings
### Integration
- [ ] Data Connect queries via `_service.run()`
- [ ] V2 API calls via `ApiService` + `V2ApiEndpoints`
- [ ] Error handling with domain failures
- [ ] Proper dependency injection in modules
## Summary
The key principle: **Clean Architecture with zero tolerance for violations.** Business logic in Use Cases, state in BLoCs, data access in Repositories, UI in Widgets. Features are isolated, backend is centralized, localization is mandatory, and design system is immutable.
The key principle: **Clean Architecture with zero tolerance for violations.** Business logic in Use Cases, state in BLoCs, data access in Repositories (via `ApiService` + `V2ApiEndpoints`), UI in Widgets. Features are isolated, backend access is centralized through the V2 REST API layer, localization is mandatory, and design system is immutable.
When in doubt, refer to existing features following these patterns or ask for clarification. It's better to ask than to introduce architectural debt.

View File

@@ -20,11 +20,6 @@ public final class GeneratedPluginRegistrant {
} 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) {
Log.e(TAG, "Error registering plugin firebase_app_check, io.flutter.plugins.firebase.appcheck.FlutterFirebaseAppCheckPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.auth.FlutterFirebaseAuthPlugin());
} catch (Exception e) {

View File

@@ -12,12 +12,6 @@
@import file_picker;
#endif
#if __has_include(<firebase_app_check/FLTFirebaseAppCheckPlugin.h>)
#import <firebase_app_check/FLTFirebaseAppCheckPlugin.h>
#else
@import firebase_app_check;
#endif
#if __has_include(<firebase_auth/FLTFirebaseAuthPlugin.h>)
#import <firebase_auth/FLTFirebaseAuthPlugin.h>
#else
@@ -82,7 +76,6 @@
+ (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"]];
[FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]];

View File

@@ -14,7 +14,6 @@ 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 'firebase_options.dart';
import 'src/widgets/session_listener.dart';
@@ -31,15 +30,6 @@ void main() async {
logStateChanges: false, // Set to true for verbose debugging
);
// Initialize session listener for Firebase Auth state changes
DataConnectService.instance.initializeAuthListener(
allowedRoles: <String>[
'CLIENT',
'BUSINESS',
'BOTH',
], // Only allow users with CLIENT, BUSINESS, or BOTH roles
);
runApp(
ModularApp(
module: AppModule(),

View File

@@ -3,7 +3,7 @@ import 'dart:async';
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';
import 'package:krow_domain/krow_domain.dart' show UserRole;
/// A widget that listens to session state changes and handles global reactions.
///
@@ -28,11 +28,20 @@ class _SessionListenerState extends State<SessionListener> {
@override
void initState() {
super.initState();
_setupSessionListener();
_initializeSession();
}
void _setupSessionListener() {
_sessionSubscription = DataConnectService.instance.onSessionStateChanged
void _initializeSession() {
// Resolve V2SessionService via DI — this triggers CoreModule's lazy
// singleton, which wires setApiService(). Must happen before
// initializeAuthListener so the session endpoint is reachable.
final V2SessionService sessionService = Modular.get<V2SessionService>();
sessionService.initializeAuthListener(
allowedRoles: const <UserRole>[UserRole.business, UserRole.both],
);
_sessionSubscription = sessionService.onSessionStateChanged
.listen((SessionState state) {
_handleSessionChange(state);
});
@@ -134,7 +143,7 @@ class _SessionListenerState extends State<SessionListener> {
),
TextButton(
onPressed: () {
Modular.to.popSafe();;
Modular.to.popSafe();
_proceedToLogin();
},
child: const Text('Log Out'),
@@ -147,8 +156,9 @@ class _SessionListenerState extends State<SessionListener> {
/// Navigate to login screen and clear navigation stack.
void _proceedToLogin() {
// Clear service caches on sign-out
DataConnectService.instance.handleSignOut();
// Clear session stores on sign-out
V2SessionService.instance.handleSignOut();
ClientSessionStore.instance.clear();
// Navigate to authentication
Modular.to.toClientGetStartedPage();

View File

@@ -7,7 +7,6 @@ import Foundation
import file_picker
import file_selector_macos
import firebase_app_check
import firebase_auth
import firebase_core
import flutter_local_notifications
@@ -20,7 +19,6 @@ 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"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))

View File

@@ -41,7 +41,6 @@ dependencies:
flutter_localizations:
sdk: flutter
firebase_core: ^4.4.0
krow_data_connect: ^0.0.1
dev_dependencies:
flutter_test:

View File

@@ -20,11 +20,6 @@ public final class GeneratedPluginRegistrant {
} 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) {
Log.e(TAG, "Error registering plugin firebase_app_check, io.flutter.plugins.firebase.appcheck.FlutterFirebaseAppCheckPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.auth.FlutterFirebaseAuthPlugin());
} catch (Exception e) {
@@ -50,11 +45,6 @@ public final class GeneratedPluginRegistrant {
} catch (Exception e) {
Log.e(TAG, "Error registering plugin geolocator_android, com.baseflow.geolocator.GeolocatorPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.googlemaps.GoogleMapsPlugin());
} 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) {

View File

@@ -12,12 +12,6 @@
@import file_picker;
#endif
#if __has_include(<firebase_app_check/FLTFirebaseAppCheckPlugin.h>)
#import <firebase_app_check/FLTFirebaseAppCheckPlugin.h>
#else
@import firebase_app_check;
#endif
#if __has_include(<firebase_auth/FLTFirebaseAuthPlugin.h>)
#import <firebase_auth/FLTFirebaseAuthPlugin.h>
#else
@@ -42,12 +36,6 @@
@import geolocator_apple;
#endif
#if __has_include(<google_maps_flutter_ios/FLTGoogleMapsPlugin.h>)
#import <google_maps_flutter_ios/FLTGoogleMapsPlugin.h>
#else
@import google_maps_flutter_ios;
#endif
#if __has_include(<image_picker_ios/FLTImagePickerPlugin.h>)
#import <image_picker_ios/FLTImagePickerPlugin.h>
#else
@@ -94,12 +82,10 @@
+ (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"]];
[FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]];
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
[FLTGoogleMapsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTGoogleMapsPlugin"]];
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
[FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]];
[RecordIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"RecordIosPlugin"]];

View File

@@ -6,7 +6,6 @@ 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;
@@ -29,14 +28,6 @@ void main() async {
logStateChanges: false, // Set to true for verbose debugging
);
// Initialize session listener for Firebase Auth state changes
DataConnectService.instance.initializeAuthListener(
allowedRoles: <String>[
'STAFF',
'BOTH',
], // Only allow users with STAFF or BOTH roles
);
runApp(
ModularApp(
module: AppModule(),

View File

@@ -3,7 +3,7 @@ import 'dart:async';
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';
import 'package:krow_domain/krow_domain.dart' show UserRole;
/// A widget that listens to session state changes and handles global reactions.
///
@@ -28,11 +28,20 @@ class _SessionListenerState extends State<SessionListener> {
@override
void initState() {
super.initState();
_setupSessionListener();
_initializeSession();
}
void _setupSessionListener() {
_sessionSubscription = DataConnectService.instance.onSessionStateChanged
void _initializeSession() {
// Resolve V2SessionService via DI — this triggers CoreModule's lazy
// singleton, which wires setApiService(). Must happen before
// initializeAuthListener so the session endpoint is reachable.
final V2SessionService sessionService = Modular.get<V2SessionService>();
sessionService.initializeAuthListener(
allowedRoles: const <UserRole>[UserRole.staff, UserRole.both],
);
_sessionSubscription = sessionService.onSessionStateChanged
.listen((SessionState state) {
_handleSessionChange(state);
});
@@ -65,6 +74,19 @@ class _SessionListenerState extends State<SessionListener> {
_sessionExpiredDialogShown = false;
debugPrint('[SessionListener] Authenticated: ${state.userId}');
// Don't auto-navigate while the auth flow is active — the auth
// BLoC handles its own navigation (e.g. profile-setup for new users).
final String currentPath = Modular.to.path;
if (currentPath.contains('/phone-verification') ||
currentPath.contains('/profile-setup') ||
currentPath.contains('/get-started')) {
debugPrint(
'[SessionListener] Skipping home navigation — auth flow active '
'(path: $currentPath)',
);
break;
}
// Navigate to the main app
Modular.to.toStaffHome();
break;
@@ -104,7 +126,7 @@ class _SessionListenerState extends State<SessionListener> {
actions: <Widget>[
TextButton(
onPressed: () {
Modular.to.popSafe();;
Modular.to.popSafe();
_proceedToLogin();
},
child: const Text('Log In'),
@@ -134,7 +156,7 @@ class _SessionListenerState extends State<SessionListener> {
),
TextButton(
onPressed: () {
Modular.to.popSafe();;
Modular.to.popSafe();
_proceedToLogin();
},
child: const Text('Log Out'),
@@ -147,8 +169,9 @@ class _SessionListenerState extends State<SessionListener> {
/// Navigate to login screen and clear navigation stack.
void _proceedToLogin() {
// Clear service caches on sign-out
DataConnectService.instance.handleSignOut();
// Clear session stores on sign-out
V2SessionService.instance.handleSignOut();
StaffSessionStore.instance.clear();
// Navigate to authentication
Modular.to.toGetStartedPage();

View File

@@ -7,7 +7,6 @@ import Foundation
import file_picker
import file_selector_macos
import firebase_app_check
import firebase_auth
import firebase_core
import flutter_local_notifications
@@ -21,7 +20,6 @@ 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"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))

View File

@@ -28,8 +28,6 @@ dependencies:
path: ../../packages/features/staff/staff_main
krow_core:
path: ../../packages/core
krow_data_connect:
path: ../../packages/data_connect
cupertino_icons: ^1.0.8
flutter_modular: ^6.3.0
firebase_core: ^4.4.0

View File

@@ -16,8 +16,16 @@ 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';
// API Mixins
export 'src/services/api_service/mixins/api_error_handler.dart';
export 'src/services/api_service/mixins/session_handler_mixin.dart';
// Feature Gate & Endpoint classes
export 'src/services/api_service/feature_gate.dart';
export 'src/services/api_service/endpoints/auth_endpoints.dart';
export 'src/services/api_service/endpoints/client_endpoints.dart';
export 'src/services/api_service/endpoints/core_endpoints.dart';
export 'src/services/api_service/endpoints/staff_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';
@@ -29,6 +37,11 @@ export 'src/services/api_service/core_api_services/verification/verification_res
export 'src/services/api_service/core_api_services/rapid_order/rapid_order_service.dart';
export 'src/services/api_service/core_api_services/rapid_order/rapid_order_response.dart';
// Session Management
export 'src/services/session/client_session_store.dart';
export 'src/services/session/staff_session_store.dart';
export 'src/services/session/v2_session_service.dart';
// Device Services
export 'src/services/device/camera/camera_service.dart';
export 'src/services/device/gallery/gallery_service.dart';

View File

@@ -13,4 +13,10 @@ class AppConfig {
static const String coreApiBaseUrl = String.fromEnvironment(
'CORE_API_BASE_URL',
);
/// The base URL for the V2 Unified API gateway.
static const String v2ApiBaseUrl = String.fromEnvironment(
'V2_API_BASE_URL',
defaultValue: 'https://krow-api-v2-933560802882.us-central1.run.app',
);
}

View File

@@ -18,6 +18,14 @@ class CoreModule extends Module {
// 2. Register the base API service
i.addLazySingleton<BaseApiService>(() => ApiService(i.get<Dio>()));
// 2b. Register V2SessionService — wires the singleton with ApiService.
// Resolved eagerly by SessionListener.initState() after Modular is ready.
i.addLazySingleton<V2SessionService>(() {
final V2SessionService service = V2SessionService.instance;
service.setApiService(i.get<BaseApiService>());
return service;
});
// 3. Register Core API Services (Orchestrators)
i.addLazySingleton<FileUploadService>(
() => FileUploadService(i.get<BaseApiService>()),

View File

@@ -98,6 +98,13 @@ extension StaffNavigator on IModularNavigator {
safeNavigate(StaffPaths.shiftDetails(shift.id), arguments: shift);
}
/// Navigates to shift details by ID only (no pre-fetched [Shift] object).
///
/// Used when only the shift ID is available (e.g. from dashboard list items).
void toShiftDetailsById(String shiftId) {
safeNavigate(StaffPaths.shiftDetails(shiftId));
}
void toPersonalInfo() {
safePush(StaffPaths.onboardingPersonalInfo);
}
@@ -118,7 +125,7 @@ extension StaffNavigator on IModularNavigator {
safeNavigate(StaffPaths.attire);
}
void toAttireCapture({required AttireItem item, String? initialPhotoUrl}) {
void toAttireCapture({required AttireChecklist item, String? initialPhotoUrl}) {
safeNavigate(
StaffPaths.attireCapture,
arguments: <String, dynamic>{
@@ -132,7 +139,7 @@ extension StaffNavigator on IModularNavigator {
safeNavigate(StaffPaths.documents);
}
void toDocumentUpload({required StaffDocument document, String? initialUrl}) {
void toDocumentUpload({required ProfileDocument document, String? initialUrl}) {
safeNavigate(
StaffPaths.documentUpload,
arguments: <String, dynamic>{

View File

@@ -1,10 +1,13 @@
import 'package:dio/dio.dart';
import 'package:krow_domain/krow_domain.dart';
import 'feature_gate.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.
/// Integrates [FeatureGate] for scope validation and throws typed domain
/// exceptions ([ApiException], [NetworkException], [ServerException]) on
/// error responses so repositories never receive silent failures.
class ApiService implements BaseApiService {
/// Creates an [ApiService] with the given [Dio] instance.
ApiService(this._dio);
@@ -15,113 +18,164 @@ class ApiService implements BaseApiService {
/// Performs a GET request to the specified [endpoint].
@override
Future<ApiResponse> get(
String endpoint, {
ApiEndpoint endpoint, {
Map<String, dynamic>? params,
}) async {
FeatureGate.instance.validateAccess(endpoint);
try {
final Response<dynamic> response = await _dio.get<dynamic>(
endpoint,
endpoint.path,
queryParameters: params,
);
return _handleResponse(response);
} on DioException catch (e) {
return _handleError(e);
throw _mapDioException(e);
}
}
/// Performs a POST request to the specified [endpoint].
@override
Future<ApiResponse> post(
String endpoint, {
ApiEndpoint endpoint, {
dynamic data,
Map<String, dynamic>? params,
}) async {
FeatureGate.instance.validateAccess(endpoint);
try {
final Response<dynamic> response = await _dio.post<dynamic>(
endpoint,
endpoint.path,
data: data,
queryParameters: params,
);
return _handleResponse(response);
} on DioException catch (e) {
return _handleError(e);
throw _mapDioException(e);
}
}
/// Performs a PUT request to the specified [endpoint].
@override
Future<ApiResponse> put(
String endpoint, {
ApiEndpoint endpoint, {
dynamic data,
Map<String, dynamic>? params,
}) async {
FeatureGate.instance.validateAccess(endpoint);
try {
final Response<dynamic> response = await _dio.put<dynamic>(
endpoint,
endpoint.path,
data: data,
queryParameters: params,
);
return _handleResponse(response);
} on DioException catch (e) {
return _handleError(e);
throw _mapDioException(e);
}
}
/// Performs a PATCH request to the specified [endpoint].
@override
Future<ApiResponse> patch(
String endpoint, {
ApiEndpoint endpoint, {
dynamic data,
Map<String, dynamic>? params,
}) async {
FeatureGate.instance.validateAccess(endpoint);
try {
final Response<dynamic> response = await _dio.patch<dynamic>(
endpoint,
endpoint.path,
data: data,
queryParameters: params,
);
return _handleResponse(response);
} on DioException catch (e) {
return _handleError(e);
throw _mapDioException(e);
}
}
/// Performs a DELETE request to the specified [endpoint].
@override
Future<ApiResponse> delete(
ApiEndpoint endpoint, {
dynamic data,
Map<String, dynamic>? params,
}) async {
FeatureGate.instance.validateAccess(endpoint);
try {
final Response<dynamic> response = await _dio.delete<dynamic>(
endpoint.path,
data: data,
queryParameters: params,
);
return _handleResponse(response);
} on DioException catch (e) {
throw _mapDioException(e);
}
}
// ---------------------------------------------------------------------------
// Response handling
// ---------------------------------------------------------------------------
/// Extracts [ApiResponse] from a successful [Response].
ApiResponse _handleResponse(Response<dynamic> response) {
final dynamic body = response.data;
final String message = body is Map<String, dynamic>
? body['message']?.toString() ?? 'Success'
: 'Success';
return ApiResponse(
code: response.statusCode?.toString() ?? '200',
message: response.data['message']?.toString() ?? 'Success',
data: response.data,
message: message,
data: body,
);
}
/// Extracts [ApiResponse] from a [DioException].
ApiResponse _handleError(DioException e) {
/// Maps a [DioException] to a typed domain exception.
///
/// The V2 API error envelope is `{ code, message, details, requestId }`.
/// This method parses it and throws the appropriate [AppException] subclass
/// so that `BlocErrorHandler` can translate it for the user.
AppException _mapDioException(DioException e) {
// Network-level failures (no response from server).
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout ||
e.type == DioExceptionType.sendTimeout ||
e.type == DioExceptionType.connectionError) {
return NetworkException(technicalMessage: e.message);
}
final int? statusCode = e.response?.statusCode;
// Parse V2 error envelope if available.
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']),
);
final String apiCode =
body['code']?.toString() ?? statusCode?.toString() ?? 'UNKNOWN';
final String apiMessage =
body['message']?.toString() ?? e.message ?? 'An error occurred';
// Map well-known codes to specific exceptions.
if (apiCode == 'UNAUTHENTICATED' || statusCode == 401) {
return NotAuthenticatedException(technicalMessage: apiMessage);
}
return ApiResponse(
code: e.response?.statusCode?.toString() ?? 'error',
message: e.message ?? 'Unknown error',
errors: <String, dynamic>{'exception': e.type.toString()},
return ApiException(
apiCode: apiCode,
apiMessage: apiMessage,
statusCode: statusCode,
details: body['details'],
technicalMessage: '$apiCode: $apiMessage',
);
}
/// 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);
// Server error without a parseable body.
if (statusCode != null && statusCode >= 500) {
return ServerException(technicalMessage: e.message);
}
return const <String, dynamic>{};
return UnknownException(technicalMessage: e.message);
}
}

View File

@@ -1,40 +0,0 @@
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';
/// Transcribe audio to text for rapid orders.
static const String transcribeRapidOrder =
'$baseUrl/core/rapid-orders/transcribe';
/// Parse text to structured rapid order.
static const String parseRapidOrder = '$baseUrl/core/rapid-orders/parse';
}

View File

@@ -1,6 +1,6 @@
import 'package:dio/dio.dart';
import 'package:krow_domain/krow_domain.dart';
import '../core_api_endpoints.dart';
import '../../endpoints/core_endpoints.dart';
import 'file_upload_response.dart';
/// Service for uploading files to the Core API.
@@ -26,7 +26,7 @@ class FileUploadService extends BaseCoreService {
if (category != null) 'category': category,
});
return api.post(CoreApiEndpoints.uploadFile, data: formData);
return api.post(CoreEndpoints.uploadFile, data: formData);
});
if (res.code.startsWith('2')) {

View File

@@ -1,5 +1,5 @@
import 'package:krow_domain/krow_domain.dart';
import '../core_api_endpoints.dart';
import '../../endpoints/core_endpoints.dart';
import 'llm_response.dart';
/// Service for invoking Large Language Models (LLM).
@@ -19,7 +19,7 @@ class LlmService extends BaseCoreService {
}) async {
final ApiResponse res = await action(() async {
return api.post(
CoreApiEndpoints.invokeLlm,
CoreEndpoints.invokeLlm,
data: <String, dynamic>{
'prompt': prompt,
if (responseJsonSchema != null)

View File

@@ -1,5 +1,5 @@
import 'package:krow_domain/krow_domain.dart';
import '../core_api_endpoints.dart';
import '../../endpoints/core_endpoints.dart';
import 'rapid_order_response.dart';
/// Service for handling RAPID order operations (Transcription and Parsing).
@@ -19,7 +19,7 @@ class RapidOrderService extends BaseCoreService {
}) async {
final ApiResponse res = await action(() async {
return api.post(
CoreApiEndpoints.transcribeRapidOrder,
CoreEndpoints.transcribeRapidOrder,
data: <String, dynamic>{
'audioFileUri': audioFileUri,
'locale': locale,
@@ -51,7 +51,7 @@ class RapidOrderService extends BaseCoreService {
}) async {
final ApiResponse res = await action(() async {
return api.post(
CoreApiEndpoints.parseRapidOrder,
CoreEndpoints.parseRapidOrder,
data: <String, dynamic>{
'text': text,
'locale': locale,

View File

@@ -1,5 +1,5 @@
import 'package:krow_domain/krow_domain.dart';
import '../core_api_endpoints.dart';
import '../../endpoints/core_endpoints.dart';
import 'signed_url_response.dart';
/// Service for creating signed URLs for Cloud Storage objects.
@@ -17,7 +17,7 @@ class SignedUrlService extends BaseCoreService {
}) async {
final ApiResponse res = await action(() async {
return api.post(
CoreApiEndpoints.createSignedUrl,
CoreEndpoints.createSignedUrl,
data: <String, dynamic>{
'fileUri': fileUri,
'expiresInSeconds': expiresInSeconds,

View File

@@ -1,5 +1,5 @@
import 'package:krow_domain/krow_domain.dart';
import '../core_api_endpoints.dart';
import '../../endpoints/core_endpoints.dart';
import 'verification_response.dart';
/// Service for handling async verification jobs.
@@ -22,7 +22,7 @@ class VerificationService extends BaseCoreService {
}) async {
final ApiResponse res = await action(() async {
return api.post(
CoreApiEndpoints.verifications,
CoreEndpoints.verifications,
data: <String, dynamic>{
'type': type,
'subjectType': subjectType,
@@ -44,7 +44,7 @@ class VerificationService extends BaseCoreService {
/// 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));
return api.get(CoreEndpoints.verificationStatus(verificationId));
});
if (res.code.startsWith('2')) {
@@ -65,7 +65,7 @@ class VerificationService extends BaseCoreService {
}) async {
final ApiResponse res = await action(() async {
return api.post(
CoreApiEndpoints.verificationReview(verificationId),
CoreEndpoints.verificationReview(verificationId),
data: <String, dynamic>{
'decision': decision,
if (note != null) 'note': note,
@@ -84,7 +84,7 @@ class VerificationService extends BaseCoreService {
/// 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));
return api.post(CoreEndpoints.verificationRetry(verificationId));
});
if (res.code.startsWith('2')) {

View File

@@ -1,13 +1,19 @@
import 'package:dio/dio.dart';
import 'package:krow_core/src/config/app_config.dart';
import 'package:krow_core/src/services/api_service/inspectors/auth_interceptor.dart';
import 'package:krow_core/src/services/api_service/inspectors/idempotency_interceptor.dart';
/// A custom Dio client for the KROW project that includes basic configuration
/// and an [AuthInterceptor].
/// A custom Dio client for the KROW project that includes basic configuration,
/// [AuthInterceptor], and [IdempotencyInterceptor].
///
/// Sets [AppConfig.v2ApiBaseUrl] as the base URL so that endpoint paths only
/// need to be relative (e.g. '/staff/dashboard').
class DioClient extends DioMixin implements Dio {
DioClient([BaseOptions? baseOptions]) {
options =
baseOptions ??
BaseOptions(
baseUrl: AppConfig.v2ApiBaseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
);
@@ -18,10 +24,11 @@ class DioClient extends DioMixin implements Dio {
// Add interceptors
interceptors.addAll(<Interceptor>[
AuthInterceptor(),
IdempotencyInterceptor(),
LogInterceptor(
requestBody: true,
responseBody: true,
), // Added for better debugging
),
]);
}
}

View File

@@ -0,0 +1,34 @@
import 'package:krow_domain/krow_domain.dart' show ApiEndpoint;
/// Authentication endpoints for both staff and client apps.
abstract final class AuthEndpoints {
/// Client email/password sign-in.
static const ApiEndpoint clientSignIn =
ApiEndpoint('/auth/client/sign-in');
/// Client business registration.
static const ApiEndpoint clientSignUp =
ApiEndpoint('/auth/client/sign-up');
/// Client sign-out.
static const ApiEndpoint clientSignOut =
ApiEndpoint('/auth/client/sign-out');
/// Start staff phone verification (SMS).
static const ApiEndpoint staffPhoneStart =
ApiEndpoint('/auth/staff/phone/start');
/// Complete staff phone verification.
static const ApiEndpoint staffPhoneVerify =
ApiEndpoint('/auth/staff/phone/verify');
/// Generic sign-out.
static const ApiEndpoint signOut = ApiEndpoint('/auth/sign-out');
/// Staff-specific sign-out.
static const ApiEndpoint staffSignOut =
ApiEndpoint('/auth/staff/sign-out');
/// Get current session data.
static const ApiEndpoint session = ApiEndpoint('/auth/session');
}

View File

@@ -0,0 +1,165 @@
import 'package:krow_domain/krow_domain.dart' show ApiEndpoint;
/// Client-specific API endpoints (read and write).
abstract final class ClientEndpoints {
// ── Read ──────────────────────────────────────────────────────────────
/// Client session data.
static const ApiEndpoint session = ApiEndpoint('/client/session');
/// Client dashboard.
static const ApiEndpoint dashboard = ApiEndpoint('/client/dashboard');
/// Client reorders.
static const ApiEndpoint reorders = ApiEndpoint('/client/reorders');
/// Billing accounts.
static const ApiEndpoint billingAccounts =
ApiEndpoint('/client/billing/accounts');
/// Pending invoices.
static const ApiEndpoint billingInvoicesPending =
ApiEndpoint('/client/billing/invoices/pending');
/// Invoice history.
static const ApiEndpoint billingInvoicesHistory =
ApiEndpoint('/client/billing/invoices/history');
/// Current bill.
static const ApiEndpoint billingCurrentBill =
ApiEndpoint('/client/billing/current-bill');
/// Savings data.
static const ApiEndpoint billingSavings =
ApiEndpoint('/client/billing/savings');
/// Spend breakdown.
static const ApiEndpoint billingSpendBreakdown =
ApiEndpoint('/client/billing/spend-breakdown');
/// Coverage overview.
static const ApiEndpoint coverage = ApiEndpoint('/client/coverage');
/// Coverage stats.
static const ApiEndpoint coverageStats =
ApiEndpoint('/client/coverage/stats');
/// Core team.
static const ApiEndpoint coverageCoreTeam =
ApiEndpoint('/client/coverage/core-team');
/// Hubs list.
static const ApiEndpoint hubs = ApiEndpoint('/client/hubs');
/// Cost centers.
static const ApiEndpoint costCenters =
ApiEndpoint('/client/cost-centers');
/// Vendors.
static const ApiEndpoint vendors = ApiEndpoint('/client/vendors');
/// Vendor roles by ID.
static ApiEndpoint vendorRoles(String vendorId) =>
ApiEndpoint('/client/vendors/$vendorId/roles');
/// Hub managers by ID.
static ApiEndpoint hubManagers(String hubId) =>
ApiEndpoint('/client/hubs/$hubId/managers');
/// Team members.
static const ApiEndpoint teamMembers =
ApiEndpoint('/client/team-members');
/// View orders.
static const ApiEndpoint ordersView =
ApiEndpoint('/client/orders/view');
/// Order reorder preview.
static ApiEndpoint orderReorderPreview(String orderId) =>
ApiEndpoint('/client/orders/$orderId/reorder-preview');
/// Reports summary.
static const ApiEndpoint reportsSummary =
ApiEndpoint('/client/reports/summary');
/// Daily ops report.
static const ApiEndpoint reportsDailyOps =
ApiEndpoint('/client/reports/daily-ops');
/// Spend report.
static const ApiEndpoint reportsSpend =
ApiEndpoint('/client/reports/spend');
/// Coverage report.
static const ApiEndpoint reportsCoverage =
ApiEndpoint('/client/reports/coverage');
/// Forecast report.
static const ApiEndpoint reportsForecast =
ApiEndpoint('/client/reports/forecast');
/// Performance report.
static const ApiEndpoint reportsPerformance =
ApiEndpoint('/client/reports/performance');
/// No-show report.
static const ApiEndpoint reportsNoShow =
ApiEndpoint('/client/reports/no-show');
// ── Write ─────────────────────────────────────────────────────────────
/// Create one-time order.
static const ApiEndpoint ordersOneTime =
ApiEndpoint('/client/orders/one-time');
/// Create recurring order.
static const ApiEndpoint ordersRecurring =
ApiEndpoint('/client/orders/recurring');
/// Create permanent order.
static const ApiEndpoint ordersPermanent =
ApiEndpoint('/client/orders/permanent');
/// Edit order by ID.
static ApiEndpoint orderEdit(String orderId) =>
ApiEndpoint('/client/orders/$orderId/edit');
/// Cancel order by ID.
static ApiEndpoint orderCancel(String orderId) =>
ApiEndpoint('/client/orders/$orderId/cancel');
/// Create hub (same path as list hubs).
static const ApiEndpoint hubCreate = ApiEndpoint('/client/hubs');
/// Update hub by ID.
static ApiEndpoint hubUpdate(String hubId) =>
ApiEndpoint('/client/hubs/$hubId');
/// Delete hub by ID.
static ApiEndpoint hubDelete(String hubId) =>
ApiEndpoint('/client/hubs/$hubId');
/// Assign NFC to hub.
static ApiEndpoint hubAssignNfc(String hubId) =>
ApiEndpoint('/client/hubs/$hubId/assign-nfc');
/// Assign managers to hub.
static ApiEndpoint hubAssignManagers(String hubId) =>
ApiEndpoint('/client/hubs/$hubId/managers');
/// Approve invoice.
static ApiEndpoint invoiceApprove(String invoiceId) =>
ApiEndpoint('/client/billing/invoices/$invoiceId/approve');
/// Dispute invoice.
static ApiEndpoint invoiceDispute(String invoiceId) =>
ApiEndpoint('/client/billing/invoices/$invoiceId/dispute');
/// Submit coverage review.
static const ApiEndpoint coverageReviews =
ApiEndpoint('/client/coverage/reviews');
/// Cancel late worker assignment.
static ApiEndpoint coverageCancelLateWorker(String assignmentId) =>
ApiEndpoint('/client/coverage/late-workers/$assignmentId/cancel');
}

View File

@@ -0,0 +1,40 @@
import 'package:krow_domain/krow_domain.dart' show ApiEndpoint;
/// Core infrastructure endpoints (upload, signed URLs, LLM, verifications,
/// rapid orders).
abstract final class CoreEndpoints {
/// Upload a file.
static const ApiEndpoint uploadFile =
ApiEndpoint('/core/upload-file');
/// Create a signed URL for a file.
static const ApiEndpoint createSignedUrl =
ApiEndpoint('/core/create-signed-url');
/// Invoke a Large Language Model.
static const ApiEndpoint invokeLlm = ApiEndpoint('/core/invoke-llm');
/// Root for verification operations.
static const ApiEndpoint verifications =
ApiEndpoint('/core/verifications');
/// Get status of a verification job.
static ApiEndpoint verificationStatus(String id) =>
ApiEndpoint('/core/verifications/$id');
/// Review a verification decision.
static ApiEndpoint verificationReview(String id) =>
ApiEndpoint('/core/verifications/$id/review');
/// Retry a verification job.
static ApiEndpoint verificationRetry(String id) =>
ApiEndpoint('/core/verifications/$id/retry');
/// Transcribe audio to text for rapid orders.
static const ApiEndpoint transcribeRapidOrder =
ApiEndpoint('/core/rapid-orders/transcribe');
/// Parse text to structured rapid order.
static const ApiEndpoint parseRapidOrder =
ApiEndpoint('/core/rapid-orders/parse');
}

View File

@@ -0,0 +1,180 @@
import 'package:krow_domain/krow_domain.dart' show ApiEndpoint;
/// Staff-specific API endpoints (read and write).
abstract final class StaffEndpoints {
// ── Read ──────────────────────────────────────────────────────────────
/// Staff session data.
static const ApiEndpoint session = ApiEndpoint('/staff/session');
/// Staff dashboard overview.
static const ApiEndpoint dashboard = ApiEndpoint('/staff/dashboard');
/// Staff profile completion status.
static const ApiEndpoint profileCompletion =
ApiEndpoint('/staff/profile-completion');
/// Staff availability schedule.
static const ApiEndpoint availability = ApiEndpoint('/staff/availability');
/// Today's shifts for clock-in.
static const ApiEndpoint clockInShiftsToday =
ApiEndpoint('/staff/clock-in/shifts/today');
/// Current clock-in status.
static const ApiEndpoint clockInStatus =
ApiEndpoint('/staff/clock-in/status');
/// Payments summary.
static const ApiEndpoint paymentsSummary =
ApiEndpoint('/staff/payments/summary');
/// Payments history.
static const ApiEndpoint paymentsHistory =
ApiEndpoint('/staff/payments/history');
/// Payments chart data.
static const ApiEndpoint paymentsChart =
ApiEndpoint('/staff/payments/chart');
/// Assigned shifts.
static const ApiEndpoint shiftsAssigned =
ApiEndpoint('/staff/shifts/assigned');
/// Open shifts available to apply.
static const ApiEndpoint shiftsOpen = ApiEndpoint('/staff/shifts/open');
/// Pending shift assignments.
static const ApiEndpoint shiftsPending =
ApiEndpoint('/staff/shifts/pending');
/// Cancelled shifts.
static const ApiEndpoint shiftsCancelled =
ApiEndpoint('/staff/shifts/cancelled');
/// Completed shifts.
static const ApiEndpoint shiftsCompleted =
ApiEndpoint('/staff/shifts/completed');
/// Shift details by ID.
static ApiEndpoint shiftDetails(String shiftId) =>
ApiEndpoint('/staff/shifts/$shiftId');
/// Staff profile sections overview.
static const ApiEndpoint profileSections =
ApiEndpoint('/staff/profile/sections');
/// Personal info.
static const ApiEndpoint personalInfo =
ApiEndpoint('/staff/profile/personal-info');
/// Industries/experience.
static const ApiEndpoint industries =
ApiEndpoint('/staff/profile/industries');
/// Skills.
static const ApiEndpoint skills = ApiEndpoint('/staff/profile/skills');
/// Save/update experience (industries + skills).
static const ApiEndpoint experience =
ApiEndpoint('/staff/profile/experience');
/// Documents.
static const ApiEndpoint documents =
ApiEndpoint('/staff/profile/documents');
/// Attire items.
static const ApiEndpoint attire = ApiEndpoint('/staff/profile/attire');
/// Tax forms.
static const ApiEndpoint taxForms =
ApiEndpoint('/staff/profile/tax-forms');
/// Emergency contacts.
static const ApiEndpoint emergencyContacts =
ApiEndpoint('/staff/profile/emergency-contacts');
/// Certificates.
static const ApiEndpoint certificates =
ApiEndpoint('/staff/profile/certificates');
/// Bank accounts.
static const ApiEndpoint bankAccounts =
ApiEndpoint('/staff/profile/bank-accounts');
/// Benefits.
static const ApiEndpoint benefits = ApiEndpoint('/staff/profile/benefits');
/// Time card.
static const ApiEndpoint timeCard =
ApiEndpoint('/staff/profile/time-card');
/// Privacy settings.
static const ApiEndpoint privacy = ApiEndpoint('/staff/profile/privacy');
/// FAQs.
static const ApiEndpoint faqs = ApiEndpoint('/staff/faqs');
/// FAQs search.
static const ApiEndpoint faqsSearch = ApiEndpoint('/staff/faqs/search');
// ── Write ─────────────────────────────────────────────────────────────
/// Staff profile setup.
static const ApiEndpoint profileSetup =
ApiEndpoint('/staff/profile/setup');
/// Clock in.
static const ApiEndpoint clockIn = ApiEndpoint('/staff/clock-in');
/// Clock out.
static const ApiEndpoint clockOut = ApiEndpoint('/staff/clock-out');
/// Quick-set availability.
static const ApiEndpoint availabilityQuickSet =
ApiEndpoint('/staff/availability/quick-set');
/// Apply for a shift.
static ApiEndpoint shiftApply(String shiftId) =>
ApiEndpoint('/staff/shifts/$shiftId/apply');
/// Accept a shift.
static ApiEndpoint shiftAccept(String shiftId) =>
ApiEndpoint('/staff/shifts/$shiftId/accept');
/// Decline a shift.
static ApiEndpoint shiftDecline(String shiftId) =>
ApiEndpoint('/staff/shifts/$shiftId/decline');
/// Request a shift swap.
static ApiEndpoint shiftRequestSwap(String shiftId) =>
ApiEndpoint('/staff/shifts/$shiftId/request-swap');
/// Update emergency contact by ID.
static ApiEndpoint emergencyContactUpdate(String contactId) =>
ApiEndpoint('/staff/profile/emergency-contacts/$contactId');
/// Update tax form by type.
static ApiEndpoint taxFormUpdate(String formType) =>
ApiEndpoint('/staff/profile/tax-forms/$formType');
/// Submit tax form by type.
static ApiEndpoint taxFormSubmit(String formType) =>
ApiEndpoint('/staff/profile/tax-forms/$formType/submit');
/// Upload staff profile photo.
static const ApiEndpoint profilePhoto =
ApiEndpoint('/staff/profile/photo');
/// Upload document by ID.
static ApiEndpoint documentUpload(String documentId) =>
ApiEndpoint('/staff/profile/documents/$documentId/upload');
/// Upload attire by ID.
static ApiEndpoint attireUpload(String documentId) =>
ApiEndpoint('/staff/profile/attire/$documentId/upload');
/// Delete certificate by ID.
static ApiEndpoint certificateDelete(String certificateId) =>
ApiEndpoint('/staff/profile/certificates/$certificateId');
}

View File

@@ -0,0 +1,69 @@
import 'package:flutter/foundation.dart';
import 'package:krow_domain/krow_domain.dart';
/// Client-side feature gate that checks user scopes against endpoint
/// requirements before allowing an API call.
///
/// Usage:
/// ```dart
/// FeatureGate.instance.validateAccess(StaffEndpoints.dashboard);
/// ```
///
/// When an endpoint's [ApiEndpoint.requiredScopes] is empty, access is always
/// granted. When scopes are defined, the gate verifies that the user has ALL
/// required scopes. Throws [InsufficientScopeException] if any are missing.
class FeatureGate {
FeatureGate._();
/// The global singleton instance.
static final FeatureGate instance = FeatureGate._();
/// The scopes the current user has.
List<String> _userScopes = const <String>[];
/// Updates the user's scopes (call after sign-in or session hydration).
void setUserScopes(List<String> scopes) {
_userScopes = List<String>.unmodifiable(scopes);
debugPrint('[FeatureGate] User scopes updated: $_userScopes');
}
/// Clears the user's scopes (call on sign-out).
void clearScopes() {
_userScopes = const <String>[];
debugPrint('[FeatureGate] User scopes cleared');
}
/// The current user's scopes (read-only).
List<String> get userScopes => _userScopes;
/// Returns `true` if the user has all scopes required by [endpoint].
bool hasAccess(ApiEndpoint endpoint) {
if (endpoint.requiredScopes.isEmpty) return true;
return endpoint.requiredScopes.every(
(String scope) => _userScopes.contains(scope),
);
}
/// Validates that the user can access [endpoint].
///
/// No-op when the endpoint has no required scopes (ungated).
/// Throws [InsufficientScopeException] when scopes are missing.
void validateAccess(ApiEndpoint endpoint) {
if (endpoint.requiredScopes.isEmpty) return;
final List<String> missingScopes = endpoint.requiredScopes
.where((String scope) => !_userScopes.contains(scope))
.toList();
if (missingScopes.isNotEmpty) {
throw InsufficientScopeException(
requiredScopes: endpoint.requiredScopes,
userScopes: _userScopes,
technicalMessage:
'Endpoint "${endpoint.path}" requires scopes '
'${endpoint.requiredScopes} but user has $_userScopes. '
'Missing: $missingScopes',
);
}
}
}

View File

@@ -1,24 +1,79 @@
import 'package:dio/dio.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:krow_core/src/services/api_service/endpoints/auth_endpoints.dart';
/// An interceptor that adds the Firebase Auth ID token to the Authorization header.
/// An interceptor that adds the Firebase Auth ID token to the Authorization
/// header and retries once on 401 with a force-refreshed token.
///
/// Skips unauthenticated auth endpoints (sign-in, sign-up, phone/start) since
/// the user has no Firebase session yet. Sign-out, session, and phone/verify
/// endpoints DO require the token.
class AuthInterceptor extends Interceptor {
/// Auth paths that must NOT receive a Bearer token (no session exists yet).
static final List<String> _unauthenticatedPaths = <String>[
AuthEndpoints.clientSignIn.path,
AuthEndpoints.clientSignUp.path,
AuthEndpoints.staffPhoneStart.path,
];
/// Tracks whether a 401 retry is in progress to prevent infinite loops.
bool _isRetrying = false;
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
// Skip token injection for endpoints that don't require authentication.
final bool skipAuth = _unauthenticatedPaths.any(
(String path) => options.path.contains(path),
);
if (!skipAuth) {
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);
}
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
// Retry once with a force-refreshed token on 401 Unauthorized.
if (err.response?.statusCode == 401 && !_isRetrying) {
final bool skipAuth = _unauthenticatedPaths.any(
(String path) => err.requestOptions.path.contains(path),
);
if (!skipAuth) {
final User? user = FirebaseAuth.instance.currentUser;
if (user != null) {
_isRetrying = true;
try {
final String? freshToken = await user.getIdToken(true);
if (freshToken != null) {
// Retry the original request with the refreshed token.
err.requestOptions.headers['Authorization'] =
'Bearer $freshToken';
final Response<dynamic> response =
await Dio().fetch<dynamic>(err.requestOptions);
return handler.resolve(response);
}
} catch (_) {
// Force-refresh or retry failed — fall through to original error.
} finally {
_isRetrying = false;
}
}
}
}
return handler.next(err);
}
}

View File

@@ -0,0 +1,24 @@
import 'package:dio/dio.dart';
import 'package:uuid/uuid.dart';
/// A Dio interceptor that adds an `Idempotency-Key` header to write requests.
///
/// The V2 API requires an idempotency key for all POST, PUT, and DELETE
/// requests to prevent duplicate operations. A unique UUID v4 is generated
/// per request automatically.
class IdempotencyInterceptor extends Interceptor {
/// The UUID generator instance.
static const Uuid _uuid = Uuid();
@override
void onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) {
final String method = options.method.toUpperCase();
if (method == 'POST' || method == 'PUT' || method == 'DELETE') {
options.headers['Idempotency-Key'] = _uuid.v4();
}
handler.next(options);
}
}

View File

@@ -0,0 +1,135 @@
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
/// Mixin to handle API layer errors and map them to domain exceptions.
///
/// Use this in repository implementations to wrap [ApiService] calls.
/// It catches [DioException], [SocketException], etc., and throws
/// the appropriate [AppException] subclass.
mixin ApiErrorHandler {
/// Executes a Future and maps low-level exceptions to [AppException].
///
/// [timeout] defaults to 30 seconds.
Future<T> executeProtected<T>(
Future<T> Function() action, {
Duration timeout = const Duration(seconds: 30),
}) async {
try {
return await action().timeout(timeout);
} on TimeoutException {
debugPrint(
'ApiErrorHandler: Request timed out after ${timeout.inSeconds}s',
);
throw ServiceUnavailableException(
technicalMessage: 'Request timed out after ${timeout.inSeconds}s',
);
} on DioException catch (e) {
throw _mapDioException(e);
} on SocketException catch (e) {
throw NetworkException(
technicalMessage: 'SocketException: ${e.message}',
);
} catch (e) {
// If it's already an AppException, rethrow it.
if (e is AppException) rethrow;
final String errorStr = e.toString().toLowerCase();
if (_isNetworkRelated(errorStr)) {
debugPrint('ApiErrorHandler: Network-related error: $e');
throw NetworkException(technicalMessage: e.toString());
}
debugPrint('ApiErrorHandler: Unhandled exception caught: $e');
throw UnknownException(technicalMessage: e.toString());
}
}
/// Maps a [DioException] to the appropriate [AppException].
AppException _mapDioException(DioException e) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
debugPrint('ApiErrorHandler: Dio timeout: ${e.type}');
return ServiceUnavailableException(
technicalMessage: 'Dio ${e.type}: ${e.message}',
);
case DioExceptionType.connectionError:
debugPrint('ApiErrorHandler: Connection error: ${e.message}');
return NetworkException(
technicalMessage: 'Connection error: ${e.message}',
);
case DioExceptionType.badResponse:
final int? statusCode = e.response?.statusCode;
final String body = e.response?.data?.toString() ?? '';
debugPrint(
'ApiErrorHandler: Bad response $statusCode: $body',
);
if (statusCode == 401 || statusCode == 403) {
return NotAuthenticatedException(
technicalMessage: 'HTTP $statusCode: $body',
);
}
if (statusCode == 404) {
return ServerException(
technicalMessage: 'HTTP 404: Not found — $body',
);
}
if (statusCode == 429) {
return ServiceUnavailableException(
technicalMessage: 'Rate limited (429): $body',
);
}
if (statusCode != null && statusCode >= 500) {
return ServiceUnavailableException(
technicalMessage: 'HTTP $statusCode: $body',
);
}
return ServerException(
technicalMessage: 'HTTP $statusCode: $body',
);
case DioExceptionType.cancel:
return UnknownException(
technicalMessage: 'Request cancelled',
);
case DioExceptionType.badCertificate:
return NetworkException(
technicalMessage: 'Bad certificate: ${e.message}',
);
case DioExceptionType.unknown:
if (e.error is SocketException) {
return NetworkException(
technicalMessage: 'Socket error: ${e.error}',
);
}
return UnknownException(
technicalMessage: 'Unknown Dio error: ${e.message}',
);
}
}
/// Checks if an error string is network-related.
bool _isNetworkRelated(String errorStr) {
return errorStr.contains('socketexception') ||
errorStr.contains('network') ||
errorStr.contains('offline') ||
errorStr.contains('connection failed') ||
errorStr.contains('unavailable') ||
errorStr.contains('handshake') ||
errorStr.contains('clientexception') ||
errorStr.contains('failed host lookup') ||
errorStr.contains('connection error') ||
errorStr.contains('terminated') ||
errorStr.contains('connectexception');
}
}

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
import 'package:flutter/cupertino.dart';
import 'package:krow_domain/krow_domain.dart' show UserRole;
/// Enum representing the current session state.
enum SessionStateType { loading, authenticated, unauthenticated, error }
@@ -41,7 +42,12 @@ class SessionState {
'SessionState(type: $type, userId: $userId, error: $errorMessage)';
}
/// Mixin for handling Firebase Auth session management, token refresh, and state emissions.
/// Mixin for handling Firebase Auth session management, token refresh,
/// and state emissions.
///
/// Implementors must provide [auth] and [fetchUserRole]. The role fetch
/// should call `GET /auth/session` via [ApiService] instead of querying
/// Data Connect directly.
mixin SessionHandlerMixin {
/// Stream controller for session state changes.
final StreamController<SessionState> _sessionStateController =
@@ -53,17 +59,14 @@ mixin SessionHandlerMixin {
/// Public stream for listening to session state changes.
/// Late subscribers will immediately receive the last emitted state.
Stream<SessionState> get onSessionStateChanged {
// Create a custom stream that emits the last state before forwarding new events
return _createStreamWithLastState();
}
/// Creates a stream that emits the last state before subscribing to new events.
Stream<SessionState> _createStreamWithLastState() async* {
// If we have a last state, emit it immediately to late subscribers
if (_lastSessionState != null) {
yield _lastSessionState!;
}
// Then forward all subsequent events
yield* _sessionStateController.stream;
}
@@ -82,17 +85,17 @@ mixin SessionHandlerMixin {
/// Firebase Auth instance (to be provided by implementing class).
firebase_auth.FirebaseAuth get auth;
/// List of allowed roles for this app (to be set during initialization).
List<String> _allowedRoles = <String>[];
/// List of allowed roles for this app (set during initialization).
List<UserRole> _allowedRoles = <UserRole>[];
/// Initialize the auth state listener (call once on app startup).
void initializeAuthListener({List<String> allowedRoles = const <String>[]}) {
void initializeAuthListener({
List<UserRole> allowedRoles = const <UserRole>[],
}) {
_allowedRoles = allowedRoles;
// Cancel any existing subscription first
_authStateSubscription?.cancel();
// Listen to Firebase auth state changes
_authStateSubscription = auth.authStateChanges().listen(
(firebase_auth.User? user) async {
if (user == null) {
@@ -108,13 +111,12 @@ mixin SessionHandlerMixin {
}
/// Validates if user has one of the allowed roles.
/// Returns true if user role is in allowed roles, false otherwise.
Future<bool> validateUserRole(
String userId,
List<String> allowedRoles,
List<UserRole> allowedRoles,
) async {
try {
final String? userRole = await fetchUserRole(userId);
final UserRole? userRole = await fetchUserRole(userId);
return userRole != null && allowedRoles.contains(userRole);
} catch (e) {
debugPrint('Failed to validate user role: $e');
@@ -122,26 +124,50 @@ mixin SessionHandlerMixin {
}
}
/// Fetches user role from Data Connect.
/// To be implemented by concrete class.
Future<String?> fetchUserRole(String userId);
/// Fetches the user role from the backend by calling `GET /auth/session`
/// and deriving the [UserRole] from the response context.
Future<UserRole?> fetchUserRole(String userId);
/// Ensures the Firebase auth token is valid and refreshes if needed.
/// Retries up to 3 times with exponential backoff before emitting error.
Future<void> ensureSessionValid() async {
final firebase_auth.User? user = auth.currentUser;
/// Handle user sign-in event.
Future<void> _handleSignIn(firebase_auth.User user) async {
try {
_emitSessionState(SessionState.loading());
// No user = not authenticated, skip check
if (user == null) return;
if (_allowedRoles.isNotEmpty) {
final UserRole? userRole = await fetchUserRole(user.uid);
// Optimization: Skip if we just checked within the last 2 seconds
if (userRole == null) {
_emitSessionState(SessionState.unauthenticated());
return;
}
if (!_allowedRoles.contains(userRole)) {
await auth.signOut();
_emitSessionState(SessionState.unauthenticated());
return;
}
}
// Proactively refresh the token if it expires soon.
await _ensureSessionValid(user);
_emitSessionState(SessionState.authenticated(userId: user.uid));
} catch (e) {
_emitSessionState(SessionState.error(e.toString()));
}
}
/// Ensures the Firebase auth token is valid and refreshes if it expires
/// within [_refreshThreshold]. Retries up to 3 times with exponential
/// backoff before emitting an error state.
Future<void> _ensureSessionValid(firebase_auth.User user) async {
final DateTime now = DateTime.now();
if (_lastTokenRefreshTime != null) {
final Duration timeSinceLastCheck = now.difference(
_lastTokenRefreshTime!,
);
if (timeSinceLastCheck < _minRefreshCheckInterval) {
return; // Skip redundant check
return;
}
}
@@ -150,35 +176,25 @@ mixin SessionHandlerMixin {
while (retryCount < maxRetries) {
try {
// Get token result (doesn't fetch from network unless needed)
final firebase_auth.IdTokenResult idToken = await user
.getIdTokenResult();
// Extract expiration time
final firebase_auth.IdTokenResult idToken =
await user.getIdTokenResult();
final DateTime? expiryTime = idToken.expirationTime;
if (expiryTime == null) {
return; // Token info unavailable, proceed anyway
}
if (expiryTime == null) return;
// Calculate time until expiry
final Duration timeUntilExpiry = expiryTime.difference(now);
// If token expires within 5 minutes, refresh it
if (timeUntilExpiry <= _refreshThreshold) {
await user.getIdTokenResult();
await user.getIdTokenResult(true);
}
// Update last refresh check timestamp
_lastTokenRefreshTime = now;
return; // Success, exit retry loop
return;
} catch (e) {
retryCount++;
debugPrint(
'Token validation error (attempt $retryCount/$maxRetries): $e',
);
// If we've exhausted retries, emit error
if (retryCount >= maxRetries) {
_emitSessionState(
SessionState.error(
@@ -188,9 +204,8 @@ mixin SessionHandlerMixin {
return;
}
// Exponential backoff: 1s, 2s, 4s
final Duration backoffDuration = Duration(
seconds: 1 << (retryCount - 1), // 2^(retryCount-1)
seconds: 1 << (retryCount - 1),
);
debugPrint(
'Retrying token validation in ${backoffDuration.inSeconds}s',
@@ -200,50 +215,6 @@ mixin SessionHandlerMixin {
}
}
/// Handle user sign-in event.
Future<void> _handleSignIn(firebase_auth.User user) async {
try {
_emitSessionState(SessionState.loading());
// Validate role only when allowed roles are specified.
if (_allowedRoles.isNotEmpty) {
final String? userRole = await fetchUserRole(user.uid);
if (userRole == null) {
// User has no record in the database yet. This is expected during
// the sign-up flow: Firebase Auth fires authStateChanges before the
// repository has created the PostgreSQL user record. Do NOT sign out
// just emit unauthenticated and let the registration flow complete.
_emitSessionState(SessionState.unauthenticated());
return;
}
if (!_allowedRoles.contains(userRole)) {
// User IS in the database but has a role that is not permitted in
// this app (e.g., a STAFF-only user trying to use the Client app).
// Sign them out to force them to use the correct app.
await auth.signOut();
_emitSessionState(SessionState.unauthenticated());
return;
}
}
// Get fresh token to validate session
final firebase_auth.IdTokenResult idToken = await user.getIdTokenResult();
if (idToken.expirationTime != null &&
DateTime.now().difference(idToken.expirationTime!) <
const Duration(minutes: 5)) {
// Token is expiring soon, refresh it
await user.getIdTokenResult();
}
// Emit authenticated state
_emitSessionState(SessionState.authenticated(userId: user.uid));
} catch (e) {
_emitSessionState(SessionState.error(e.toString()));
}
}
/// Handle user sign-out event.
void handleSignOut() {
_emitSessionState(SessionState.unauthenticated());

View File

@@ -0,0 +1,28 @@
import 'package:krow_domain/krow_domain.dart' show ClientSession;
/// Singleton store for the authenticated client's session context.
///
/// Holds a [ClientSession] (V2 domain entity) populated after sign-in via the
/// V2 session API. Features read from this store to access business context
/// without re-fetching from the backend.
class ClientSessionStore {
ClientSessionStore._();
/// The global singleton instance.
static final ClientSessionStore instance = ClientSessionStore._();
ClientSession? _session;
/// The current client session, or `null` if not authenticated.
ClientSession? get session => _session;
/// Replaces the current session with [session].
void setSession(ClientSession session) {
_session = session;
}
/// Clears the stored session (e.g. on sign-out).
void clear() {
_session = null;
}
}

View File

@@ -0,0 +1,28 @@
import 'package:krow_domain/krow_domain.dart' show StaffSession;
/// Singleton store for the authenticated staff member's session context.
///
/// Holds a [StaffSession] (V2 domain entity) populated after sign-in via the
/// V2 session API. Features read from this store to access staff/tenant context
/// without re-fetching from the backend.
class StaffSessionStore {
StaffSessionStore._();
/// The global singleton instance.
static final StaffSessionStore instance = StaffSessionStore._();
StaffSession? _session;
/// The current staff session, or `null` if not authenticated.
StaffSession? get session => _session;
/// Replaces the current session with [session].
void setSession(StaffSession session) {
_session = session;
}
/// Clears the stored session (e.g. on sign-out).
void clear() {
_session = null;
}
}

View File

@@ -0,0 +1,128 @@
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
import 'package:flutter/foundation.dart';
import 'package:krow_domain/krow_domain.dart';
import '../api_service/api_service.dart';
import '../api_service/endpoints/auth_endpoints.dart';
import '../api_service/feature_gate.dart';
import '../api_service/mixins/session_handler_mixin.dart';
import '../device/background_task/background_task_service.dart';
import 'client_session_store.dart';
import 'staff_session_store.dart';
/// A singleton service that manages user session state via the V2 REST API.
///
/// Replaces `DataConnectService` for auth-state listening, role validation,
/// and session-state broadcasting. Uses [SessionHandlerMixin] for token
/// refresh and retry logic.
class V2SessionService with SessionHandlerMixin {
V2SessionService._();
/// The global singleton instance.
static final V2SessionService instance = V2SessionService._();
/// Optional [BaseApiService] reference set during DI initialisation.
///
/// When `null` the service falls back to a raw Dio call so that
/// `initializeAuthListener` can work before the Modular injector is ready.
BaseApiService? _apiService;
/// Injects the [BaseApiService] dependency.
///
/// Call once from `CoreModule.exportedBinds` after registering [ApiService].
void setApiService(BaseApiService apiService) {
_apiService = apiService;
}
@override
firebase_auth.FirebaseAuth get auth => firebase_auth.FirebaseAuth.instance;
/// Fetches the user role by calling `GET /auth/session`.
///
/// Returns the [UserRole] derived from the session context, or `null` if
/// the call fails or the user has no role.
@override
Future<UserRole?> fetchUserRole(String userId) async {
try {
final BaseApiService? api = _apiService;
if (api == null) {
debugPrint(
'[V2SessionService] ApiService not injected; '
'cannot fetch user role.',
);
return null;
}
final ApiResponse response = await api.get(AuthEndpoints.session);
if (response.data is Map<String, dynamic>) {
final Map<String, dynamic> data =
response.data as Map<String, dynamic>;
// Hydrate session stores from the session endpoint response.
// Per V2 auth doc, GET /auth/session is used for app startup hydration.
_hydrateSessionStores(data);
return UserRole.fromSessionData(data);
}
return null;
} catch (e) {
debugPrint('[V2SessionService] Error fetching user role: $e');
return null;
}
}
/// Hydrates session stores from a `GET /auth/session` response.
///
/// The session endpoint returns `{ user, tenant, business, vendor, staff }`
/// which maps to both [ClientSession] and [StaffSession] entities.
void _hydrateSessionStores(Map<String, dynamic> data) {
try {
// Hydrate staff session if staff context is present.
if (data['staff'] is Map<String, dynamic>) {
final StaffSession staffSession = StaffSession.fromJson(data);
StaffSessionStore.instance.setSession(staffSession);
}
// Hydrate client session if business context is present.
if (data['business'] is Map<String, dynamic>) {
final ClientSession clientSession = ClientSession.fromJson(data);
ClientSessionStore.instance.setSession(clientSession);
}
} catch (e) {
debugPrint('[V2SessionService] Error hydrating session stores: $e');
}
}
/// Signs out the current user from Firebase Auth and clears local state.
Future<void> signOut() async {
try {
// Revoke server-side session token.
final BaseApiService? api = _apiService;
if (api != null) {
try {
await api.post(AuthEndpoints.signOut);
} catch (e) {
debugPrint('[V2SessionService] Server sign-out failed: $e');
}
}
await auth.signOut();
} catch (e) {
debugPrint('[V2SessionService] Error signing out: $e');
rethrow;
} finally {
// Cancel all background tasks (geofence tracking, etc.).
try {
await const BackgroundTaskService().cancelAll();
} catch (e) {
debugPrint('[V2SessionService] Failed to cancel background tasks: $e');
}
StaffSessionStore.instance.clear();
ClientSessionStore.instance.clear();
FeatureGate.instance.clearScopes();
handleSignOut();
}
}
}

View File

@@ -32,3 +32,4 @@ dependencies:
flutter_local_notifications: ^21.0.0
shared_preferences: ^2.5.4
workmanager: ^0.9.0+3
uuid: ^4.5.1

View File

@@ -785,6 +785,9 @@
"personal_info": {
"title": "Personal Info",
"change_photo_hint": "Tap to change photo",
"choose_photo_source": "Choose Photo Source",
"photo_upload_success": "Profile photo updated",
"photo_upload_failed": "Failed to upload photo. Please try again.",
"full_name_label": "Full Name",
"email_label": "Email",
"phone_label": "Phone Number",
@@ -820,6 +823,8 @@
"custom_skills_title": "Custom Skills:",
"custom_skill_hint": "Add custom skill...",
"save_button": "Save & Continue",
"save_success": "Experience saved successfully",
"save_error": "An error occurred",
"industries": {
"hospitality": "Hospitality",
"food_service": "Food Service",
@@ -827,6 +832,8 @@
"events": "Events",
"retail": "Retail",
"healthcare": "Healthcare",
"catering": "Catering",
"cafe": "Cafe",
"other": "Other"
},
"skills": {

View File

@@ -780,6 +780,9 @@
"personal_info": {
"title": "Informaci\u00f3n Personal",
"change_photo_hint": "Toca para cambiar foto",
"choose_photo_source": "Elegir fuente de foto",
"photo_upload_success": "Foto de perfil actualizada",
"photo_upload_failed": "Error al subir la foto. Por favor, int\u00e9ntalo de nuevo.",
"full_name_label": "Nombre Completo",
"email_label": "Correo Electr\u00f3nico",
"phone_label": "N\u00famero de Tel\u00e9fono",
@@ -815,6 +818,8 @@
"custom_skills_title": "Habilidades personalizadas:",
"custom_skill_hint": "A\u00f1adir habilidad...",
"save_button": "Guardar y continuar",
"save_success": "Experiencia guardada exitosamente",
"save_error": "Ocurrió un error",
"industries": {
"hospitality": "Hoteler\u00eda",
"food_service": "Servicio de alimentos",
@@ -822,6 +827,8 @@
"events": "Eventos",
"retail": "Venta al por menor",
"healthcare": "Cuidado de la salud",
"catering": "Catering",
"cafe": "Cafetería",
"other": "Otro"
},
"skills": {

View File

@@ -1,52 +0,0 @@
/// The Data Connect layer.
///
/// This package provides mock implementations of domain repository interfaces
/// for development and testing purposes.
///
/// They will implement interfaces defined in feature packages once those are created.
library;
export 'src/data_connect_module.dart';
export 'src/session/client_session_store.dart';
// Export the generated Data Connect SDK
export 'src/dataconnect_generated/generated.dart';
export 'src/services/data_connect_service.dart';
export 'src/services/mixins/session_handler_mixin.dart';
export 'src/session/staff_session_store.dart';
export 'src/services/mixins/data_error_handler.dart';
// Export Staff Connector repositories and use cases
export 'src/connectors/staff/domain/repositories/staff_connector_repository.dart';
export 'src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart';
export 'src/connectors/staff/domain/usecases/get_personal_info_completion_usecase.dart';
export 'src/connectors/staff/domain/usecases/get_emergency_contacts_completion_usecase.dart';
export 'src/connectors/staff/domain/usecases/get_experience_completion_usecase.dart';
export 'src/connectors/staff/domain/usecases/get_tax_forms_completion_usecase.dart';
export 'src/connectors/staff/domain/usecases/get_attire_options_completion_usecase.dart';
export 'src/connectors/staff/domain/usecases/get_staff_documents_completion_usecase.dart';
export 'src/connectors/staff/domain/usecases/get_staff_certificates_completion_usecase.dart';
export 'src/connectors/staff/domain/usecases/get_staff_profile_usecase.dart';
export 'src/connectors/staff/domain/usecases/sign_out_staff_usecase.dart';
export 'src/connectors/staff/data/repositories/staff_connector_repository_impl.dart';
// Export Reports Connector
export 'src/connectors/reports/domain/repositories/reports_connector_repository.dart';
export 'src/connectors/reports/data/repositories/reports_connector_repository_impl.dart';
// Export Shifts Connector
export 'src/connectors/shifts/domain/repositories/shifts_connector_repository.dart';
export 'src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart';
// Export Hubs Connector
export 'src/connectors/hubs/domain/repositories/hubs_connector_repository.dart';
export 'src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart';
// Export Billing Connector
export 'src/connectors/billing/domain/repositories/billing_connector_repository.dart';
export 'src/connectors/billing/data/repositories/billing_connector_repository_impl.dart';
// Export Coverage Connector
export 'src/connectors/coverage/domain/repositories/coverage_connector_repository.dart';
export 'src/connectors/coverage/data/repositories/coverage_connector_repository_impl.dart';

View File

@@ -1,30 +0,0 @@
import 'package:krow_domain/krow_domain.dart';
/// Repository interface for billing connector operations.
///
/// This acts as a buffer layer between the domain repository and the Data Connect SDK.
abstract interface class BillingConnectorRepository {
/// Fetches bank accounts associated with the business.
Future<List<BusinessBankAccount>> getBankAccounts({required String businessId});
/// Fetches the current bill amount for the period.
Future<double> getCurrentBillAmount({required String businessId});
/// Fetches historically paid invoices.
Future<List<Invoice>> getInvoiceHistory({required String businessId});
/// Fetches pending invoices (Open or Disputed).
Future<List<Invoice>> getPendingInvoices({required String businessId});
/// Fetches the breakdown of spending.
Future<List<InvoiceItem>> getSpendingBreakdown({
required String businessId,
required BillingPeriod period,
});
/// Approves an invoice.
Future<void> approveInvoice({required String id});
/// Disputes an invoice.
Future<void> disputeInvoice({required String id, required String reason});
}

View File

@@ -1,158 +0,0 @@
// 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:firebase_data_connect/src/core/ref.dart';
import 'package:intl/intl.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/coverage_connector_repository.dart';
/// Implementation of [CoverageConnectorRepository].
class CoverageConnectorRepositoryImpl implements CoverageConnectorRepository {
CoverageConnectorRepositoryImpl({
dc.DataConnectService? service,
}) : _service = service ?? dc.DataConnectService.instance;
final dc.DataConnectService _service;
@override
Future<List<CoverageShift>> getShiftsForDate({
required String businessId,
required DateTime date,
}) async {
return _service.run(() async {
final DateTime start = DateTime(date.year, date.month, date.day);
final DateTime end = DateTime(date.year, date.month, date.day, 23, 59, 59, 999);
final QueryResult<dc.ListShiftRolesByBusinessAndDateRangeData, dc.ListShiftRolesByBusinessAndDateRangeVariables> shiftRolesResult = await _service.connector
.listShiftRolesByBusinessAndDateRange(
businessId: businessId,
start: _service.toTimestamp(start),
end: _service.toTimestamp(end),
)
.execute();
final QueryResult<dc.ListStaffsApplicationsByBusinessForDayData, dc.ListStaffsApplicationsByBusinessForDayVariables> applicationsResult = await _service.connector
.listStaffsApplicationsByBusinessForDay(
businessId: businessId,
dayStart: _service.toTimestamp(start),
dayEnd: _service.toTimestamp(end),
)
.execute();
return _mapCoverageShifts(
shiftRolesResult.data.shiftRoles,
applicationsResult.data.applications,
date,
);
});
}
List<CoverageShift> _mapCoverageShifts(
List<dynamic> shiftRoles,
List<dynamic> applications,
DateTime date,
) {
if (shiftRoles.isEmpty && applications.isEmpty) return <CoverageShift>[];
final Map<String, _CoverageGroup> groups = <String, _CoverageGroup>{};
for (final sr in shiftRoles) {
final String key = '${sr.shiftId}:${sr.roleId}';
final DateTime? startTime = _service.toDateTime(sr.startTime);
groups[key] = _CoverageGroup(
shiftId: sr.shiftId,
roleId: sr.roleId,
title: sr.role.name,
location: sr.shift.location ?? sr.shift.locationAddress ?? '',
startTime: startTime != null ? DateFormat('HH:mm').format(startTime) : '00:00',
workersNeeded: sr.count,
date: _service.toDateTime(sr.shift.date) ?? date,
workers: <CoverageWorker>[],
);
}
for (final app in applications) {
final String key = '${app.shiftId}:${app.roleId}';
if (!groups.containsKey(key)) {
final DateTime? startTime = _service.toDateTime(app.shiftRole.startTime);
groups[key] = _CoverageGroup(
shiftId: app.shiftId,
roleId: app.roleId,
title: app.shiftRole.role.name,
location: app.shiftRole.shift.location ?? app.shiftRole.shift.locationAddress ?? '',
startTime: startTime != null ? DateFormat('HH:mm').format(startTime) : '00:00',
workersNeeded: app.shiftRole.count,
date: _service.toDateTime(app.shiftRole.shift.date) ?? date,
workers: <CoverageWorker>[],
);
}
final DateTime? checkIn = _service.toDateTime(app.checkInTime);
groups[key]!.workers.add(
CoverageWorker(
name: app.staff.fullName,
status: _mapWorkerStatus(app.status.stringValue),
checkInTime: checkIn != null ? DateFormat('HH:mm').format(checkIn) : null,
),
);
}
return groups.values
.map((_CoverageGroup g) => CoverageShift(
id: '${g.shiftId}:${g.roleId}',
title: g.title,
location: g.location,
startTime: g.startTime,
workersNeeded: g.workersNeeded,
date: g.date,
workers: g.workers,
))
.toList();
}
CoverageWorkerStatus _mapWorkerStatus(String status) {
switch (status) {
case 'PENDING':
return CoverageWorkerStatus.pending;
case 'REJECTED':
return CoverageWorkerStatus.rejected;
case 'CONFIRMED':
return CoverageWorkerStatus.confirmed;
case 'CHECKED_IN':
return CoverageWorkerStatus.checkedIn;
case 'CHECKED_OUT':
return CoverageWorkerStatus.checkedOut;
case 'LATE':
return CoverageWorkerStatus.late;
case 'NO_SHOW':
return CoverageWorkerStatus.noShow;
case 'COMPLETED':
return CoverageWorkerStatus.completed;
default:
return CoverageWorkerStatus.pending;
}
}
}
class _CoverageGroup {
_CoverageGroup({
required this.shiftId,
required this.roleId,
required this.title,
required this.location,
required this.startTime,
required this.workersNeeded,
required this.date,
required this.workers,
});
final String shiftId;
final String roleId;
final String title;
final String location;
final String startTime;
final int workersNeeded;
final DateTime date;
final List<CoverageWorker> workers;
}

View File

@@ -1,12 +0,0 @@
import 'package:krow_domain/krow_domain.dart';
/// Repository interface for coverage connector operations.
///
/// This acts as a buffer layer between the domain repository and the Data Connect SDK.
abstract interface class CoverageConnectorRepository {
/// Fetches coverage data for a specific date and business.
Future<List<CoverageShift>> getShiftsForDate({
required String businessId,
required DateTime date,
});
}

View File

@@ -1,342 +0,0 @@
// 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/firebase_data_connect.dart';
import 'package:http/http.dart' as http;
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/hubs_connector_repository.dart';
/// Implementation of [HubsConnectorRepository].
class HubsConnectorRepositoryImpl implements HubsConnectorRepository {
HubsConnectorRepositoryImpl({
dc.DataConnectService? service,
}) : _service = service ?? dc.DataConnectService.instance;
final dc.DataConnectService _service;
@override
Future<List<Hub>> getHubs({required String businessId}) async {
return _service.run(() async {
final String teamId = await _getOrCreateTeamId(businessId);
final QueryResult<dc.GetTeamHubsByTeamIdData, dc.GetTeamHubsByTeamIdVariables> response = await _service.connector
.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,
name: h.hubName,
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();
});
}
@override
Future<Hub> createHub({
required String businessId,
required String name,
required String address,
String? placeId,
double? latitude,
double? longitude,
String? city,
String? state,
String? street,
String? country,
String? zipCode,
String? costCenterId,
}) async {
return _service.run(() async {
final String teamId = await _getOrCreateTeamId(businessId);
final _PlaceAddress? placeAddress = (placeId != null && placeId.isNotEmpty)
? await _fetchPlaceAddress(placeId)
: null;
final OperationResult<dc.CreateTeamHubData, dc.CreateTeamHubVariables> result = await _service.connector
.createTeamHub(
teamId: teamId,
hubName: name,
address: address,
)
.placeId(placeId)
.latitude(latitude)
.longitude(longitude)
.city(city ?? placeAddress?.city ?? '')
.state(state ?? placeAddress?.state)
.street(street ?? placeAddress?.street)
.country(country ?? placeAddress?.country)
.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: hubId,
businessId: businessId,
name: name,
address: address,
nfcTagId: null,
status: HubStatus.active,
costCenter: costCenter,
);
});
}
@override
Future<Hub> updateHub({
required String businessId,
required String id,
String? name,
String? address,
String? placeId,
double? latitude,
double? longitude,
String? city,
String? state,
String? street,
String? country,
String? zipCode,
String? costCenterId,
}) async {
return _service.run(() async {
final _PlaceAddress? placeAddress = (placeId != null && placeId.isNotEmpty)
? await _fetchPlaceAddress(placeId)
: null;
final dc.UpdateTeamHubVariablesBuilder builder = _service.connector.updateTeamHub(id: id);
if (name != null) builder.hubName(name);
if (address != null) builder.address(address);
if (placeId != null) builder.placeId(placeId);
if (latitude != null) builder.latitude(latitude);
if (longitude != null) builder.longitude(longitude);
if (city != null || placeAddress?.city != null) {
builder.city(city ?? placeAddress?.city);
}
if (state != null || placeAddress?.state != null) {
builder.state(state ?? placeAddress?.state);
}
if (street != null || placeAddress?.street != null) {
builder.street(street ?? placeAddress?.street);
}
if (country != null || placeAddress?.country != null) {
builder.country(country ?? placeAddress?.country);
}
if (zipCode != null || placeAddress?.zipCode != null) {
builder.zipCode(zipCode ?? placeAddress?.zipCode);
}
await builder.execute();
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,
name: name ?? '',
address: address ?? '',
nfcTagId: null,
status: HubStatus.active,
costCenter: costCenter,
);
});
}
@override
Future<void> deleteHub({required String businessId, required String id}) async {
return _service.run(() async {
final QueryResult<dc.ListOrdersByBusinessAndTeamHubData, dc.ListOrdersByBusinessAndTeamHubVariables> ordersRes = await _service.connector
.listOrdersByBusinessAndTeamHub(businessId: businessId, teamHubId: id)
.execute();
if (ordersRes.data.orders.isNotEmpty) {
throw HubHasOrdersException(
technicalMessage: 'Hub $id has ${ordersRes.data.orders.length} orders',
);
}
await _service.connector.deleteTeamHub(id: id).execute();
});
}
// --- HELPERS ---
Future<String> _getOrCreateTeamId(String businessId) async {
final QueryResult<dc.GetTeamsByOwnerIdData, dc.GetTeamsByOwnerIdVariables> teamsRes = await _service.connector
.getTeamsByOwnerId(ownerId: businessId)
.execute();
if (teamsRes.data.teams.isNotEmpty) {
return teamsRes.data.teams.first.id;
}
// Logic to fetch business details to create a team name if missing
// For simplicity, we assume one exists or we create a generic one
final OperationResult<dc.CreateTeamData, dc.CreateTeamVariables> createRes = await _service.connector
.createTeam(
teamName: 'Business Team',
ownerId: businessId,
ownerName: '',
ownerRole: 'OWNER',
)
.execute();
return createRes.data.team_insert.id;
}
Future<_PlaceAddress?> _fetchPlaceAddress(String placeId) async {
final Uri uri = Uri.https(
'maps.googleapis.com',
'/maps/api/place/details/json',
<String, dynamic>{
'place_id': placeId,
'fields': 'address_component',
'key': AppConfig.googleMapsApiKey,
},
);
try {
final http.Response response = await http.get(uri);
if (response.statusCode != 200) return null;
final Map<String, dynamic> payload = json.decode(response.body) as Map<String, dynamic>;
if (payload['status'] != 'OK') return null;
final Map<String, dynamic>? result = payload['result'] as Map<String, dynamic>?;
final List<dynamic>? components = result?['address_components'] as List<dynamic>?;
if (components == null || components.isEmpty) return null;
String? streetNumber, route, city, state, country, zipCode;
for (var entry in components) {
final Map<String, dynamic> component = entry as Map<String, dynamic>;
final List<dynamic> types = component['types'] as List<dynamic>? ?? <dynamic>[];
final String? longName = component['long_name'] as String?;
final String? shortName = component['short_name'] as String?;
if (types.contains('street_number')) {
streetNumber = longName;
} else if (types.contains('route')) {
route = longName;
} else if (types.contains('locality')) {
city = longName;
} else if (types.contains('administrative_area_level_1')) {
state = shortName ?? longName;
} else if (types.contains('country')) {
country = shortName ?? longName;
} else if (types.contains('postal_code')) {
zipCode = longName;
}
}
final String street = <String?>[streetNumber, route]
.where((String? v) => v != null && v.isNotEmpty)
.join(' ')
.trim();
return _PlaceAddress(
street: street.isEmpty ? null : street,
city: city,
state: state,
country: country,
zipCode: zipCode,
);
} catch (_) {
return null;
}
}
}
class _PlaceAddress {
const _PlaceAddress({
this.street,
this.city,
this.state,
this.country,
this.zipCode,
});
final String? street;
final String? city;
final String? state;
final String? country;
final String? zipCode;
}

View File

@@ -1,45 +0,0 @@
import 'package:krow_domain/krow_domain.dart';
/// Repository interface for hubs connector operations.
///
/// This acts as a buffer layer between the domain repository and the Data Connect SDK.
abstract interface class HubsConnectorRepository {
/// Fetches the list of hubs for a business.
Future<List<Hub>> getHubs({required String businessId});
/// Creates a new hub.
Future<Hub> createHub({
required String businessId,
required String name,
required String address,
String? placeId,
double? latitude,
double? longitude,
String? city,
String? state,
String? street,
String? country,
String? zipCode,
String? costCenterId,
});
/// Updates an existing hub.
Future<Hub> updateHub({
required String businessId,
required String id,
String? name,
String? address,
String? placeId,
double? latitude,
double? longitude,
String? city,
String? state,
String? street,
String? country,
String? zipCode,
String? costCenterId,
});
/// Deletes a hub.
Future<void> deleteHub({required String businessId, required String id});
}

View File

@@ -1,537 +0,0 @@
// 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: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';
import '../../domain/repositories/reports_connector_repository.dart';
/// Implementation of [ReportsConnectorRepository].
///
/// Fetches report-related data from the Data Connect backend.
class ReportsConnectorRepositoryImpl implements ReportsConnectorRepository {
/// Creates a new [ReportsConnectorRepositoryImpl].
ReportsConnectorRepositoryImpl({
dc.DataConnectService? service,
}) : _service = service ?? dc.DataConnectService.instance;
final dc.DataConnectService _service;
@override
Future<DailyOpsReport> getDailyOpsReport({
String? businessId,
required DateTime date,
}) async {
return _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final QueryResult<dc.ListShiftsForDailyOpsByBusinessData, dc.ListShiftsForDailyOpsByBusinessVariables> response = await _service.connector
.listShiftsForDailyOpsByBusiness(
businessId: id,
date: _service.toTimestamp(date),
)
.execute();
final List<dc.ListShiftsForDailyOpsByBusinessShifts> shifts = response.data.shifts;
final int scheduledShifts = shifts.length;
int workersConfirmed = 0;
int inProgressShifts = 0;
int completedShifts = 0;
final List<DailyOpsShift> dailyOpsShifts = <DailyOpsShift>[];
for (final dc.ListShiftsForDailyOpsByBusinessShifts shift in shifts) {
workersConfirmed += shift.filled ?? 0;
final String statusStr = shift.status?.stringValue ?? '';
if (statusStr == 'IN_PROGRESS') inProgressShifts++;
if (statusStr == 'COMPLETED') completedShifts++;
dailyOpsShifts.add(DailyOpsShift(
id: shift.id,
title: shift.title ?? '',
location: shift.location ?? '',
startTime: shift.startTime?.toDateTime() ?? DateTime.now(),
endTime: shift.endTime?.toDateTime() ?? DateTime.now(),
workersNeeded: shift.workersNeeded ?? 0,
filled: shift.filled ?? 0,
status: statusStr,
));
}
return DailyOpsReport(
scheduledShifts: scheduledShifts,
workersConfirmed: workersConfirmed,
inProgressShifts: inProgressShifts,
completedShifts: completedShifts,
shifts: dailyOpsShifts,
);
});
}
@override
Future<SpendReport> getSpendReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) async {
return _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final QueryResult<dc.ListInvoicesForSpendByBusinessData, dc.ListInvoicesForSpendByBusinessVariables> response = await _service.connector
.listInvoicesForSpendByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final List<dc.ListInvoicesForSpendByBusinessInvoices> invoices = response.data.invoices;
double totalSpend = 0.0;
int paidInvoices = 0;
int pendingInvoices = 0;
int overdueInvoices = 0;
final List<SpendInvoice> spendInvoices = <SpendInvoice>[];
final Map<DateTime, double> dailyAggregates = <DateTime, double>{};
final Map<String, double> industryAggregates = <String, double>{};
for (final dc.ListInvoicesForSpendByBusinessInvoices inv in invoices) {
final double amount = (inv.amount ?? 0.0).toDouble();
totalSpend += amount;
final String statusStr = inv.status.stringValue;
if (statusStr == 'PAID') {
paidInvoices++;
} else if (statusStr == 'PENDING') {
pendingInvoices++;
} else if (statusStr == 'OVERDUE') {
overdueInvoices++;
}
final String industry = inv.vendor.serviceSpecialty ?? 'Other';
industryAggregates[industry] = (industryAggregates[industry] ?? 0.0) + amount;
final DateTime issueDateTime = inv.issueDate.toDateTime();
spendInvoices.add(SpendInvoice(
id: inv.id,
invoiceNumber: inv.invoiceNumber ?? '',
issueDate: issueDateTime,
amount: amount,
status: statusStr,
vendorName: inv.vendor.companyName ?? 'Unknown',
industry: industry,
));
// Chart data aggregation
final DateTime date = DateTime(issueDateTime.year, issueDateTime.month, issueDateTime.day);
dailyAggregates[date] = (dailyAggregates[date] ?? 0.0) + amount;
}
// Ensure chart data covers all days in range
final Map<DateTime, double> completeDailyAggregates = <DateTime, double>{};
for (int i = 0; i <= endDate.difference(startDate).inDays; i++) {
final DateTime date = startDate.add(Duration(days: i));
final DateTime normalizedDate = DateTime(date.year, date.month, date.day);
completeDailyAggregates[normalizedDate] =
dailyAggregates[normalizedDate] ?? 0.0;
}
final List<SpendChartPoint> chartData = completeDailyAggregates.entries
.map((MapEntry<DateTime, double> e) => SpendChartPoint(date: e.key, amount: e.value))
.toList()
..sort((SpendChartPoint a, SpendChartPoint b) => a.date.compareTo(b.date));
final List<SpendIndustryCategory> industryBreakdown = industryAggregates.entries
.map((MapEntry<String, double> e) => SpendIndustryCategory(
name: e.key,
amount: e.value,
percentage: totalSpend > 0 ? (e.value / totalSpend * 100) : 0,
))
.toList()
..sort((SpendIndustryCategory a, SpendIndustryCategory b) => b.amount.compareTo(a.amount));
final int daysCount = endDate.difference(startDate).inDays + 1;
return SpendReport(
totalSpend: totalSpend,
averageCost: daysCount > 0 ? totalSpend / daysCount : 0,
paidInvoices: paidInvoices,
pendingInvoices: pendingInvoices,
overdueInvoices: overdueInvoices,
invoices: spendInvoices,
chartData: chartData,
industryBreakdown: industryBreakdown,
);
});
}
@override
Future<CoverageReport> getCoverageReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) async {
return _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final QueryResult<dc.ListShiftsForCoverageData, dc.ListShiftsForCoverageVariables> response = await _service.connector
.listShiftsForCoverage(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final List<dc.ListShiftsForCoverageShifts> shifts = response.data.shifts;
int totalNeeded = 0;
int totalFilled = 0;
final Map<DateTime, (int, int)> dailyStats = <DateTime, (int, int)>{};
for (final dc.ListShiftsForCoverageShifts shift in shifts) {
final DateTime shiftDate = shift.date?.toDateTime() ?? DateTime.now();
final DateTime date = DateTime(shiftDate.year, shiftDate.month, shiftDate.day);
final int needed = shift.workersNeeded ?? 0;
final int filled = shift.filled ?? 0;
totalNeeded += needed;
totalFilled += filled;
final (int, int) current = dailyStats[date] ?? (0, 0);
dailyStats[date] = (current.$1 + needed, current.$2 + filled);
}
final List<CoverageDay> dailyCoverage = dailyStats.entries.map((MapEntry<DateTime, (int, int)> e) {
final int needed = e.value.$1;
final int filled = e.value.$2;
return CoverageDay(
date: e.key,
needed: needed,
filled: filled,
percentage: needed == 0 ? 100.0 : (filled / needed) * 100.0,
);
}).toList()..sort((CoverageDay a, CoverageDay b) => a.date.compareTo(b.date));
return CoverageReport(
overallCoverage: totalNeeded == 0 ? 100.0 : (totalFilled / totalNeeded) * 100.0,
totalNeeded: totalNeeded,
totalFilled: totalFilled,
dailyCoverage: dailyCoverage,
);
});
}
@override
Future<ForecastReport> getForecastReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) async {
return _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final QueryResult<dc.ListShiftsForForecastByBusinessData, dc.ListShiftsForForecastByBusinessVariables> response = await _service.connector
.listShiftsForForecastByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final List<dc.ListShiftsForForecastByBusinessShifts> shifts = response.data.shifts;
double projectedSpend = 0.0;
int projectedWorkers = 0;
double totalHours = 0.0;
final Map<DateTime, (double, int)> dailyStats = <DateTime, (double, int)>{};
// Weekly stats: index -> (cost, count, hours)
final Map<int, (double, int, double)> weeklyStats = <int, (double, int, double)>{
0: (0.0, 0, 0.0),
1: (0.0, 0, 0.0),
2: (0.0, 0, 0.0),
3: (0.0, 0, 0.0),
};
for (final dc.ListShiftsForForecastByBusinessShifts shift in shifts) {
final DateTime shiftDate = shift.date?.toDateTime() ?? DateTime.now();
final DateTime date = DateTime(shiftDate.year, shiftDate.month, shiftDate.day);
final double cost = (shift.cost ?? 0.0).toDouble();
final int workers = shift.workersNeeded ?? 0;
final double hoursVal = (shift.hours ?? 0).toDouble();
final double shiftTotalHours = hoursVal * workers;
projectedSpend += cost;
projectedWorkers += workers;
totalHours += shiftTotalHours;
final (double, int) current = dailyStats[date] ?? (0.0, 0);
dailyStats[date] = (current.$1 + cost, current.$2 + workers);
// Weekly logic
final int diffDays = shiftDate.difference(startDate).inDays;
if (diffDays >= 0) {
final int weekIndex = diffDays ~/ 7;
if (weekIndex < 4) {
final (double, int, double) wCurrent = weeklyStats[weekIndex]!;
weeklyStats[weekIndex] = (
wCurrent.$1 + cost,
wCurrent.$2 + 1,
wCurrent.$3 + shiftTotalHours,
);
}
}
}
final List<ForecastPoint> chartData = dailyStats.entries.map((MapEntry<DateTime, (double, int)> e) {
return ForecastPoint(
date: e.key,
projectedCost: e.value.$1,
workersNeeded: e.value.$2,
);
}).toList()..sort((ForecastPoint a, ForecastPoint b) => a.date.compareTo(b.date));
final List<ForecastWeek> weeklyBreakdown = <ForecastWeek>[];
for (int i = 0; i < 4; i++) {
final (double, int, double) stats = weeklyStats[i]!;
weeklyBreakdown.add(ForecastWeek(
weekNumber: i + 1,
totalCost: stats.$1,
shiftsCount: stats.$2,
hoursCount: stats.$3,
avgCostPerShift: stats.$2 == 0 ? 0.0 : stats.$1 / stats.$2,
));
}
final int weeksCount = (endDate.difference(startDate).inDays / 7).ceil();
final double avgWeeklySpend = weeksCount > 0 ? projectedSpend / weeksCount : 0.0;
return ForecastReport(
projectedSpend: projectedSpend,
projectedWorkers: projectedWorkers,
averageLaborCost: projectedWorkers == 0 ? 0.0 : projectedSpend / projectedWorkers,
chartData: chartData,
totalShifts: shifts.length,
totalHours: totalHours,
avgWeeklySpend: avgWeeklySpend,
weeklyBreakdown: weeklyBreakdown,
);
});
}
@override
Future<PerformanceReport> getPerformanceReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) async {
return _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final QueryResult<dc.ListShiftsForPerformanceByBusinessData, dc.ListShiftsForPerformanceByBusinessVariables> response = await _service.connector
.listShiftsForPerformanceByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final List<dc.ListShiftsForPerformanceByBusinessShifts> shifts = response.data.shifts;
int totalNeeded = 0;
int totalFilled = 0;
int completedCount = 0;
double totalFillTimeSeconds = 0.0;
int filledShiftsWithTime = 0;
for (final dc.ListShiftsForPerformanceByBusinessShifts shift in shifts) {
totalNeeded += shift.workersNeeded ?? 0;
totalFilled += shift.filled ?? 0;
if ((shift.status?.stringValue ?? '') == 'COMPLETED') {
completedCount++;
}
if (shift.filledAt != null && shift.createdAt != null) {
final DateTime createdAt = shift.createdAt!.toDateTime();
final DateTime filledAt = shift.filledAt!.toDateTime();
totalFillTimeSeconds += filledAt.difference(createdAt).inSeconds;
filledShiftsWithTime++;
}
}
final double fillRate = totalNeeded == 0 ? 100.0 : (totalFilled / totalNeeded) * 100.0;
final double completionRate = shifts.isEmpty ? 100.0 : (completedCount / shifts.length) * 100.0;
final double avgFillTimeHours = filledShiftsWithTime == 0
? 0
: (totalFillTimeSeconds / filledShiftsWithTime) / 3600;
return PerformanceReport(
fillRate: fillRate,
completionRate: completionRate,
onTimeRate: 95.0,
avgFillTimeHours: avgFillTimeHours,
keyPerformanceIndicators: <PerformanceMetric>[
PerformanceMetric(label: 'Fill Rate', value: '${fillRate.toStringAsFixed(1)}%', trend: 0.02),
PerformanceMetric(label: 'Completion', value: '${completionRate.toStringAsFixed(1)}%', trend: 0.05),
PerformanceMetric(label: 'Avg Fill Time', value: '${avgFillTimeHours.toStringAsFixed(1)}h', trend: -0.1),
],
);
});
}
@override
Future<NoShowReport> getNoShowReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) async {
return _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final QueryResult<dc.ListShiftsForNoShowRangeByBusinessData, dc.ListShiftsForNoShowRangeByBusinessVariables> shiftsResponse = await _service.connector
.listShiftsForNoShowRangeByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final List<String> shiftIds = shiftsResponse.data.shifts.map((dc.ListShiftsForNoShowRangeByBusinessShifts s) => s.id).toList();
if (shiftIds.isEmpty) {
return const NoShowReport(totalNoShows: 0, noShowRate: 0, flaggedWorkers: <NoShowWorker>[]);
}
final QueryResult<dc.ListApplicationsForNoShowRangeData, dc.ListApplicationsForNoShowRangeVariables> appsResponse = await _service.connector
.listApplicationsForNoShowRange(shiftIds: shiftIds)
.execute();
final List<dc.ListApplicationsForNoShowRangeApplications> apps = appsResponse.data.applications;
final List<dc.ListApplicationsForNoShowRangeApplications> noShowApps = apps.where((dc.ListApplicationsForNoShowRangeApplications a) => (a.status.stringValue) == 'NO_SHOW').toList();
final List<String> noShowStaffIds = noShowApps.map((dc.ListApplicationsForNoShowRangeApplications a) => a.staffId).toSet().toList();
if (noShowStaffIds.isEmpty) {
return NoShowReport(
totalNoShows: noShowApps.length,
noShowRate: apps.isEmpty ? 0 : (noShowApps.length / apps.length) * 100.0,
flaggedWorkers: <NoShowWorker>[],
);
}
final QueryResult<dc.ListStaffForNoShowReportData, dc.ListStaffForNoShowReportVariables> staffResponse = await _service.connector
.listStaffForNoShowReport(staffIds: noShowStaffIds)
.execute();
final List<dc.ListStaffForNoShowReportStaffs> staffList = staffResponse.data.staffs;
final List<NoShowWorker> flaggedWorkers = staffList.map((dc.ListStaffForNoShowReportStaffs s) => NoShowWorker(
id: s.id,
fullName: s.fullName ?? '',
noShowCount: s.noShowCount ?? 0,
reliabilityScore: (s.reliabilityScore ?? 0.0).toDouble(),
)).toList();
return NoShowReport(
totalNoShows: noShowApps.length,
noShowRate: apps.isEmpty ? 0 : (noShowApps.length / apps.length) * 100.0,
flaggedWorkers: flaggedWorkers,
);
});
}
@override
Future<ReportsSummary> getReportsSummary({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) async {
return _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
// Use forecast query for hours/cost data
final QueryResult<dc.ListShiftsForForecastByBusinessData, dc.ListShiftsForForecastByBusinessVariables> shiftsResponse = await _service.connector
.listShiftsForForecastByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
// Use performance query for avgFillTime (has filledAt + createdAt)
final QueryResult<dc.ListShiftsForPerformanceByBusinessData, dc.ListShiftsForPerformanceByBusinessVariables> perfResponse = await _service.connector
.listShiftsForPerformanceByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final QueryResult<dc.ListInvoicesForSpendByBusinessData, dc.ListInvoicesForSpendByBusinessVariables> invoicesResponse = await _service.connector
.listInvoicesForSpendByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final List<dc.ListShiftsForForecastByBusinessShifts> forecastShifts = shiftsResponse.data.shifts;
final List<dc.ListShiftsForPerformanceByBusinessShifts> perfShifts = perfResponse.data.shifts;
final List<dc.ListInvoicesForSpendByBusinessInvoices> invoices = invoicesResponse.data.invoices;
// Aggregate hours and fill rate from forecast shifts
double totalHours = 0;
int totalNeeded = 0;
for (final dc.ListShiftsForForecastByBusinessShifts shift in forecastShifts) {
totalHours += (shift.hours ?? 0).toDouble();
totalNeeded += shift.workersNeeded ?? 0;
}
// Aggregate fill rate from performance shifts (has 'filled' field)
int perfNeeded = 0;
int perfFilled = 0;
double totalFillTimeSeconds = 0;
int filledShiftsWithTime = 0;
for (final dc.ListShiftsForPerformanceByBusinessShifts shift in perfShifts) {
perfNeeded += shift.workersNeeded ?? 0;
perfFilled += shift.filled ?? 0;
if (shift.filledAt != null && shift.createdAt != null) {
final DateTime createdAt = shift.createdAt!.toDateTime();
final DateTime filledAt = shift.filledAt!.toDateTime();
totalFillTimeSeconds += filledAt.difference(createdAt).inSeconds;
filledShiftsWithTime++;
}
}
// Aggregate total spend from invoices
double totalSpend = 0;
for (final dc.ListInvoicesForSpendByBusinessInvoices inv in invoices) {
totalSpend += (inv.amount ?? 0).toDouble();
}
// Fetch no-show rate using forecast shift IDs
final List<String> shiftIds = forecastShifts.map((dc.ListShiftsForForecastByBusinessShifts s) => s.id).toList();
double noShowRate = 0;
if (shiftIds.isNotEmpty) {
final QueryResult<dc.ListApplicationsForNoShowRangeData, dc.ListApplicationsForNoShowRangeVariables> appsResponse = await _service.connector
.listApplicationsForNoShowRange(shiftIds: shiftIds)
.execute();
final List<dc.ListApplicationsForNoShowRangeApplications> apps = appsResponse.data.applications;
final List<dc.ListApplicationsForNoShowRangeApplications> noShowApps = apps.where((dc.ListApplicationsForNoShowRangeApplications a) => (a.status.stringValue) == 'NO_SHOW').toList();
noShowRate = apps.isEmpty ? 0 : (noShowApps.length / apps.length) * 100.0;
}
final double fillRate = perfNeeded == 0 ? 100.0 : (perfFilled / perfNeeded) * 100.0;
return ReportsSummary(
totalHours: totalHours,
otHours: totalHours * 0.05, // ~5% OT approximation until schema supports it
totalSpend: totalSpend,
fillRate: fillRate,
avgFillTimeHours: filledShiftsWithTime == 0
? 0
: (totalFillTimeSeconds / filledShiftsWithTime) / 3600,
noShowRate: noShowRate,
);
});
}
}

View File

@@ -1,55 +0,0 @@
import 'package:krow_domain/krow_domain.dart';
/// Repository interface for reports connector queries.
///
/// This interface defines the contract for accessing report-related data
/// from the backend via Data Connect.
abstract interface class ReportsConnectorRepository {
/// Fetches the daily operations report for a specific business and date.
Future<DailyOpsReport> getDailyOpsReport({
String? businessId,
required DateTime date,
});
/// Fetches the spend report for a specific business and date range.
Future<SpendReport> getSpendReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
/// Fetches the coverage report for a specific business and date range.
Future<CoverageReport> getCoverageReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
/// Fetches the forecast report for a specific business and date range.
Future<ForecastReport> getForecastReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
/// Fetches the performance report for a specific business and date range.
Future<PerformanceReport> getPerformanceReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
/// Fetches the no-show report for a specific business and date range.
Future<NoShowReport> getNoShowReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
/// Fetches a summary of all reports for a specific business and date range.
Future<ReportsSummary> getReportsSummary({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
}

View File

@@ -1,56 +0,0 @@
import 'package:krow_domain/krow_domain.dart';
/// Repository interface for shifts connector operations.
///
/// This acts as a buffer layer between the domain repository and the Data Connect SDK.
abstract interface class ShiftsConnectorRepository {
/// Retrieves shifts assigned to the current staff member.
Future<List<Shift>> getMyShifts({
required String staffId,
required DateTime start,
required DateTime end,
});
/// Retrieves available shifts.
Future<List<Shift>> getAvailableShifts({
required String staffId,
String? query,
String? type,
});
/// Retrieves pending shift assignments for the current staff member.
Future<List<Shift>> getPendingAssignments({required String staffId});
/// Retrieves detailed information for a specific shift.
Future<Shift?> getShiftDetails({
required String shiftId,
required String staffId,
String? roleId,
});
/// Applies for a specific open shift.
Future<void> applyForShift({
required String shiftId,
required String staffId,
bool isInstantBook = false,
String? roleId,
});
/// Accepts a pending shift assignment.
Future<void> acceptShift({
required String shiftId,
required String staffId,
});
/// Declines a pending shift assignment.
Future<void> declineShift({
required String shiftId,
required String staffId,
});
/// Retrieves cancelled shifts for the current staff member.
Future<List<Shift>> getCancelledShifts({required String staffId});
/// Retrieves historical (completed) shifts for the current staff member.
Future<List<Shift>> getHistoryShifts({required String staffId});
}

View File

@@ -1,876 +0,0 @@
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_core/core.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].
///
/// Fetches staff-related data from the Data Connect backend using
/// the staff connector queries.
class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
/// Creates a new [StaffConnectorRepositoryImpl].
///
/// Requires a [DataConnectService] instance for backend communication.
StaffConnectorRepositoryImpl({dc.DataConnectService? service})
: _service = service ?? dc.DataConnectService.instance;
final dc.DataConnectService _service;
@override
Future<bool> getProfileCompletion() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<
dc.GetStaffProfileCompletionData,
dc.GetStaffProfileCompletionVariables
>
response = await _service.connector
.getStaffProfileCompletion(id: staffId)
.execute();
final dc.GetStaffProfileCompletionStaff? staff = response.data.staff;
final List<dc.GetStaffProfileCompletionEmergencyContacts>
emergencyContacts = response.data.emergencyContacts;
return _isProfileComplete(staff, emergencyContacts);
});
}
@override
Future<bool> getPersonalInfoCompletion() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<
dc.GetStaffPersonalInfoCompletionData,
dc.GetStaffPersonalInfoCompletionVariables
>
response = await _service.connector
.getStaffPersonalInfoCompletion(id: staffId)
.execute();
final dc.GetStaffPersonalInfoCompletionStaff? staff = response.data.staff;
return _isPersonalInfoComplete(staff);
});
}
@override
Future<bool> getEmergencyContactsCompletion() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<
dc.GetStaffEmergencyProfileCompletionData,
dc.GetStaffEmergencyProfileCompletionVariables
>
response = await _service.connector
.getStaffEmergencyProfileCompletion(id: staffId)
.execute();
return response.data.emergencyContacts.isNotEmpty;
});
}
@override
Future<bool> getExperienceCompletion() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<
dc.GetStaffExperienceProfileCompletionData,
dc.GetStaffExperienceProfileCompletionVariables
>
response = await _service.connector
.getStaffExperienceProfileCompletion(id: staffId)
.execute();
final dc.GetStaffExperienceProfileCompletionStaff? staff =
response.data.staff;
return _hasExperience(staff);
});
}
@override
Future<bool> getTaxFormsCompletion() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<
dc.GetStaffTaxFormsProfileCompletionData,
dc.GetStaffTaxFormsProfileCompletionVariables
>
response = await _service.connector
.getStaffTaxFormsProfileCompletion(id: staffId)
.execute();
final List<dc.GetStaffTaxFormsProfileCompletionTaxForms> taxForms =
response.data.taxForms;
// Return false if no tax forms exist
if (taxForms.isEmpty) return false;
// Return true only if all tax forms have status == "SUBMITTED"
return taxForms.every(
(dc.GetStaffTaxFormsProfileCompletionTaxForms form) {
if (form.status is dc.Unknown) return false;
final dc.TaxFormStatus status =
(form.status as dc.Known<dc.TaxFormStatus>).value;
return status == dc.TaxFormStatus.SUBMITTED;
},
);
});
}
@override
Future<bool?> getAttireOptionsCompletion() 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.ListAttireOptionsAttireOptions> attireOptions =
optionsRes.data.attireOptions;
final List<dc.GetStaffAttireStaffAttires> staffAttire =
staffAttireRes.data.staffAttires;
// Get only mandatory attire options
final List<dc.ListAttireOptionsAttireOptions> mandatoryOptions =
attireOptions
.where((dc.ListAttireOptionsAttireOptions opt) =>
opt.isMandatory ?? false)
.toList();
// Return null if no mandatory attire options
if (mandatoryOptions.isEmpty) return null;
// Return true only if all mandatory attire items are verified
return mandatoryOptions.every(
(dc.ListAttireOptionsAttireOptions mandatoryOpt) {
final dc.GetStaffAttireStaffAttires? currentAttire = staffAttire
.where(
(dc.GetStaffAttireStaffAttires a) =>
a.attireOptionId == mandatoryOpt.id,
)
.firstOrNull;
if (currentAttire == null) return false; // Not uploaded
if (currentAttire.verificationStatus is dc.Unknown) return false;
final dc.AttireVerificationStatus status =
(currentAttire.verificationStatus
as dc.Known<dc.AttireVerificationStatus>)
.value;
return status == dc.AttireVerificationStatus.APPROVED;
},
);
});
}
@override
Future<bool?> getStaffDocumentsCompletion() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<
dc.ListStaffDocumentsByStaffIdData,
dc.ListStaffDocumentsByStaffIdVariables
>
response = await _service.connector
.listStaffDocumentsByStaffId(staffId: staffId)
.execute();
final List<dc.ListStaffDocumentsByStaffIdStaffDocuments> staffDocs =
response.data.staffDocuments;
// Return null if no documents
if (staffDocs.isEmpty) return null;
// Return true only if all documents are verified
return staffDocs.every(
(dc.ListStaffDocumentsByStaffIdStaffDocuments doc) {
if (doc.status is dc.Unknown) return false;
final dc.DocumentStatus status =
(doc.status as dc.Known<dc.DocumentStatus>).value;
return status == dc.DocumentStatus.VERIFIED;
},
);
});
}
@override
Future<bool?> getStaffCertificatesCompletion() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<
dc.ListCertificatesByStaffIdData,
dc.ListCertificatesByStaffIdVariables
>
response = await _service.connector
.listCertificatesByStaffId(staffId: staffId)
.execute();
final List<dc.ListCertificatesByStaffIdCertificates> certificates =
response.data.certificates;
// Return false if no certificates
if (certificates.isEmpty) return null;
// Return true only if all certificates are fully validated
return certificates.every(
(dc.ListCertificatesByStaffIdCertificates cert) {
if (cert.validationStatus is dc.Unknown) return false;
final dc.ValidationStatus status =
(cert.validationStatus as dc.Known<dc.ValidationStatus>).value;
return status == dc.ValidationStatus.APPROVED;
},
);
});
}
/// Checks if personal info is complete.
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 &&
(email?.trim().isNotEmpty ?? false) &&
(phone?.trim().isNotEmpty ?? false);
}
/// Checks if staff has experience data (skills or industries).
bool _hasExperience(dc.GetStaffExperienceProfileCompletionStaff? staff) {
if (staff == null) return false;
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(
dc.GetStaffProfileCompletionStaff? staff,
List<dc.GetStaffProfileCompletionEmergencyContacts> emergencyContacts,
) {
if (staff == null) return false;
final List<String>? skills = staff.skills;
final List<String>? industries = staff.industries;
final bool hasExperience =
(skills?.isNotEmpty ?? false) || (industries?.isNotEmpty ?? false);
return (staff.fullName.trim().isNotEmpty) &&
(staff.email?.trim().isNotEmpty ?? false) &&
emergencyContacts.isNotEmpty &&
hasExperience;
}
@override
Future<domain.Staff> getStaffProfile() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<dc.GetStaffByIdData, dc.GetStaffByIdVariables>
response = await _service.connector.getStaffById(id: staffId).execute();
final dc.GetStaffByIdStaff? staff = response.data.staff;
if (staff == null) {
throw Exception('Staff not found');
}
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<domain.Benefit>> getBenefits() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<
dc.ListBenefitsDataByStaffIdData,
dc.ListBenefitsDataByStaffIdVariables
>
response = await _service.connector
.listBenefitsDataByStaffId(staffId: staffId)
.execute();
return response.data.benefitsDatas.map((
dc.ListBenefitsDataByStaffIdBenefitsDatas e,
) {
final double total = e.vendorBenefitPlan.total?.toDouble() ?? 0.0;
final double remaining = e.current.toDouble();
return domain.Benefit(
title: e.vendorBenefitPlan.title,
entitlementHours: total,
usedHours: (total - remaining).clamp(0.0, total),
);
}).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 {
await _service.signOut();
} catch (e) {
throw Exception('Error signing out: ${e.toString()}');
}
}
@override
Future<List<domain.StaffDocument>> getStaffDocuments() 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.listDocuments().execute(),
_service.connector
.listStaffDocumentsByStaffId(staffId: staffId)
.execute(),
],
);
final QueryResult<dc.ListDocumentsData, void> documentsRes =
results[0] as QueryResult<dc.ListDocumentsData, void>;
final QueryResult<
dc.ListStaffDocumentsByStaffIdData,
dc.ListStaffDocumentsByStaffIdVariables
>
staffDocsRes =
results[1]
as QueryResult<
dc.ListStaffDocumentsByStaffIdData,
dc.ListStaffDocumentsByStaffIdVariables
>;
final List<dc.ListStaffDocumentsByStaffIdStaffDocuments> staffDocs =
staffDocsRes.data.staffDocuments;
return documentsRes.data.documents.map((dc.ListDocumentsDocuments doc) {
// Find if this staff member has already uploaded this document
final dc.ListStaffDocumentsByStaffIdStaffDocuments? currentDoc =
staffDocs
.where(
(dc.ListStaffDocumentsByStaffIdStaffDocuments d) =>
d.documentId == doc.id,
)
.firstOrNull;
return domain.StaffDocument(
id: currentDoc?.id ?? '',
staffId: staffId,
documentId: doc.id,
name: doc.name,
description: doc.description,
status: currentDoc != null
? _mapDocumentStatus(currentDoc.status)
: domain.DocumentStatus.missing,
documentUrl: currentDoc?.documentUrl,
expiryDate: currentDoc?.expiryDate == null
? null
: DateTimeUtils.toDeviceTime(
currentDoc!.expiryDate!.toDateTime(),
),
verificationId: currentDoc?.verificationId,
verificationStatus: currentDoc != null
? _mapFromDCDocumentVerificationStatus(currentDoc.status)
: null,
);
}).toList();
});
}
@override
Future<void> upsertStaffDocument({
required String documentId,
required String documentUrl,
domain.DocumentStatus? status,
String? verificationId,
}) async {
await _service.run(() async {
final String staffId = await _service.getStaffId();
final domain.Staff staff = await getStaffProfile();
await _service.connector
.upsertStaffDocument(
staffId: staffId,
staffName: staff.name,
documentId: documentId,
status: _mapToDCDocumentStatus(status),
)
.documentUrl(documentUrl)
.verificationId(verificationId)
.execute();
});
}
domain.DocumentStatus _mapDocumentStatus(
dc.EnumValue<dc.DocumentStatus> status,
) {
if (status is dc.Unknown) {
return domain.DocumentStatus.pending;
}
final dc.DocumentStatus value =
(status as dc.Known<dc.DocumentStatus>).value;
switch (value) {
case dc.DocumentStatus.VERIFIED:
return domain.DocumentStatus.verified;
case dc.DocumentStatus.PENDING:
return domain.DocumentStatus.pending;
case dc.DocumentStatus.MISSING:
return domain.DocumentStatus.missing;
case dc.DocumentStatus.UPLOADED:
case dc.DocumentStatus.EXPIRING:
return domain.DocumentStatus.pending;
case dc.DocumentStatus.PROCESSING:
case dc.DocumentStatus.AUTO_PASS:
case dc.DocumentStatus.AUTO_FAIL:
case dc.DocumentStatus.NEEDS_REVIEW:
case dc.DocumentStatus.APPROVED:
case dc.DocumentStatus.REJECTED:
case dc.DocumentStatus.ERROR:
if (value == dc.DocumentStatus.AUTO_PASS ||
value == dc.DocumentStatus.APPROVED) {
return domain.DocumentStatus.verified;
}
if (value == dc.DocumentStatus.AUTO_FAIL ||
value == dc.DocumentStatus.REJECTED ||
value == dc.DocumentStatus.ERROR) {
return domain.DocumentStatus.rejected;
}
return domain.DocumentStatus.pending;
}
}
domain.DocumentVerificationStatus _mapFromDCDocumentVerificationStatus(
dc.EnumValue<dc.DocumentStatus> status,
) {
if (status is dc.Unknown) {
return domain.DocumentVerificationStatus.error;
}
final String name = (status as dc.Known<dc.DocumentStatus>).value.name;
switch (name) {
case 'PENDING':
return domain.DocumentVerificationStatus.pending;
case 'PROCESSING':
return domain.DocumentVerificationStatus.processing;
case 'AUTO_PASS':
return domain.DocumentVerificationStatus.autoPass;
case 'AUTO_FAIL':
return domain.DocumentVerificationStatus.autoFail;
case 'NEEDS_REVIEW':
return domain.DocumentVerificationStatus.needsReview;
case 'APPROVED':
return domain.DocumentVerificationStatus.approved;
case 'REJECTED':
return domain.DocumentVerificationStatus.rejected;
case 'VERIFIED':
return domain.DocumentVerificationStatus.approved;
case 'ERROR':
return domain.DocumentVerificationStatus.error;
default:
return domain.DocumentVerificationStatus.error;
}
}
dc.DocumentStatus _mapToDCDocumentStatus(domain.DocumentStatus? status) {
if (status == null) return dc.DocumentStatus.PENDING;
switch (status) {
case domain.DocumentStatus.verified:
return dc.DocumentStatus.VERIFIED;
case domain.DocumentStatus.pending:
return dc.DocumentStatus.PENDING;
case domain.DocumentStatus.missing:
return dc.DocumentStatus.MISSING;
case domain.DocumentStatus.rejected:
return dc.DocumentStatus.REJECTED;
case domain.DocumentStatus.expired:
return dc.DocumentStatus.EXPIRING;
}
}
@override
Future<List<domain.StaffCertificate>> getStaffCertificates() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<
dc.ListCertificatesByStaffIdData,
dc.ListCertificatesByStaffIdVariables
>
response = await _service.connector
.listCertificatesByStaffId(staffId: staffId)
.execute();
return response.data.certificates.map((
dc.ListCertificatesByStaffIdCertificates cert,
) {
return domain.StaffCertificate(
id: cert.id,
staffId: cert.staffId,
name: cert.name,
description: cert.description,
expiryDate: _service.toDateTime(cert.expiry),
status: _mapToDomainCertificateStatus(cert.status),
certificateUrl: cert.fileUrl,
icon: cert.icon,
certificationType: _mapToDomainComplianceType(cert.certificationType),
issuer: cert.issuer,
certificateNumber: cert.certificateNumber,
validationStatus: _mapToDomainValidationStatus(cert.validationStatus),
createdAt: _service.toDateTime(cert.createdAt),
);
}).toList();
});
}
@override
Future<void> upsertStaffCertificate({
required domain.ComplianceType certificationType,
required String name,
required domain.StaffCertificateStatus status,
String? fileUrl,
DateTime? expiry,
String? issuer,
String? certificateNumber,
domain.StaffCertificateValidationStatus? validationStatus,
String? verificationId,
}) async {
await _service.run(() async {
final String staffId = await _service.getStaffId();
await _service.connector
.upsertStaffCertificate(
staffId: staffId,
certificationType: _mapToDCComplianceType(certificationType),
name: name,
status: _mapToDCCertificateStatus(status),
)
.fileUrl(fileUrl)
.expiry(_service.tryToTimestamp(expiry))
.issuer(issuer)
.certificateNumber(certificateNumber)
.validationStatus(_mapToDCValidationStatus(validationStatus))
// .verificationId(verificationId) // FIXME: Uncomment after running 'make dataconnect-generate-sdk'
.execute();
});
}
@override
Future<void> deleteStaffCertificate({
required domain.ComplianceType certificationType,
}) async {
await _service.run(() async {
final String staffId = await _service.getStaffId();
await _service.connector
.deleteCertificate(
staffId: staffId,
certificationType: _mapToDCComplianceType(certificationType),
)
.execute();
});
}
domain.StaffCertificateStatus _mapToDomainCertificateStatus(
dc.EnumValue<dc.CertificateStatus> status,
) {
if (status is dc.Unknown) return domain.StaffCertificateStatus.notStarted;
final dc.CertificateStatus value =
(status as dc.Known<dc.CertificateStatus>).value;
switch (value) {
case dc.CertificateStatus.CURRENT:
return domain.StaffCertificateStatus.current;
case dc.CertificateStatus.EXPIRING_SOON:
return domain.StaffCertificateStatus.expiringSoon;
case dc.CertificateStatus.COMPLETED:
return domain.StaffCertificateStatus.completed;
case dc.CertificateStatus.PENDING:
return domain.StaffCertificateStatus.pending;
case dc.CertificateStatus.EXPIRED:
return domain.StaffCertificateStatus.expired;
case dc.CertificateStatus.EXPIRING:
return domain.StaffCertificateStatus.expiring;
case dc.CertificateStatus.NOT_STARTED:
return domain.StaffCertificateStatus.notStarted;
}
}
dc.CertificateStatus _mapToDCCertificateStatus(
domain.StaffCertificateStatus status,
) {
switch (status) {
case domain.StaffCertificateStatus.current:
return dc.CertificateStatus.CURRENT;
case domain.StaffCertificateStatus.expiringSoon:
return dc.CertificateStatus.EXPIRING_SOON;
case domain.StaffCertificateStatus.completed:
return dc.CertificateStatus.COMPLETED;
case domain.StaffCertificateStatus.pending:
return dc.CertificateStatus.PENDING;
case domain.StaffCertificateStatus.expired:
return dc.CertificateStatus.EXPIRED;
case domain.StaffCertificateStatus.expiring:
return dc.CertificateStatus.EXPIRING;
case domain.StaffCertificateStatus.notStarted:
return dc.CertificateStatus.NOT_STARTED;
}
}
domain.ComplianceType _mapToDomainComplianceType(
dc.EnumValue<dc.ComplianceType> type,
) {
if (type is dc.Unknown) return domain.ComplianceType.other;
final dc.ComplianceType value = (type as dc.Known<dc.ComplianceType>).value;
switch (value) {
case dc.ComplianceType.BACKGROUND_CHECK:
return domain.ComplianceType.backgroundCheck;
case dc.ComplianceType.FOOD_HANDLER:
return domain.ComplianceType.foodHandler;
case dc.ComplianceType.RBS:
return domain.ComplianceType.rbs;
case dc.ComplianceType.LEGAL:
return domain.ComplianceType.legal;
case dc.ComplianceType.OPERATIONAL:
return domain.ComplianceType.operational;
case dc.ComplianceType.SAFETY:
return domain.ComplianceType.safety;
case dc.ComplianceType.TRAINING:
return domain.ComplianceType.training;
case dc.ComplianceType.LICENSE:
return domain.ComplianceType.license;
case dc.ComplianceType.OTHER:
return domain.ComplianceType.other;
}
}
dc.ComplianceType _mapToDCComplianceType(domain.ComplianceType type) {
switch (type) {
case domain.ComplianceType.backgroundCheck:
return dc.ComplianceType.BACKGROUND_CHECK;
case domain.ComplianceType.foodHandler:
return dc.ComplianceType.FOOD_HANDLER;
case domain.ComplianceType.rbs:
return dc.ComplianceType.RBS;
case domain.ComplianceType.legal:
return dc.ComplianceType.LEGAL;
case domain.ComplianceType.operational:
return dc.ComplianceType.OPERATIONAL;
case domain.ComplianceType.safety:
return dc.ComplianceType.SAFETY;
case domain.ComplianceType.training:
return dc.ComplianceType.TRAINING;
case domain.ComplianceType.license:
return dc.ComplianceType.LICENSE;
case domain.ComplianceType.other:
return dc.ComplianceType.OTHER;
}
}
domain.StaffCertificateValidationStatus? _mapToDomainValidationStatus(
dc.EnumValue<dc.ValidationStatus>? status,
) {
if (status == null || status is dc.Unknown) return null;
final dc.ValidationStatus value =
(status as dc.Known<dc.ValidationStatus>).value;
switch (value) {
case dc.ValidationStatus.APPROVED:
return domain.StaffCertificateValidationStatus.approved;
case dc.ValidationStatus.PENDING_EXPERT_REVIEW:
return domain.StaffCertificateValidationStatus.pendingExpertReview;
case dc.ValidationStatus.REJECTED:
return domain.StaffCertificateValidationStatus.rejected;
case dc.ValidationStatus.AI_VERIFIED:
return domain.StaffCertificateValidationStatus.aiVerified;
case dc.ValidationStatus.AI_FLAGGED:
return domain.StaffCertificateValidationStatus.aiFlagged;
case dc.ValidationStatus.MANUAL_REVIEW_NEEDED:
return domain.StaffCertificateValidationStatus.manualReviewNeeded;
}
}
dc.ValidationStatus? _mapToDCValidationStatus(
domain.StaffCertificateValidationStatus? status,
) {
if (status == null) return null;
switch (status) {
case domain.StaffCertificateValidationStatus.approved:
return dc.ValidationStatus.APPROVED;
case domain.StaffCertificateValidationStatus.pendingExpertReview:
return dc.ValidationStatus.PENDING_EXPERT_REVIEW;
case domain.StaffCertificateValidationStatus.rejected:
return dc.ValidationStatus.REJECTED;
case domain.StaffCertificateValidationStatus.aiVerified:
return dc.ValidationStatus.AI_VERIFIED;
case domain.StaffCertificateValidationStatus.aiFlagged:
return dc.ValidationStatus.AI_FLAGGED;
case domain.StaffCertificateValidationStatus.manualReviewNeeded:
return dc.ValidationStatus.MANUAL_REVIEW_NEEDED;
}
}
}

View File

@@ -1,122 +0,0 @@
import 'package:krow_domain/krow_domain.dart';
/// Repository interface for staff connector queries.
///
/// This interface defines the contract for accessing staff-related data
/// from the backend via Data Connect.
abstract interface class StaffConnectorRepository {
/// Fetches whether the profile is complete for the current staff member.
///
/// Returns true if all required profile sections have been completed,
/// false otherwise.
///
/// Throws an exception if the query fails.
Future<bool> getProfileCompletion();
/// Fetches personal information completion status.
///
/// Returns true if personal info (name, email, phone, locations) is complete.
Future<bool> getPersonalInfoCompletion();
/// Fetches emergency contacts completion status.
///
/// Returns true if at least one emergency contact exists.
Future<bool> getEmergencyContactsCompletion();
/// Fetches experience completion status.
///
/// Returns true if staff has industries or skills defined.
Future<bool> getExperienceCompletion();
/// Fetches tax forms completion status.
///
/// Returns true if at least one tax form exists.
Future<bool> getTaxFormsCompletion();
/// Fetches attire options completion status.
///
/// Returns true if all mandatory attire options are verified.
Future<bool?> getAttireOptionsCompletion();
/// Fetches documents completion status.
///
/// Returns true if all mandatory documents are verified.
Future<bool?> getStaffDocumentsCompletion();
/// Fetches certificates completion status.
///
/// Returns true if all certificates are validated.
Future<bool?> getStaffCertificatesCompletion();
/// Fetches the full staff profile for the current authenticated user.
///
/// Returns a [Staff] entity containing all profile information.
///
/// Throws an exception if the profile cannot be retrieved.
Future<Staff> getStaffProfile();
/// Fetches the benefits for the current authenticated user.
///
/// 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,
});
/// Fetches the staff documents for the current authenticated user.
Future<List<StaffDocument>> getStaffDocuments();
/// Upserts staff document information.
Future<void> upsertStaffDocument({
required String documentId,
required String documentUrl,
DocumentStatus? status,
String? verificationId,
});
/// Fetches the staff certificates for the current authenticated user.
Future<List<StaffCertificate>> getStaffCertificates();
/// Upserts staff certificate information.
Future<void> upsertStaffCertificate({
required ComplianceType certificationType,
required String name,
required StaffCertificateStatus status,
String? fileUrl,
DateTime? expiry,
String? issuer,
String? certificateNumber,
StaffCertificateValidationStatus? validationStatus,
String? verificationId,
});
/// Deletes a staff certificate.
Future<void> deleteStaffCertificate({
required ComplianceType certificationType,
});
}

View File

@@ -1,27 +0,0 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for retrieving attire options completion status.
///
/// This use case encapsulates the business logic for determining whether
/// a staff member has fully uploaded and verified all mandatory attire options.
/// It delegates to the repository for data access.
class GetAttireOptionsCompletionUseCase extends NoInputUseCase<bool?> {
/// Creates a [GetAttireOptionsCompletionUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetAttireOptionsCompletionUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get attire options completion status.
///
/// Returns true if all mandatory attire options are verified, false otherwise.
///
/// Throws an exception if the operation fails.
@override
Future<bool?> call() => _repository.getAttireOptionsCompletion();
}

View File

@@ -1,27 +0,0 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for retrieving emergency contacts completion status.
///
/// This use case encapsulates the business logic for determining whether
/// a staff member has at least one emergency contact registered.
/// It delegates to the repository for data access.
class GetEmergencyContactsCompletionUseCase extends NoInputUseCase<bool> {
/// Creates a [GetEmergencyContactsCompletionUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetEmergencyContactsCompletionUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get emergency contacts completion status.
///
/// Returns true if emergency contacts are registered, false otherwise.
///
/// Throws an exception if the operation fails.
@override
Future<bool> call() => _repository.getEmergencyContactsCompletion();
}

View File

@@ -1,27 +0,0 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for retrieving experience completion status.
///
/// This use case encapsulates the business logic for determining whether
/// a staff member has experience data (skills or industries) defined.
/// It delegates to the repository for data access.
class GetExperienceCompletionUseCase extends NoInputUseCase<bool> {
/// Creates a [GetExperienceCompletionUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetExperienceCompletionUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get experience completion status.
///
/// Returns true if experience data is defined, false otherwise.
///
/// Throws an exception if the operation fails.
@override
Future<bool> call() => _repository.getExperienceCompletion();
}

View File

@@ -1,27 +0,0 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for retrieving personal information completion status.
///
/// This use case encapsulates the business logic for determining whether
/// a staff member's personal information is complete (name, email, phone).
/// It delegates to the repository for data access.
class GetPersonalInfoCompletionUseCase extends NoInputUseCase<bool> {
/// Creates a [GetPersonalInfoCompletionUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetPersonalInfoCompletionUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get personal info completion status.
///
/// Returns true if personal information is complete, false otherwise.
///
/// Throws an exception if the operation fails.
@override
Future<bool> call() => _repository.getPersonalInfoCompletion();
}

View File

@@ -1,27 +0,0 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for retrieving staff profile completion status.
///
/// This use case encapsulates the business logic for determining whether
/// a staff member's profile is complete. It delegates to the repository
/// for data access.
class GetProfileCompletionUseCase extends NoInputUseCase<bool> {
/// Creates a [GetProfileCompletionUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetProfileCompletionUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get profile completion status.
///
/// Returns true if the profile is complete, false otherwise.
///
/// Throws an exception if the operation fails.
@override
Future<bool> call() => _repository.getProfileCompletion();
}

View File

@@ -1,27 +0,0 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for retrieving certificates completion status.
///
/// This use case encapsulates the business logic for determining whether
/// a staff member has fully validated all certificates.
/// It delegates to the repository for data access.
class GetStaffCertificatesCompletionUseCase extends NoInputUseCase<bool?> {
/// Creates a [GetStaffCertificatesCompletionUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetStaffCertificatesCompletionUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get certificates completion status.
///
/// Returns true if all certificates are validated, false otherwise.
///
/// Throws an exception if the operation fails.
@override
Future<bool?> call() => _repository.getStaffCertificatesCompletion();
}

View File

@@ -1,27 +0,0 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for retrieving documents completion status.
///
/// This use case encapsulates the business logic for determining whether
/// a staff member has fully uploaded and verified all mandatory documents.
/// It delegates to the repository for data access.
class GetStaffDocumentsCompletionUseCase extends NoInputUseCase<bool?> {
/// Creates a [GetStaffDocumentsCompletionUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetStaffDocumentsCompletionUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get documents completion status.
///
/// Returns true if all mandatory documents are verified, false otherwise.
///
/// Throws an exception if the operation fails.
@override
Future<bool?> call() => _repository.getStaffDocumentsCompletion();
}

View File

@@ -1,28 +0,0 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for fetching a staff member's full profile information.
///
/// This use case encapsulates the business logic for retrieving the complete
/// staff profile including personal info, ratings, and reliability scores.
/// It delegates to the repository for data access.
class GetStaffProfileUseCase extends UseCase<void, Staff> {
/// Creates a [GetStaffProfileUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetStaffProfileUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get the staff profile.
///
/// Returns a [Staff] entity containing all profile information.
///
/// Throws an exception if the operation fails.
@override
Future<Staff> call([void params]) => _repository.getStaffProfile();
}

View File

@@ -1,27 +0,0 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for retrieving tax forms completion status.
///
/// This use case encapsulates the business logic for determining whether
/// a staff member has at least one tax form submitted.
/// It delegates to the repository for data access.
class GetTaxFormsCompletionUseCase extends NoInputUseCase<bool> {
/// Creates a [GetTaxFormsCompletionUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetTaxFormsCompletionUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get tax forms completion status.
///
/// Returns true if tax forms are submitted, false otherwise.
///
/// Throws an exception if the operation fails.
@override
Future<bool> call() => _repository.getTaxFormsCompletion();
}

View File

@@ -1,25 +0,0 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for signing out the current staff user.
///
/// This use case encapsulates the business logic for signing out,
/// including clearing authentication state and cache.
/// It delegates to the repository for data access.
class SignOutStaffUseCase extends NoInputUseCase<void> {
/// Creates a [SignOutStaffUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
SignOutStaffUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to sign out the user.
///
/// Throws an exception if the operation fails.
@override
Future<void> call() => _repository.signOut();
}

View File

@@ -1,37 +0,0 @@
import 'package:flutter_modular/flutter_modular.dart';
import 'connectors/reports/domain/repositories/reports_connector_repository.dart';
import 'connectors/reports/data/repositories/reports_connector_repository_impl.dart';
import 'connectors/shifts/domain/repositories/shifts_connector_repository.dart';
import 'connectors/shifts/data/repositories/shifts_connector_repository_impl.dart';
import 'connectors/hubs/domain/repositories/hubs_connector_repository.dart';
import 'connectors/hubs/data/repositories/hubs_connector_repository_impl.dart';
import 'connectors/billing/domain/repositories/billing_connector_repository.dart';
import 'connectors/billing/data/repositories/billing_connector_repository_impl.dart';
import 'connectors/coverage/domain/repositories/coverage_connector_repository.dart';
import 'connectors/coverage/data/repositories/coverage_connector_repository_impl.dart';
import 'services/data_connect_service.dart';
/// A module that provides Data Connect dependencies.
class DataConnectModule extends Module {
@override
void exportedBinds(Injector i) {
i.addInstance<DataConnectService>(DataConnectService.instance);
// Repositories
i.addLazySingleton<ReportsConnectorRepository>(
ReportsConnectorRepositoryImpl.new,
);
i.addLazySingleton<ShiftsConnectorRepository>(
ShiftsConnectorRepositoryImpl.new,
);
i.addLazySingleton<HubsConnectorRepository>(
HubsConnectorRepositoryImpl.new,
);
i.addLazySingleton<BillingConnectorRepository>(
BillingConnectorRepositoryImpl.new,
);
i.addLazySingleton<CoverageConnectorRepository>(
CoverageConnectorRepositoryImpl.new,
);
}
}

View File

@@ -1,250 +0,0 @@
// 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:firebase_auth/firebase_auth.dart' as firebase;
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:flutter/foundation.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart' as domain;
import '../connectors/reports/domain/repositories/reports_connector_repository.dart';
import '../connectors/reports/data/repositories/reports_connector_repository_impl.dart';
import '../connectors/shifts/domain/repositories/shifts_connector_repository.dart';
import '../connectors/shifts/data/repositories/shifts_connector_repository_impl.dart';
import '../connectors/hubs/domain/repositories/hubs_connector_repository.dart';
import '../connectors/hubs/data/repositories/hubs_connector_repository_impl.dart';
import '../connectors/billing/domain/repositories/billing_connector_repository.dart';
import '../connectors/billing/data/repositories/billing_connector_repository_impl.dart';
import '../connectors/coverage/domain/repositories/coverage_connector_repository.dart';
import '../connectors/coverage/data/repositories/coverage_connector_repository_impl.dart';
import '../connectors/staff/domain/repositories/staff_connector_repository.dart';
import '../connectors/staff/data/repositories/staff_connector_repository_impl.dart';
import 'mixins/data_error_handler.dart';
import 'mixins/session_handler_mixin.dart';
/// A centralized service for interacting with Firebase Data Connect.
///
/// This service provides common utilities and context management for all repositories.
class DataConnectService with DataErrorHandler, SessionHandlerMixin {
DataConnectService._();
/// The singleton instance of the [DataConnectService].
static final DataConnectService instance = DataConnectService._();
/// The Data Connect connector used for data operations.
final dc.ExampleConnector connector = dc.ExampleConnector.instance;
// Repositories
ReportsConnectorRepository? _reportsRepository;
ShiftsConnectorRepository? _shiftsRepository;
HubsConnectorRepository? _hubsRepository;
BillingConnectorRepository? _billingRepository;
CoverageConnectorRepository? _coverageRepository;
StaffConnectorRepository? _staffRepository;
/// Gets the reports connector repository.
ReportsConnectorRepository getReportsRepository() {
return _reportsRepository ??= ReportsConnectorRepositoryImpl(service: this);
}
/// Gets the shifts connector repository.
ShiftsConnectorRepository getShiftsRepository() {
return _shiftsRepository ??= ShiftsConnectorRepositoryImpl(service: this);
}
/// Gets the hubs connector repository.
HubsConnectorRepository getHubsRepository() {
return _hubsRepository ??= HubsConnectorRepositoryImpl(service: this);
}
/// Gets the billing connector repository.
BillingConnectorRepository getBillingRepository() {
return _billingRepository ??= BillingConnectorRepositoryImpl(service: this);
}
/// Gets the coverage connector repository.
CoverageConnectorRepository getCoverageRepository() {
return _coverageRepository ??= CoverageConnectorRepositoryImpl(
service: this,
);
}
/// Gets the staff connector repository.
StaffConnectorRepository getStaffRepository() {
return _staffRepository ??= StaffConnectorRepositoryImpl(service: this);
}
/// Returns the current Firebase Auth instance.
@override
firebase.FirebaseAuth get auth => firebase.FirebaseAuth.instance;
/// Helper to get the current staff ID from the session.
Future<String> getStaffId() async {
String? staffId = dc.StaffSessionStore.instance.session?.staff?.id;
if (staffId == null || staffId.isEmpty) {
// Attempt to recover session if user is signed in
final user = auth.currentUser;
if (user != null) {
await _loadSession(user.uid);
staffId = dc.StaffSessionStore.instance.session?.staff?.id;
}
}
if (staffId == null || staffId.isEmpty) {
throw Exception('No staff ID found in session.');
}
return staffId;
}
/// Helper to get the current business ID from the session.
Future<String> getBusinessId() async {
String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
// Attempt to recover session if user is signed in
final user = auth.currentUser;
if (user != null) {
await _loadSession(user.uid);
businessId = dc.ClientSessionStore.instance.session?.business?.id;
}
}
if (businessId == null || businessId.isEmpty) {
throw Exception('No business ID found in session.');
}
return businessId;
}
/// Logic to load session data from backend and populate stores.
Future<void> _loadSession(String userId) async {
try {
final role = await fetchUserRole(userId);
if (role == null) return;
// Load Staff Session if applicable
if (role == 'STAFF' || role == 'BOTH') {
final response = await connector
.getStaffByUserId(userId: userId)
.execute();
if (response.data.staffs.isNotEmpty) {
final s = response.data.staffs.first;
dc.StaffSessionStore.instance.setSession(
dc.StaffSession(
ownerId: s.ownerId,
staff: domain.Staff(
id: s.id,
authProviderId: s.userId,
name: s.fullName,
email: s.email ?? '',
phone: s.phone,
status: domain.StaffStatus.completedProfile,
address: s.addres,
avatar: s.photoUrl,
),
),
);
}
}
// Load Client Session if applicable
if (role == 'BUSINESS' || role == 'BOTH') {
final response = await connector
.getBusinessesByUserId(userId: userId)
.execute();
if (response.data.businesses.isNotEmpty) {
final b = response.data.businesses.first;
dc.ClientSessionStore.instance.setSession(
dc.ClientSession(
business: dc.ClientBusinessSession(
id: b.id,
businessName: b.businessName,
email: b.email,
city: b.city,
contactName: b.contactName,
companyLogoUrl: b.companyLogoUrl,
),
),
);
}
}
} catch (e) {
debugPrint('DataConnectService: Error loading session for $userId: $e');
}
}
/// Converts a Data Connect [Timestamp] to a Dart [DateTime] in local time.
///
/// Firebase Data Connect always stores and returns timestamps in UTC.
/// Calling [toLocal] ensures the result reflects the device's timezone so
/// that shift dates, start/end times, and formatted strings are correct for
/// the end user.
DateTime? toDateTime(dynamic timestamp) {
if (timestamp == null) return null;
if (timestamp is fdc.Timestamp) {
return timestamp.toDateTime().toLocal();
}
return null;
}
/// Converts a Dart [DateTime] to a Data Connect [Timestamp].
///
/// Converts the [DateTime] to UTC before creating the [Timestamp].
fdc.Timestamp toTimestamp(DateTime dateTime) {
final DateTime utc = dateTime.toUtc();
final int millis = utc.millisecondsSinceEpoch;
final int seconds = millis ~/ 1000;
final int nanos = (millis % 1000) * 1000000;
return fdc.Timestamp(nanos, seconds);
}
/// Converts a nullable Dart [DateTime] to a nullable Data Connect [Timestamp].
fdc.Timestamp? tryToTimestamp(DateTime? dateTime) {
if (dateTime == null) return null;
return toTimestamp(dateTime);
}
/// Executes an operation with centralized error handling.
Future<T> run<T>(
Future<T> Function() operation, {
bool requiresAuthentication = true,
}) async {
if (requiresAuthentication) {
await ensureSessionValid();
}
return executeProtected(operation);
}
/// Implementation for SessionHandlerMixin.
@override
Future<String?> fetchUserRole(String userId) async {
try {
final response = await connector.getUserById(id: userId).execute();
return response.data.user?.userRole;
} catch (e) {
return null;
}
}
/// Signs out the current user from Firebase Auth and clears all session data.
Future<void> signOut() async {
try {
await auth.signOut();
_clearCache();
} catch (e) {
debugPrint('DataConnectService: Error signing out: $e');
rethrow;
}
}
/// Clears Cached Repositories and Session data.
void _clearCache() {
_reportsRepository = null;
_shiftsRepository = null;
_hubsRepository = null;
_billingRepository = null;
_coverageRepository = null;
_staffRepository = null;
dc.StaffSessionStore.instance.clear();
dc.ClientSessionStore.instance.clear();
}
}

View File

@@ -1,86 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
/// Mixin to handle Data Layer errors and map them to Domain Failures.
///
/// Use this in Repositories to wrap remote calls.
/// It catches [SocketException], [FirebaseException], etc., and throws [AppException].
mixin DataErrorHandler {
/// Executes a Future and maps low-level exceptions to [AppException].
///
/// [timeout] defaults to 30 seconds.
Future<T> executeProtected<T>(
Future<T> Function() action, {
Duration timeout = const Duration(seconds: 30),
}) async {
try {
return await action().timeout(timeout);
} on TimeoutException {
debugPrint(
'DataErrorHandler: Request timed out after ${timeout.inSeconds}s',
);
throw ServiceUnavailableException(
technicalMessage: 'Request timed out after ${timeout.inSeconds}s',
);
} on SocketException catch (e) {
throw NetworkException(technicalMessage: 'SocketException: ${e.message}');
} on FirebaseException catch (e) {
final String code = e.code.toLowerCase();
final String msg = (e.message ?? '').toLowerCase();
if (code == 'unavailable' ||
code == 'network-request-failed' ||
msg.contains('offline') ||
msg.contains('network') ||
msg.contains('connection failed')) {
debugPrint(
'DataErrorHandler: Firebase network error: ${e.code} - ${e.message}',
);
throw NetworkException(
technicalMessage: 'Firebase ${e.code}: ${e.message}',
);
}
if (code == 'deadline-exceeded') {
debugPrint(
'DataErrorHandler: Firebase timeout error: ${e.code} - ${e.message}',
);
throw ServiceUnavailableException(
technicalMessage: 'Firebase ${e.code}: ${e.message}',
);
}
debugPrint('DataErrorHandler: Firebase error: ${e.code} - ${e.message}');
// Fallback for other Firebase errors
throw ServerException(
technicalMessage: 'Firebase ${e.code}: ${e.message}',
);
} catch (e) {
final String errorStr = e.toString().toLowerCase();
if (errorStr.contains('socketexception') ||
errorStr.contains('network') ||
errorStr.contains('offline') ||
errorStr.contains('connection failed') ||
errorStr.contains('unavailable') ||
errorStr.contains('handshake') ||
errorStr.contains('clientexception') ||
errorStr.contains('failed host lookup') ||
errorStr.contains('connection error') ||
errorStr.contains('grpc error') ||
errorStr.contains('terminated') ||
errorStr.contains('connectexception')) {
debugPrint('DataErrorHandler: Network-related error: $e');
throw NetworkException(technicalMessage: e.toString());
}
// If it's already an AppException, rethrow it
if (e is AppException) rethrow;
// Debugging: Log unexpected errors
debugPrint('DataErrorHandler: Unhandled exception caught: $e');
throw UnknownException(technicalMessage: e.toString());
}
}
}

View File

@@ -1,41 +0,0 @@
class ClientBusinessSession {
const ClientBusinessSession({
required this.id,
required this.businessName,
this.email,
this.city,
this.contactName,
this.companyLogoUrl,
});
final String id;
final String businessName;
final String? email;
final String? city;
final String? contactName;
final String? companyLogoUrl;
}
class ClientSession {
const ClientSession({required this.business});
final ClientBusinessSession? business;
}
class ClientSessionStore {
ClientSessionStore._();
ClientSession? _session;
ClientSession? get session => _session;
void setSession(ClientSession session) {
_session = session;
}
void clear() {
_session = null;
}
static final ClientSessionStore instance = ClientSessionStore._();
}

View File

@@ -1,25 +0,0 @@
import 'package:krow_domain/krow_domain.dart' as domain;
class StaffSession {
const StaffSession({this.staff, this.ownerId});
final domain.Staff? staff;
final String? ownerId;
}
class StaffSessionStore {
StaffSessionStore._();
StaffSession? _session;
StaffSession? get session => _session;
void setSession(StaffSession session) {
_session = session;
}
void clear() {
_session = null;
}
static final StaffSessionStore instance = StaffSessionStore._();
}

View File

@@ -1,21 +0,0 @@
name: krow_data_connect
description: Firebase Data Connect access layer.
version: 0.0.1
publish_to: none
resolution: workspace
environment:
sdk: '>=3.10.0 <4.0.0'
flutter: ">=3.0.0"
dependencies:
flutter:
sdk: flutter
krow_domain:
path: ../domain
krow_core:
path: ../core
flutter_modular: ^6.3.0
firebase_data_connect: ^0.2.2+2
firebase_core: ^4.4.0
firebase_auth: ^6.1.4

View File

@@ -245,7 +245,7 @@ class UiColors {
static const Color buttonPrimaryStill = primary;
/// Primary button hover (#082EB2)
static const Color buttonPrimaryHover = Color(0xFF082EB2);
static const Color buttonPrimaryHover = Color.fromARGB(255, 8, 46, 178);
/// Primary button inactive (#F1F3F5)
static const Color buttonPrimaryInactive = secondary;

View File

@@ -368,7 +368,6 @@ class UiTypography {
fontWeight: FontWeight.w400,
fontSize: 12,
height: 1.5,
letterSpacing: -0.1,
color: UiColors.textPrimary,
);

View File

@@ -67,7 +67,10 @@ class UiNoticeBanner extends StatelessWidget {
color: backgroundColor ?? UiColors.primary.withValues(alpha: 0.08),
borderRadius: borderRadius ?? UiConstants.radiusLg,
),
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (leading != null) ...<Widget>[
@@ -76,21 +79,25 @@ class UiNoticeBanner extends StatelessWidget {
] else if (icon != null) ...<Widget>[
Icon(icon, color: iconColor ?? UiColors.primary, size: 24),
const SizedBox(width: UiConstants.space3),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
title,
style: UiTypography.body2b.copyWith(color: titleColor),
style: UiTypography.body2b.copyWith(
color: titleColor ?? UiColors.primary,
),
),
],
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (description != null) ...<Widget>[
const SizedBox(height: 2),
const SizedBox(height: UiConstants.space2),
Text(
description!,
style: UiTypography.body3r.copyWith(
color: descriptionColor,
color: descriptionColor ?? UiColors.primary,
),
),
],
@@ -100,7 +107,6 @@ class UiNoticeBanner extends StatelessWidget {
],
],
),
),
],
),
);

View File

@@ -6,7 +6,26 @@
/// Note: Repository Interfaces are now located in their respective Feature packages.
library;
// Enums (shared status/type enums aligned with V2 CHECK constraints)
export 'src/entities/enums/account_type.dart';
export 'src/entities/enums/application_status.dart';
export 'src/entities/enums/assignment_status.dart';
export 'src/entities/enums/attendance_status_type.dart';
export 'src/entities/enums/availability_status.dart';
export 'src/entities/enums/benefit_status.dart';
export 'src/entities/enums/business_status.dart';
export 'src/entities/enums/invoice_status.dart';
export 'src/entities/enums/onboarding_status.dart';
export 'src/entities/enums/order_type.dart';
export 'src/entities/enums/payment_status.dart';
export 'src/entities/enums/shift_status.dart';
export 'src/entities/enums/staff_industry.dart';
export 'src/entities/enums/staff_skill.dart';
export 'src/entities/enums/staff_status.dart';
export 'src/entities/enums/user_role.dart';
// Core
export 'src/core/services/api_services/api_endpoint.dart';
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';
@@ -22,124 +41,90 @@ export 'src/core/models/device_location.dart';
// Users & Membership
export 'src/entities/users/user.dart';
export 'src/entities/users/staff.dart';
export 'src/entities/users/membership.dart';
export 'src/entities/users/biz_member.dart';
export 'src/entities/users/hub_member.dart';
export 'src/entities/users/staff_session.dart';
export 'src/entities/users/client_session.dart';
// Business & Organization
export 'src/entities/business/business.dart';
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';
export 'src/entities/events/event_shift.dart';
export 'src/entities/events/event_shift_position.dart';
export 'src/entities/events/assignment.dart';
export 'src/entities/events/work_session.dart';
export 'src/entities/business/vendor_role.dart';
export 'src/entities/business/hub_manager.dart';
export 'src/entities/business/team_member.dart';
// Shifts
export 'src/entities/shifts/shift.dart';
export 'src/adapters/shifts/shift_adapter.dart';
export 'src/entities/shifts/break/break.dart';
export 'src/adapters/shifts/break/break_adapter.dart';
export 'src/entities/shifts/today_shift.dart';
export 'src/entities/shifts/assigned_shift.dart';
export 'src/entities/shifts/open_shift.dart';
export 'src/entities/shifts/pending_assignment.dart';
export 'src/entities/shifts/cancelled_shift.dart';
export 'src/entities/shifts/completed_shift.dart';
export 'src/entities/shifts/shift_detail.dart';
// Orders & Requests
export 'src/entities/orders/one_time_order.dart';
export 'src/entities/orders/one_time_order_position.dart';
export 'src/entities/orders/recurring_order.dart';
export 'src/entities/orders/recurring_order_position.dart';
export 'src/entities/orders/permanent_order.dart';
export 'src/entities/orders/permanent_order_position.dart';
export 'src/entities/orders/order_type.dart';
// Orders
export 'src/entities/orders/order_item.dart';
export 'src/entities/orders/reorder_data.dart';
// Skills & Certs
export 'src/entities/skills/skill.dart';
export 'src/entities/skills/skill_category.dart';
export 'src/entities/skills/staff_skill.dart';
export 'src/entities/skills/certificate.dart';
export 'src/entities/skills/skill_kit.dart';
export 'src/entities/orders/assigned_worker_summary.dart';
export 'src/entities/orders/order_preview.dart';
export 'src/entities/orders/recent_order.dart';
// Financial & Payroll
export 'src/entities/benefits/benefit.dart';
export 'src/entities/financial/invoice.dart';
export 'src/entities/financial/time_card.dart';
export 'src/entities/financial/invoice_item.dart';
export 'src/entities/financial/invoice_decline.dart';
export 'src/entities/financial/staff_payment.dart';
export 'src/entities/financial/billing_account.dart';
export 'src/entities/financial/current_bill.dart';
export 'src/entities/financial/savings.dart';
export 'src/entities/financial/spend_item.dart';
export 'src/entities/financial/bank_account.dart';
export 'src/entities/financial/payment_summary.dart';
export 'src/entities/financial/billing_period.dart';
export 'src/entities/financial/bank_account/bank_account.dart';
export 'src/entities/financial/bank_account/business_bank_account.dart';
export 'src/entities/financial/bank_account/staff_bank_account.dart';
export 'src/adapters/financial/bank_account/bank_account_adapter.dart';
export 'src/entities/financial/staff_payment.dart';
export 'src/entities/financial/payment_chart_point.dart';
export 'src/entities/financial/time_card.dart';
// Profile
export 'src/entities/profile/staff_document.dart';
export 'src/entities/profile/document_verification_status.dart';
export 'src/entities/profile/staff_certificate.dart';
export 'src/entities/profile/compliance_type.dart';
export 'src/entities/profile/staff_certificate_status.dart';
export 'src/entities/profile/staff_certificate_validation_status.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';
// Ratings & Penalties
export 'src/entities/ratings/staff_rating.dart';
export 'src/entities/ratings/penalty_log.dart';
export 'src/entities/ratings/business_staff_preference.dart';
// Staff Profile
export 'src/entities/profile/staff_personal_info.dart';
export 'src/entities/profile/profile_section_status.dart';
export 'src/entities/profile/profile_completion.dart';
export 'src/entities/profile/profile_document.dart';
export 'src/entities/profile/certificate.dart';
export 'src/entities/profile/emergency_contact.dart';
export 'src/entities/profile/tax_form.dart';
export 'src/entities/profile/privacy_settings.dart';
export 'src/entities/profile/attire_checklist.dart';
export 'src/entities/profile/accessibility.dart';
export 'src/entities/profile/schedule.dart';
// Support & Config
export 'src/entities/support/addon.dart';
export 'src/entities/support/tag.dart';
export 'src/entities/support/media.dart';
export 'src/entities/support/working_area.dart';
// Ratings
export 'src/entities/ratings/staff_rating.dart';
// Home
export 'src/entities/home/home_dashboard_data.dart';
export 'src/entities/home/reorder_item.dart';
export 'src/entities/home/client_dashboard.dart';
export 'src/entities/home/spending_summary.dart';
export 'src/entities/home/coverage_metrics.dart';
export 'src/entities/home/live_activity_metrics.dart';
export 'src/entities/home/staff_dashboard.dart';
// Availability
export 'src/adapters/availability/availability_adapter.dart';
// Clock-In & Availability
export 'src/entities/clock_in/attendance_status.dart';
export 'src/adapters/clock_in/clock_in_adapter.dart';
export 'src/entities/availability/availability_slot.dart';
export 'src/entities/availability/day_availability.dart';
export 'src/entities/availability/availability_day.dart';
export 'src/entities/availability/time_slot.dart';
// Coverage
export 'src/entities/coverage_domain/coverage_shift.dart';
export 'src/entities/coverage_domain/coverage_worker.dart';
export 'src/entities/coverage_domain/shift_with_workers.dart';
export 'src/entities/coverage_domain/assigned_worker.dart';
export 'src/entities/coverage_domain/time_range.dart';
export 'src/entities/coverage_domain/coverage_stats.dart';
export 'src/entities/coverage_domain/core_team_member.dart';
// Adapters
export 'src/adapters/profile/emergency_contact_adapter.dart';
export 'src/adapters/profile/experience_adapter.dart';
export 'src/entities/profile/experience_skill.dart';
export 'src/adapters/profile/bank_account_adapter.dart';
export 'src/adapters/profile/tax_form_adapter.dart';
export 'src/adapters/financial/payment_adapter.dart';
// Reports
export 'src/entities/reports/report_summary.dart';
export 'src/entities/reports/daily_ops_report.dart';
export 'src/entities/reports/spend_data_point.dart';
export 'src/entities/reports/coverage_report.dart';
export 'src/entities/reports/forecast_report.dart';
export 'src/entities/reports/performance_report.dart';
export 'src/entities/reports/no_show_report.dart';
// Exceptions
export 'src/exceptions/app_exception.dart';
// Reports
export 'src/entities/reports/daily_ops_report.dart';
export 'src/entities/reports/spend_report.dart';
export 'src/entities/reports/coverage_report.dart';
export 'src/entities/reports/forecast_report.dart';
export 'src/entities/reports/no_show_report.dart';
export 'src/entities/reports/performance_report.dart';
export 'src/entities/reports/reports_summary.dart';

View File

@@ -1,33 +0,0 @@
import '../../entities/availability/availability_slot.dart';
/// Adapter for [AvailabilitySlot] domain entity.
class AvailabilityAdapter {
static const Map<String, Map<String, String>> _slotDefinitions = <String, Map<String, String>>{
'MORNING': <String, String>{
'id': 'morning',
'label': 'Morning',
'timeRange': '4:00 AM - 12:00 PM',
},
'AFTERNOON': <String, String>{
'id': 'afternoon',
'label': 'Afternoon',
'timeRange': '12:00 PM - 6:00 PM',
},
'EVENING': <String, String>{
'id': 'evening',
'label': 'Evening',
'timeRange': '6:00 PM - 12:00 AM',
},
};
/// Converts a backend slot name (e.g. 'MORNING') to a Domain [AvailabilitySlot].
static AvailabilitySlot fromPrimitive(String slotName, {bool isAvailable = false}) {
final Map<String, String> def = _slotDefinitions[slotName.toUpperCase()] ?? _slotDefinitions['MORNING']!;
return AvailabilitySlot(
id: def['id']!,
label: def['label']!,
timeRange: def['timeRange']!,
isAvailable: isAvailable,
);
}
}

View File

@@ -1,27 +0,0 @@
import '../../entities/clock_in/attendance_status.dart';
/// Adapter for Clock In related data.
class ClockInAdapter {
/// Converts primitive attendance data to [AttendanceStatus].
static AttendanceStatus toAttendanceStatus({
required String status,
DateTime? checkInTime,
DateTime? checkOutTime,
String? activeShiftId,
String? activeApplicationId,
}) {
final bool isCheckedIn = status == 'CHECKED_IN' || status == 'LATE'; // Assuming LATE is also checked in?
// Statuses that imply active attendance: CHECKED_IN, LATE.
// Statuses that imply completed: CHECKED_OUT.
return AttendanceStatus(
isCheckedIn: isCheckedIn,
checkInTime: checkInTime,
checkOutTime: checkOutTime,
activeShiftId: activeShiftId,
activeApplicationId: activeApplicationId,
);
}
}

View File

@@ -1,21 +0,0 @@
import '../../../entities/financial/bank_account/business_bank_account.dart';
/// Adapter for [BusinessBankAccount] to map data layer values to domain entity.
class BusinessBankAccountAdapter {
/// Maps primitive values to [BusinessBankAccount].
static BusinessBankAccount fromPrimitives({
required String id,
required String bank,
required String last4,
required bool isPrimary,
DateTime? expiryTime,
}) {
return BusinessBankAccount(
id: id,
bankName: bank,
last4: last4,
isPrimary: isPrimary,
expiryTime: expiryTime,
);
}
}

View File

@@ -1,19 +0,0 @@
import '../../entities/financial/staff_payment.dart';
/// Adapter for Payment related data.
class PaymentAdapter {
/// Converts string status to [PaymentStatus].
static PaymentStatus toPaymentStatus(String status) {
switch (status) {
case 'PAID':
return PaymentStatus.paid;
case 'PENDING':
return PaymentStatus.pending;
case 'FAILED':
return PaymentStatus.failed;
default:
return PaymentStatus.unknown;
}
}
}

View File

@@ -1,49 +0,0 @@
import '../../entities/financial/time_card.dart';
/// Adapter for [TimeCard] to map data layer values to domain entity.
class TimeCardAdapter {
/// Maps primitive values to [TimeCard].
static TimeCard fromPrimitives({
required String id,
required String shiftTitle,
required String clientName,
required DateTime date,
required String startTime,
required String endTime,
required double totalHours,
required double hourlyRate,
required double totalPay,
required String status,
String? location,
}) {
return TimeCard(
id: id,
shiftTitle: shiftTitle,
clientName: clientName,
date: date,
startTime: startTime,
endTime: endTime,
totalHours: totalHours,
hourlyRate: hourlyRate,
totalPay: totalPay,
status: _stringToStatus(status),
location: location,
);
}
static TimeCardStatus _stringToStatus(String status) {
switch (status.toUpperCase()) {
case 'CHECKED_OUT':
case 'COMPLETED':
return TimeCardStatus.approved; // Assuming completed = approved for now
case 'PAID':
return TimeCardStatus.paid; // If this status exists
case 'DISPUTED':
return TimeCardStatus.disputed;
case 'CHECKED_IN':
case 'CONFIRMED':
default:
return TimeCardStatus.pending;
}
}
}

View File

@@ -1,53 +0,0 @@
import '../../entities/financial/bank_account/staff_bank_account.dart';
/// Adapter for [StaffBankAccount] to map data layer values to domain entity.
class BankAccountAdapter {
/// Maps primitive values to [StaffBankAccount].
static StaffBankAccount fromPrimitives({
required String id,
required String userId,
required String bankName,
required String? type,
String? accountNumber,
String? last4,
String? sortCode,
bool? isPrimary,
}) {
return StaffBankAccount(
id: id,
userId: userId,
bankName: bankName,
accountNumber: accountNumber ?? '',
accountName: '', // Not provided by backend
last4: last4,
sortCode: sortCode,
type: _stringToType(type),
isPrimary: isPrimary ?? false,
);
}
static StaffBankAccountType _stringToType(String? value) {
if (value == null) return StaffBankAccountType.checking;
try {
// Assuming backend enum names match or are uppercase
return StaffBankAccountType.values.firstWhere(
(StaffBankAccountType e) => e.name.toLowerCase() == value.toLowerCase(),
orElse: () => StaffBankAccountType.other,
);
} catch (_) {
return StaffBankAccountType.other;
}
}
/// Converts domain type to string for backend.
static String typeToString(StaffBankAccountType type) {
switch (type) {
case StaffBankAccountType.checking:
return 'CHECKING';
case StaffBankAccountType.savings:
return 'SAVINGS';
default:
return 'CHECKING';
}
}
}

View File

@@ -1,19 +0,0 @@
import '../../entities/profile/emergency_contact.dart';
/// Adapter for [EmergencyContact] to map data layer values to domain entity.
class EmergencyContactAdapter {
/// Maps primitive values to [EmergencyContact].
static EmergencyContact fromPrimitives({
required String id,
required String name,
required String phone,
String? relationship,
}) {
return EmergencyContact(
id: id,
name: name,
phone: phone,
relationship: EmergencyContact.stringToRelationshipType(relationship),
);
}
}

View File

@@ -1,18 +0,0 @@
/// Adapter for Experience data (skills/industries) to map data layer values to domain models.
class ExperienceAdapter {
/// Converts a dynamic list (from backend AnyValue) to List<String>.
///
/// Handles nulls and converts elements to Strings.
static List<String> fromDynamicList(dynamic data) {
if (data == null) return <String>[];
if (data is List) {
return data
.where((dynamic e) => e != null)
.map((dynamic e) => e.toString())
.toList();
}
return <String>[];
}
}

View File

@@ -1,104 +0,0 @@
import '../../entities/profile/tax_form.dart';
/// Adapter for [TaxForm] to map data layer values to domain entity.
class TaxFormAdapter {
/// Maps primitive values to [TaxForm].
static TaxForm fromPrimitives({
required String id,
required String type,
required String title,
String? subtitle,
String? description,
required String status,
String? staffId,
dynamic formData,
DateTime? createdAt,
DateTime? updatedAt,
}) {
final TaxFormType formType = _stringToType(type);
final TaxFormStatus formStatus = _stringToStatus(status);
final Map<String, dynamic> formDetails =
formData is Map ? Map<String, dynamic>.from(formData) : <String, dynamic>{};
if (formType == TaxFormType.i9) {
return I9TaxForm(
id: id,
title: title,
subtitle: subtitle,
description: description,
status: formStatus,
staffId: staffId,
formData: formDetails,
createdAt: createdAt,
updatedAt: updatedAt,
);
} else {
return W4TaxForm(
id: id,
title: title,
subtitle: subtitle,
description: description,
status: formStatus,
staffId: staffId,
formData: formDetails,
createdAt: createdAt,
updatedAt: updatedAt,
);
}
}
static TaxFormType _stringToType(String? value) {
if (value == null) return TaxFormType.i9;
try {
return TaxFormType.values.firstWhere(
(TaxFormType e) => e.name.toLowerCase() == value.toLowerCase(),
orElse: () => TaxFormType.i9,
);
} catch (_) {
return TaxFormType.i9;
}
}
static TaxFormStatus _stringToStatus(String? value) {
if (value == null) return TaxFormStatus.notStarted;
try {
final String normalizedValue = value.replaceAll('_', '').toLowerCase();
// map DRAFT to inProgress
if (normalizedValue == 'draft') return TaxFormStatus.inProgress;
return TaxFormStatus.values.firstWhere(
(TaxFormStatus e) {
// Handle differences like not_started vs notStarted if any,
// but standardizing to lowercase is a good start.
// The enum names are camelCase in Dart, but might be SNAKE_CASE from backend.
final String normalizedEnum = e.name.toLowerCase();
return normalizedValue == normalizedEnum;
},
orElse: () => TaxFormStatus.notStarted,
);
} catch (_) {
return TaxFormStatus.notStarted;
}
}
/// Converts domain [TaxFormType] to string for backend.
static String typeToString(TaxFormType type) {
return type.name.toUpperCase();
}
/// Converts domain [TaxFormStatus] to string for backend.
static String statusToString(TaxFormStatus status) {
switch (status) {
case TaxFormStatus.notStarted:
return 'NOT_STARTED';
case TaxFormStatus.inProgress:
return 'DRAFT';
case TaxFormStatus.submitted:
return 'SUBMITTED';
case TaxFormStatus.approved:
return 'APPROVED';
case TaxFormStatus.rejected:
return 'REJECTED';
}
}
}

View File

@@ -1,39 +0,0 @@
import '../../../entities/shifts/break/break.dart';
/// Adapter for Break related data.
class BreakAdapter {
/// Maps break data to a Break entity.
///
/// [isPaid] whether the break is paid.
/// [breakTime] the string representation of the break duration (e.g., 'MIN_10', 'MIN_30').
static Break fromData({
required bool isPaid,
required String? breakTime,
}) {
return Break(
isBreakPaid: isPaid,
duration: _parseDuration(breakTime),
);
}
static BreakDuration _parseDuration(String? breakTime) {
if (breakTime == null) return BreakDuration.none;
switch (breakTime.toUpperCase()) {
case 'MIN_10':
return BreakDuration.ten;
case 'MIN_15':
return BreakDuration.fifteen;
case 'MIN_20':
return BreakDuration.twenty;
case 'MIN_30':
return BreakDuration.thirty;
case 'MIN_45':
return BreakDuration.fortyFive;
case 'MIN_60':
return BreakDuration.sixty;
default:
return BreakDuration.none;
}
}
}

View File

@@ -1,59 +0,0 @@
import 'package:intl/intl.dart';
import '../../entities/shifts/shift.dart';
/// Adapter for Shift related data.
class ShiftAdapter {
/// Maps application data to a Shift entity.
///
/// This method handles the common mapping logic used across different
/// repositories when converting application data from Data Connect to
/// domain Shift entities.
static Shift fromApplicationData({
required String shiftId,
required String roleId,
required String roleName,
required String businessName,
String? companyLogoUrl,
required double costPerHour,
String? shiftLocation,
required String teamHubName,
DateTime? shiftDate,
DateTime? startTime,
DateTime? endTime,
DateTime? createdAt,
required String status,
String? description,
int? durationDays,
required int count,
int? assigned,
String? eventName,
bool hasApplied = false,
}) {
final String orderName = (eventName ?? '').trim().isNotEmpty
? eventName!
: businessName;
final String title = '$roleName - $orderName';
return Shift(
id: shiftId,
roleId: roleId,
title: title,
clientName: businessName,
logoUrl: companyLogoUrl,
hourlyRate: costPerHour,
location: shiftLocation ?? '',
locationAddress: teamHubName,
date: shiftDate?.toIso8601String() ?? '',
startTime: startTime != null ? DateFormat('HH:mm').format(startTime) : '',
endTime: endTime != null ? DateFormat('HH:mm').format(endTime) : '',
createdDate: createdAt?.toIso8601String() ?? '',
status: status,
description: description,
durationDays: durationDays,
requiredSlots: count,
filledSlots: assigned ?? 0,
hasApplied: hasApplied,
);
}
}

View File

@@ -0,0 +1,16 @@
/// Represents an API endpoint with its path and required scopes for future
/// feature gating.
class ApiEndpoint {
/// Creates an [ApiEndpoint] with the given [path] and optional
/// [requiredScopes].
const ApiEndpoint(this.path, {this.requiredScopes = const <String>[]});
/// The relative URL path (e.g. '/auth/client/sign-in').
final String path;
/// Scopes required to access this endpoint. Empty means no gate.
final List<String> requiredScopes;
@override
String toString() => path;
}

View File

@@ -8,7 +8,7 @@ class ApiResponse {
this.errors = const <String, dynamic>{},
});
/// The response code (e.g., '200', '404', or custom error code).
/// The response code (e.g., '200', '404', or V2 error code like 'VALIDATION_ERROR').
final String code;
/// A descriptive message about the response.
@@ -19,4 +19,13 @@ class ApiResponse {
/// A map of field-specific error messages, if any.
final Map<String, dynamic> errors;
/// Whether the response indicates success (HTTP 2xx).
bool get isSuccess {
final int? statusCode = int.tryParse(code);
return statusCode != null && statusCode >= 200 && statusCode < 300;
}
/// Whether the response indicates failure.
bool get isFailure => !isSuccess;
}

View File

@@ -1,29 +1,38 @@
import 'api_endpoint.dart';
import 'api_response.dart';
/// Abstract base class for API services.
///
/// This defines the contract for making HTTP requests.
/// Methods accept [ApiEndpoint] which carries the path and required scopes.
/// Implementations should validate scopes via [FeatureGate] before executing.
abstract class BaseApiService {
/// Performs a GET request to the specified [endpoint].
Future<ApiResponse> get(String endpoint, {Map<String, dynamic>? params});
Future<ApiResponse> get(ApiEndpoint endpoint, {Map<String, dynamic>? params});
/// Performs a POST request to the specified [endpoint].
Future<ApiResponse> post(
String endpoint, {
ApiEndpoint endpoint, {
dynamic data,
Map<String, dynamic>? params,
});
/// Performs a PUT request to the specified [endpoint].
Future<ApiResponse> put(
String endpoint, {
ApiEndpoint endpoint, {
dynamic data,
Map<String, dynamic>? params,
});
/// Performs a PATCH request to the specified [endpoint].
Future<ApiResponse> patch(
String endpoint, {
ApiEndpoint endpoint, {
dynamic data,
Map<String, dynamic>? params,
});
/// Performs a DELETE request to the specified [endpoint].
Future<ApiResponse> delete(
ApiEndpoint endpoint, {
dynamic data,
Map<String, dynamic>? params,
});

View File

@@ -1,3 +1,4 @@
import '../../../exceptions/app_exception.dart';
import 'api_response.dart';
import 'base_api_service.dart';
@@ -14,10 +15,13 @@ abstract class BaseCoreService {
/// Standardized wrapper to execute API actions.
///
/// This handles generic error normalization for unexpected non-HTTP errors.
/// Rethrows [AppException] subclasses (domain errors) directly.
/// Wraps unexpected non-HTTP errors into an error [ApiResponse].
Future<ApiResponse> action(Future<ApiResponse> Function() execution) async {
try {
return await execution();
} on AppException {
rethrow;
} catch (e) {
return ApiResponse(
code: 'CORE_INTERNAL_ERROR',

View File

@@ -0,0 +1,86 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/src/entities/availability/time_slot.dart';
import 'package:krow_domain/src/entities/enums/availability_status.dart';
/// Availability for a single calendar date.
///
/// Returned by `GET /staff/availability`. The backend generates one entry
/// per date in the requested range by projecting the staff member's
/// recurring weekly availability pattern.
class AvailabilityDay extends Equatable {
/// Creates an [AvailabilityDay].
const AvailabilityDay({
required this.date,
required this.dayOfWeek,
required this.availabilityStatus,
this.slots = const <TimeSlot>[],
});
/// Deserialises from the V2 API JSON response.
factory AvailabilityDay.fromJson(Map<String, dynamic> json) {
final dynamic rawSlots = json['slots'];
final List<TimeSlot> parsedSlots = rawSlots is List<dynamic>
? rawSlots
.map((dynamic e) =>
TimeSlot.fromJson(e as Map<String, dynamic>))
.toList()
: <TimeSlot>[];
return AvailabilityDay(
date: json['date'] as String,
dayOfWeek: json['dayOfWeek'] as int,
availabilityStatus:
AvailabilityStatus.fromJson(json['availabilityStatus'] as String?),
slots: parsedSlots,
);
}
/// ISO date string (`YYYY-MM-DD`).
final String date;
/// Day of week (0 = Sunday, 6 = Saturday).
final int dayOfWeek;
/// Availability status for this day.
final AvailabilityStatus availabilityStatus;
/// Time slots when the worker is available (relevant for `PARTIAL`).
final List<TimeSlot> slots;
/// Whether the worker has any availability on this day.
bool get isAvailable => availabilityStatus != AvailabilityStatus.unavailable;
/// Creates a copy with the given fields replaced.
AvailabilityDay copyWith({
String? date,
int? dayOfWeek,
AvailabilityStatus? availabilityStatus,
List<TimeSlot>? slots,
}) {
return AvailabilityDay(
date: date ?? this.date,
dayOfWeek: dayOfWeek ?? this.dayOfWeek,
availabilityStatus: availabilityStatus ?? this.availabilityStatus,
slots: slots ?? this.slots,
);
}
/// Serialises to JSON.
Map<String, dynamic> toJson() {
return <String, dynamic>{
'date': date,
'dayOfWeek': dayOfWeek,
'availabilityStatus': availabilityStatus.toJson(),
'slots': slots.map((TimeSlot s) => s.toJson()).toList(),
};
}
@override
List<Object?> get props => <Object?>[
date,
dayOfWeek,
availabilityStatus,
slots,
];
}

View File

@@ -1,33 +0,0 @@
import 'package:equatable/equatable.dart';
/// Represents a specific time slot within a day (e.g., Morning, Afternoon, Evening).
class AvailabilitySlot extends Equatable {
const AvailabilitySlot({
required this.id,
required this.label,
required this.timeRange,
this.isAvailable = true,
});
final String id;
final String label;
final String timeRange;
final bool isAvailable;
AvailabilitySlot copyWith({
String? id,
String? label,
String? timeRange,
bool? isAvailable,
}) {
return AvailabilitySlot(
id: id ?? this.id,
label: label ?? this.label,
timeRange: timeRange ?? this.timeRange,
isAvailable: isAvailable ?? this.isAvailable,
);
}
@override
List<Object?> get props => <Object?>[id, label, timeRange, isAvailable];
}

View File

@@ -1,31 +0,0 @@
import 'package:equatable/equatable.dart';
import 'availability_slot.dart';
/// Represents availability configuration for a specific date.
class DayAvailability extends Equatable {
const DayAvailability({
required this.date,
this.isAvailable = false,
this.slots = const <AvailabilitySlot>[],
});
final DateTime date;
final bool isAvailable;
final List<AvailabilitySlot> slots;
DayAvailability copyWith({
DateTime? date,
bool? isAvailable,
List<AvailabilitySlot>? slots,
}) {
return DayAvailability(
date: date ?? this.date,
isAvailable: isAvailable ?? this.isAvailable,
slots: slots ?? this.slots,
);
}
@override
List<Object?> get props => <Object?>[date, isAvailable, slots];
}

View File

@@ -0,0 +1,45 @@
import 'package:equatable/equatable.dart';
/// A time range within a day of availability.
///
/// Embedded inside [AvailabilityDay.slots]. Times are stored as `HH:MM`
/// strings because the backend stores them in a JSONB array and they
/// are timezone-agnostic display values.
class TimeSlot extends Equatable {
/// Creates a [TimeSlot].
const TimeSlot({
required this.startTime,
required this.endTime,
});
/// Deserialises from a JSON map inside the availability slots array.
///
/// Supports both V2 API keys (`start`/`end`) and legacy keys
/// (`startTime`/`endTime`).
factory TimeSlot.fromJson(Map<String, dynamic> json) {
return TimeSlot(
startTime: json['start'] as String? ??
json['startTime'] as String? ??
'00:00',
endTime:
json['end'] as String? ?? json['endTime'] as String? ?? '00:00',
);
}
/// Start time in `HH:MM` format.
final String startTime;
/// End time in `HH:MM` format.
final String endTime;
/// Serialises to JSON matching the V2 API contract.
Map<String, dynamic> toJson() {
return <String, dynamic>{
'start': startTime,
'end': endTime,
};
}
@override
List<Object?> get props => <Object?>[startTime, endTime];
}

View File

@@ -1,26 +1,73 @@
import 'package:equatable/equatable.dart';
/// Represents a staff member's benefit balance.
import 'package:krow_domain/src/entities/enums/benefit_status.dart';
/// A benefit accrued by a staff member (e.g. sick leave, vacation).
///
/// Returned by `GET /staff/profile/benefits`.
class Benefit extends Equatable {
/// Creates a [Benefit].
/// Creates a [Benefit] instance.
const Benefit({
required this.benefitId,
required this.benefitType,
required this.title,
required this.entitlementHours,
required this.usedHours,
required this.status,
required this.trackedHours,
required this.targetHours,
});
/// The title of the benefit (e.g., Sick Leave, Holiday, Vacation).
/// Deserialises a [Benefit] from a V2 API JSON map.
factory Benefit.fromJson(Map<String, dynamic> json) {
return Benefit(
benefitId: json['benefitId'] as String,
benefitType: json['benefitType'] as String,
title: json['title'] as String,
status: BenefitStatus.fromJson(json['status'] as String?),
trackedHours: (json['trackedHours'] as num).toInt(),
targetHours: (json['targetHours'] as num).toInt(),
);
}
/// Unique identifier.
final String benefitId;
/// Type code (e.g. SICK_LEAVE, VACATION).
final String benefitType;
/// Human-readable title.
final String title;
/// The total entitlement in hours.
final double entitlementHours;
/// Current benefit status.
final BenefitStatus status;
/// The hours used so far.
final double usedHours;
/// Hours tracked so far.
final int trackedHours;
/// The hours remaining.
double get remainingHours => entitlementHours - usedHours;
/// Target hours to accrue.
final int targetHours;
/// Remaining hours to reach the target.
int get remainingHours => targetHours - trackedHours;
/// Serialises this [Benefit] to a JSON map.
Map<String, dynamic> toJson() {
return <String, dynamic>{
'benefitId': benefitId,
'benefitType': benefitType,
'title': title,
'status': status.toJson(),
'trackedHours': trackedHours,
'targetHours': targetHours,
};
}
@override
List<Object?> get props => [title, entitlementHours, usedHours];
List<Object?> get props => <Object?>[
benefitId,
benefitType,
title,
status,
trackedHours,
targetHours,
];
}

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