Merge pull request #663 from Oloodi/592-migrate-frontend-applications-to-v2-backend-and-database

feat(mobile): V2 API migration, coverage UI, worker review, and architecture compliance
This commit is contained in:
Achintha Isuru
2026-03-19 01:21:45 -04:00
committed by GitHub
792 changed files with 25078 additions and 26054 deletions

View File

@@ -18,12 +18,56 @@
- `firebase_data_connect` and `firebase_auth` are listed as direct dependencies in `client_create_order/pubspec.yaml` (should only be in `data_connect` package)
- All 3 order pages use `Modular.to.pop()` instead of `Modular.to.popSafe()` for the back button
## Known Staff App Issues (full scan 2026-03-19)
- [recurring_violations.md](recurring_violations.md) - Detailed violation patterns
### Critical
- ProfileCubit calls repository directly (no use cases, no interface)
- BenefitsOverviewCubit calls repository.getDashboard() directly (bypasses use case)
- StaffMainCubit missing BlocErrorHandler mixin
- firebase_auth imported directly in auth feature repos (2 files)
### High (Widespread)
- 53 instances of `context.read<>()` without `ReadContext()` wrapper
- ~20 hardcoded Color(0x...) values in home/benefits widgets
- 5 custom TextStyle() in faqs_widget and tax_forms
- 8 copyWith(fontSize:) overrides on UiTypography
- ~40 hardcoded SizedBox spacing values
- Hardcoded nav labels in staff_nav_items_config.dart
- Zero test files across entire staff feature tree
## Design System Tokens
- Colors: `UiColors.*`
- Typography: `UiTypography.*`
- Spacing: `UiConstants.space*` (e.g., `space3`, `space4`, `space6`)
- App bar: `UiAppBar`
## Known Client App Issues (full scan 2026-03-19)
### Critical
- Reports feature: All 7 report BLoCs call ReportsRepository directly (no use cases)
- OneTimeOrderBloc, PermanentOrderBloc, RecurringOrderBloc call _queryRepository directly for loading vendors/hubs/roles
- OneTimeOrderBloc._onSubmitted has payload building business logic (should be in use case)
- ClientMainCubit missing BlocErrorHandler mixin
- firebase_auth imported directly in authentication and settings feature repos (2 packages)
### High (Widespread)
- 17 hardcoded Color(0x...) across reports, coverage, billing, hubs
- 11 Material Colors.* usage (coverage, billing, reports)
- 66 standalone TextStyle() (almost all in reports feature)
- ~145 hardcoded EdgeInsets spacing values
- ~97 hardcoded SizedBox dimensions
- ~42 hardcoded BorderRadius.circular values
- 6 unsafe Modular.to.pop() calls (settings, hubs)
- BlocProvider(create:) used in no_show_report_page for Modular.get singleton
- Zero test files across entire client feature tree
- 2 hardcoded user-facing strings ("Export coming soon")
- 9 files with blanket ignore_for_file directives (reports feature)
### Naming Convention Violations
- CoverageRepository, BillingRepository, ReportsRepository missing "Interface" suffix
- IViewOrdersRepository uses "I" prefix instead of "Interface" suffix
## Review Patterns (grep-based checks)
- `Color(0x` for hardcoded colors
- `TextStyle(` for custom text styles

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

@@ -0,0 +1,3 @@
# Mobile Feature Builder Memory Index
- [firebase_auth_isolation.md](firebase_auth_isolation.md) - FirebaseAuthService in core abstracts all Firebase Auth operations; features must never import firebase_auth directly

View File

@@ -0,0 +1,15 @@
---
name: Firebase Auth Isolation Pattern
description: FirebaseAuthService in core/lib/src/services/auth/ abstracts all Firebase Auth SDK operations so feature packages never import firebase_auth directly
type: project
---
`FirebaseAuthService` (interface) and `FirebaseAuthServiceImpl` live in `core/lib/src/services/auth/firebase_auth_service.dart`.
Registered in `CoreModule` as `i.addLazySingleton<FirebaseAuthService>(FirebaseAuthServiceImpl.new)`.
Exported from `core.dart`.
**Why:** Architecture rule requires firebase_auth only in core. Features inject `FirebaseAuthService` via DI.
**How to apply:** Any new feature needing Firebase Auth operations (sign-in, sign-out, phone verification, get current user info) should depend on `FirebaseAuthService` from `krow_core`, not import `firebase_auth` directly. The service provides: `authStateChanges`, `currentUserPhoneNumber`, `currentUserUid`, `verifyPhoneNumber`, `signInWithPhoneCredential` (returns `PhoneSignInResult`), `signInWithEmailAndPassword`, `signOut`, `getIdToken`.

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

@@ -0,0 +1,7 @@
# UI/UX Design Agent Memory
## Index
- [design-system-tokens.md](design-system-tokens.md) — Verified token values from actual source files
- [component-patterns.md](component-patterns.md) — Established component patterns in KROW staff app
- [design-gaps.md](design-gaps.md) — Known design system gaps and escalation items

View File

@@ -0,0 +1,87 @@
---
name: KROW Staff App Component Patterns
description: Established UI patterns, widget conventions, and design decisions confirmed in the KROW staff app codebase
type: project
---
## Card Pattern (standard surface)
Cards use:
- `UiColors.cardViewBackground` (white) background
- `Border.all(color: UiColors.border)` outline
- `BorderRadius.circular(UiConstants.radiusBase)` = 12dp
- `EdgeInsets.all(UiConstants.space4)` = 16dp padding
Do NOT use `UiColors.bgSecondary` as card background — that is for toggles/headers inside cards.
## Section Toggle / Expand-Collapse Header
Used for collapsible sections inside cards:
- Background: `UiColors.bgSecondary`
- Radius: `UiConstants.radiusMd` (6dp)
- Height: minimum 48dp (touch target)
- Label: `UiTypography.titleUppercase3m.textSecondary` for ALL-CAPS labels
- Trailing: `UiIcons.chevronDown` animated 180° via `AnimatedRotation`, 200ms
- Ripple: `InkWell` with `borderRadius: UiConstants.radiusMd` and splash `UiColors.primary.withValues(alpha: 0.06)`
## Shimmer Loading Pattern
Use `UiShimmer` wrapper + `UiShimmerLine` / `UiShimmerBox` / `UiShimmerCircle` primitives.
- Base color: `UiColors.muted`
- Highlight: `UiColors.background`
- For list content: 3 shimmer rows by default
- Do NOT use fixed height containers for shimmer — let content flow
## Status Badge (read-only, non-interactive)
Custom `Container` with pill shape:
- `borderRadius: UiConstants.radiusFull`
- `padding: EdgeInsets.symmetric(horizontal: space2, vertical: 2)`
- Label style: `UiTypography.footnote2b`
- Do NOT use the interactive `UiChip` widget for read-only display
Status color mapping:
- ACTIVE: bg=`tagActive`, fg=`textSuccess`
- PENDING: bg=`tagPending`, fg=`textWarning`
- INACTIVE/ENDED: bg=`tagFreeze`, fg=`textSecondary`
- ERROR: bg=`tagError`, fg=`textError`
## Inline Error Banner (inside card)
NOT a full-page error — a compact container inside the widget:
- bg: `UiColors.tagError`
- radius: `UiConstants.radiusMd`
- Icon: `UiIcons.error` at `iconMd` (20dp), color: `UiColors.destructive`
- Title: `body2m.textError`
- Retry link: `body3r.primary` with `TextDecoration.underline`
## Inline Empty State (inside card)
NOT `UiEmptyState` widget (that is full-page). Use compact inline version:
- `Icon(UiIcons.clock, size: iconXl=32, color: UiColors.iconDisabled)`
- `body2r.textSecondary` label
- `EdgeInsets.symmetric(vertical: space6)` padding
## AnimatedSize for Expand/Collapse
```dart
AnimatedSize(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
child: isExpanded ? content : const SizedBox.shrink(),
)
```
## Benefits Feature Structure
Legacy benefits: `apps/mobile/legacy/legacy-staff-app/lib/features/profile/benefits/`
V2 domain entity: `apps/mobile/packages/domain/lib/src/entities/benefits/benefit.dart`
V2 history entity: needs creation at `packages/domain/lib/src/entities/benefits/benefit_history.dart`
Benefit history is lazy-loaded per card (not with the initial overview fetch).
History state is cached in BLoC as `Map<String, AsyncValue<List<BenefitHistory>>>` keyed by benefitId.
## Screen Page Pattern (overview pages)
Uses `CustomScrollView` with `SliverList` for header + `SliverPadding` wrapping `SliverList.separated` for content.
Bottom padding on content sliver: `EdgeInsets.fromLTRB(16, 16, 16, 120)` to clear bottom nav bar.

View File

@@ -0,0 +1,22 @@
---
name: KROW Design System Gaps and Escalations
description: Known missing tokens, open design questions, and items requiring escalation to PM or design system owner
type: project
---
## Open Escalations (as of 2026-03-18)
### 1. No Dark Theme Token Definitions
**Severity:** High
**Detail:** `ui_colors.dart` defines a single light `ColorScheme`. Tag colors (`tagActive`, `tagPending`, `tagFreeze`, `tagError`) have no dark mode equivalents. No dark theme has been configured in `UiTheme`.
**Action:** Escalate to design system owner before any dark mode work. Until resolved, do not attempt dark mode overrides in feature widgets.
### 2. V2 History API — trackedHours Sign Convention
**Severity:** Medium
**Detail:** `GET /staff/profile/benefits/history` returns `trackedHours` as a positive integer. There is no `transactionType` field to distinguish accruals from deductions (used hours). Design assumes accrual-only for now with `+` prefix in `UiColors.textSuccess`.
**Action:** Escalate to PM/backend. Recommend adding `transactionType: "ACCRUAL" | "USAGE"` or signed integer to distinguish visually.
### 3. Missing Localization Keys for Benefits History
**Severity:** Low (implementation blocker, not design blocker)
**Detail:** New keys under `benefits.history.*` need to be added to both `en.i18n.json` and `es.i18n.json` in `packages/core_localization/lib/src/l10n/`. Must be coordinated with Mobile Feature Agent who runs `dart run slang`.
**Action:** Hand off key list to Mobile Feature Agent.

View File

@@ -0,0 +1,102 @@
---
name: KROW Design System Token Reference
description: Verified token values from actual source files in apps/mobile/packages/design_system/lib/src/
type: reference
---
## Source Files (verified 2026-03-18)
- `ui_colors.dart` — all color tokens
- `ui_typography.dart` — all text styles (primary font: Instrument Sans, secondary: Space Grotesk)
- `ui_constants.dart` — spacing, radius, icon sizes
- `ui_icons.dart` — icon aliases over LucideIcons (primary) + FontAwesomeIcons (secondary)
## Key Color Tokens (hex values confirmed)
| Token | Hex | Use |
|-------|-----|-----|
| `UiColors.background` | `#FAFBFC` | Page background |
| `UiColors.cardViewBackground` | `#FFFFFF` | Card surface |
| `UiColors.bgSecondary` | `#F1F3F5` | Toggle/section headers |
| `UiColors.bgThird` | `#EDF0F2` | — |
| `UiColors.primary` | `#0A39DF` | Brand blue |
| `UiColors.textPrimary` | `#121826` | Main text |
| `UiColors.textSecondary` | `#6A7382` | Secondary/muted text |
| `UiColors.textInactive` | `#9CA3AF` | Disabled/placeholder |
| `UiColors.textSuccess` | `#0A8159` | Green text (darker than success icon) |
| `UiColors.textError` | `#F04444` | Red text |
| `UiColors.textWarning` | `#D97706` | Amber text |
| `UiColors.success` | `#10B981` | Green brand color |
| `UiColors.destructive` | `#F04444` | Red brand color |
| `UiColors.border` | `#D1D5DB` | Default border |
| `UiColors.separatorSecondary` | `#F1F5F9` | Light dividers |
| `UiColors.tagActive` | `#DCFCE7` | Active status badge bg |
| `UiColors.tagPending` | `#FEF3C7` | Pending badge bg |
| `UiColors.tagError` | `#FEE2E2` | Error banner bg |
| `UiColors.tagFreeze` | `#F3F4F6` | Ended/frozen badge bg |
| `UiColors.tagInProgress` | `#DBEAFE` | In-progress badge bg |
| `UiColors.iconDisabled` | `#D1D5DB` | Disabled icon color |
| `UiColors.muted` | `#F1F3F5` | Shimmer base color |
## Key Spacing Constants
| Token | Value |
|-------|-------|
| `space1` | 4dp |
| `space2` | 8dp |
| `space3` | 12dp |
| `space4` | 16dp |
| `space5` | 20dp |
| `space6` | 24dp |
| `space8` | 32dp |
| `space10` | 40dp |
| `space12` | 48dp |
## Key Radius Constants
| Token | Value |
|-------|-------|
| `radiusSm` | 4dp |
| `radiusMd` (radiusMdValue) | 6dp |
| `radiusBase` | 12dp |
| `radiusLg` | 12dp (BorderRadius.circular(12)) |
| `radiusXl` | 16dp |
| `radius2xl` | 24dp |
| `radiusFull` | 999dp |
NOTE: `radiusBase` is a `double` (12.0), `radiusLg` is a `BorderRadius`. Use `BorderRadius.circular(UiConstants.radiusBase)` when a double is needed.
## Icon Sizes
| Token | Value |
|-------|-------|
| `iconXs` | 12dp |
| `iconSm` | 16dp |
| `iconMd` | 20dp |
| `iconLg` | 24dp |
| `iconXl` | 32dp |
## Key Typography Styles (Instrument Sans)
| Token | Size | Weight | Notes |
|-------|------|--------|-------|
| `display1b` | 26px | 600 | letterSpacing: -1 |
| `title1b` | 18px | 600 | height: 1.5 |
| `title1m` | 18px | 500 | height: 1.5 |
| `title2b` | 16px | 600 | height: 1.1 |
| `body1m` | 16px | 600 | letterSpacing: -0.025 |
| `body1r` | 16px | 400 | letterSpacing: -0.05 |
| `body2b` | 14px | 700 | height: 1.5 |
| `body2m` | 14px | 500 | height: 1.5 |
| `body2r` | 14px | 400 | letterSpacing: 0.1 |
| `body3r` | 12px | 400 | height: 1.5 |
| `body3m` | 12px | 500 | letterSpacing: -0.1 |
| `footnote1r` | 12px | 400 | letterSpacing: 0.05 |
| `footnote1m` | 12px | 500 | — |
| `footnote2b` | 10px | 700 | — |
| `footnote2r` | 10px | 400 | — |
| `titleUppercase3m` | 12px | 500 | letterSpacing: 0.7 — use for ALL-CAPS section labels |
## Typography Color Extension
`UiTypography` styles have a `.textSecondary`, `.textSuccess`, `.textError`, `.textWarning`, `.primary`, `.white` extension defined in `TypographyColors`. Use these instead of `.copyWith(color: ...)` where possible for brevity.

View File

@@ -0,0 +1,247 @@
---
name: bug-reporter
description: "Use this agent when you need to create a GitHub issue to report a bug, request a feature, or document a technical task. This includes when a bug is discovered during development, when a TODO or known issue is identified in the codebase, when a feature request needs to be formally tracked, or when technical debt needs to be documented.\\n\\nExamples:\\n\\n- User: \"I found a bug where the order total calculates incorrectly when discounts are applied\"\\n Assistant: \"Let me use the bug-reporter agent to create a well-structured GitHub issue for this calculation bug.\"\\n (Use the Agent tool to launch the bug-reporter agent with the bug context)\\n\\n- User: \"We need to track that the session timeout doesn't redirect properly on the client app\"\\n Assistant: \"I'll use the bug-reporter agent to file this as a GitHub issue with the right labels and context.\"\\n (Use the Agent tool to launch the bug-reporter agent)\\n\\n- After discovering an issue during code review or development:\\n Assistant: \"I noticed a potential race condition in the BLoC disposal logic. Let me use the bug-reporter agent to create a tracked issue for this.\"\\n (Use the Agent tool to launch the bug-reporter agent proactively)\\n\\n- User: \"Create a feature request for adding push notification support to the staff app\"\\n Assistant: \"I'll use the bug-reporter agent to create a well-structured feature request issue on GitHub.\"\\n (Use the Agent tool to launch the bug-reporter agent)"
model: haiku
color: yellow
memory: project
---
You are an expert GitHub Issue Reporter specializing in creating clear, actionable, and well-structured issues for software projects. You have deep experience in bug triage, issue classification, and technical writing for development teams.
You have access to the GitHub CLI (`gh`) and GitHub MCP tools. Use `gh` commands as your primary tool, falling back to GitHub MCP if needed.
## Your Primary Mission
Create well-structured GitHub issues with comprehensive context that enables any developer to understand, reproduce, and resolve the issue efficiently.
## Before Creating an Issue
1. **Determine the repository**: Run `gh repo view --json nameWithOwner -q .nameWithOwner` to confirm the current repo.
2. **Check existing labels**: Run `gh label list` to see available labels. Only use labels that exist in the repository.
3. **Check for duplicates**: Run `gh issue list --search "<relevant keywords>"` to avoid creating duplicate issues.
4. **Determine issue type**: Classify as one of: bug, feature request, technical debt, enhancement, chore, or documentation.
## Issue Structure
Every issue MUST contain these sections, formatted in Markdown:
### For Bugs:
```
## Context
[Background on the feature/area affected, why it matters, and how it was discovered]
## Current State (Bug Behavior)
[What is currently happening — be specific with error messages, incorrect outputs, or unexpected behavior]
## Expected Behavior
[What should happen instead]
## Steps to Reproduce
[Numbered steps to reliably reproduce the issue, if known]
## Suggested Approach
[Technical guidance on where the fix likely needs to happen — files, functions, architectural layers]
## Additional Context
[Screenshots, logs, related issues, environment details, or any other relevant information]
```
### For Feature Requests:
```
## Context
[Background on why this feature is needed, user pain points, or business requirements]
## Current State
[How things work today without this feature, any workarounds in use]
## What's Needed
[Clear description of the desired functionality and acceptance criteria]
## Suggested Approach
[Technical approach, architecture considerations, affected components]
## Additional Context
[Mockups, references, related features, or dependencies]
```
### For Technical Debt / Chores:
```
## Context
[Background on the technical area and why this work matters]
## Current State
[What the current implementation looks like and its problems]
## What Needs to Change
[Specific improvements or refactoring required]
## Suggested Approach
[Step-by-step technical plan, migration strategy if applicable]
## Impact & Risk
[What areas are affected, potential risks, testing considerations]
```
## Label Selection
Apply labels based on these criteria (only use labels that exist in the repo):
- **Type labels**: `bug`, `enhancement`, `feature`, `chore`, `documentation`, `technical-debt`
- **Priority labels**: `priority: critical`, `priority: high`, `priority: medium`, `priority: low`
- **Area labels**: Match to the affected area (e.g., `mobile`, `web`, `backend`, `api`, `ui`, `infrastructure`)
- **Status labels**: `good first issue`, `help wanted` if applicable
If unsure about a label's existence, check with `gh label list` first. Never fabricate labels.
## Creating the Issue
Use this command pattern:
```bash
gh issue create --title "<clear, concise title>" --body "<full markdown body>" --label "<label1>,<label2>"
```
**Title conventions:**
- Bugs: `[Bug] <concise description of the problem>`
- Features: `[Feature] <concise description of the feature>`
- Tech Debt: `[Tech Debt] <concise description>`
- Chore: `[Chore] <concise description>`
## Quality Checklist (Self-Verify Before Submitting)
- [ ] Title is clear and descriptive (someone can understand the issue from the title alone)
- [ ] All required sections are filled with specific, actionable content
- [ ] Labels are valid (verified against repo's label list)
- [ ] No duplicate issue exists
- [ ] Technical details reference specific files, functions, or components when possible
- [ ] The suggested approach is realistic and aligns with the project's architecture
- [ ] Markdown formatting is correct
## Important Rules
- Always confirm the issue details with the user before creating it, unless explicitly told to proceed
- If context is insufficient, ask clarifying questions before creating the issue
- Reference specific file paths, component names, and code patterns from the codebase when possible
- For this KROW project: reference the Clean Architecture layers, BLoC patterns, feature package paths, and V2 API conventions as appropriate
- After creating the issue, display the issue URL and a summary of what was created
- If `gh` auth fails, guide the user through `gh auth login` or fall back to GitHub MCP tools
# Persistent Agent Memory
You have a persistent, file-based memory system at `/Users/achinthaisuru/Documents/GitHub/krow-workforce/apps/mobile/packages/core_localization/.claude/agent-memory/bug-reporter/`. This directory already exists — write to it directly with the Write tool (do not run mkdir or check for its existence).
You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you.
If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry.
## Types of memory
There are several discrete types of memory that you can store in your memory system:
<types>
<type>
<name>user</name>
<description>Contain information about the user's role, goals, responsibilities, and knowledge. Great user memories help you tailor your future behavior to the user's preferences and perspective. Your goal in reading and writing these memories is to build up an understanding of who the user is and how you can be most helpful to them specifically. For example, you should collaborate with a senior software engineer differently than a student who is coding for the very first time. Keep in mind, that the aim here is to be helpful to the user. Avoid writing memories about the user that could be viewed as a negative judgement or that are not relevant to the work you're trying to accomplish together.</description>
<when_to_save>When you learn any details about the user's role, preferences, responsibilities, or knowledge</when_to_save>
<how_to_use>When your work should be informed by the user's profile or perspective. For example, if the user is asking you to explain a part of the code, you should answer that question in a way that is tailored to the specific details that they will find most valuable or that helps them build their mental model in relation to domain knowledge they already have.</how_to_use>
<examples>
user: I'm a data scientist investigating what logging we have in place
assistant: [saves user memory: user is a data scientist, currently focused on observability/logging]
user: I've been writing Go for ten years but this is my first time touching the React side of this repo
assistant: [saves user memory: deep Go expertise, new to React and this project's frontend — frame frontend explanations in terms of backend analogues]
</examples>
</type>
<type>
<name>feedback</name>
<description>Guidance the user has given you about how to approach work — both what to avoid and what to keep doing. These are a very important type of memory to read and write as they allow you to remain coherent and responsive to the way you should approach work in the project. Record from failure AND success: if you only save corrections, you will avoid past mistakes but drift away from approaches the user has already validated, and may grow overly cautious.</description>
<when_to_save>Any time the user corrects your approach ("no not that", "don't", "stop doing X") OR confirms a non-obvious approach worked ("yes exactly", "perfect, keep doing that", accepting an unusual choice without pushback). Corrections are easy to notice; confirmations are quieter — watch for them. In both cases, save what is applicable to future conversations, especially if surprising or not obvious from the code. Include *why* so you can judge edge cases later.</when_to_save>
<how_to_use>Let these memories guide your behavior so that the user does not need to offer the same guidance twice.</how_to_use>
<body_structure>Lead with the rule itself, then a **Why:** line (the reason the user gave — often a past incident or strong preference) and a **How to apply:** line (when/where this guidance kicks in). Knowing *why* lets you judge edge cases instead of blindly following the rule.</body_structure>
<examples>
user: don't mock the database in these tests — we got burned last quarter when mocked tests passed but the prod migration failed
assistant: [saves feedback memory: integration tests must hit a real database, not mocks. Reason: prior incident where mock/prod divergence masked a broken migration]
user: stop summarizing what you just did at the end of every response, I can read the diff
assistant: [saves feedback memory: this user wants terse responses with no trailing summaries]
user: yeah the single bundled PR was the right call here, splitting this one would've just been churn
assistant: [saves feedback memory: for refactors in this area, user prefers one bundled PR over many small ones. Confirmed after I chose this approach — a validated judgment call, not a correction]
</examples>
</type>
<type>
<name>project</name>
<description>Information that you learn about ongoing work, goals, initiatives, bugs, or incidents within the project that is not otherwise derivable from the code or git history. Project memories help you understand the broader context and motivation behind the work the user is doing within this working directory.</description>
<when_to_save>When you learn who is doing what, why, or by when. These states change relatively quickly so try to keep your understanding of this up to date. Always convert relative dates in user messages to absolute dates when saving (e.g., "Thursday" → "2026-03-05"), so the memory remains interpretable after time passes.</when_to_save>
<how_to_use>Use these memories to more fully understand the details and nuance behind the user's request and make better informed suggestions.</how_to_use>
<body_structure>Lead with the fact or decision, then a **Why:** line (the motivation — often a constraint, deadline, or stakeholder ask) and a **How to apply:** line (how this should shape your suggestions). Project memories decay fast, so the why helps future-you judge whether the memory is still load-bearing.</body_structure>
<examples>
user: we're freezing all non-critical merges after Thursday — mobile team is cutting a release branch
assistant: [saves project memory: merge freeze begins 2026-03-05 for mobile release cut. Flag any non-critical PR work scheduled after that date]
user: the reason we're ripping out the old auth middleware is that legal flagged it for storing session tokens in a way that doesn't meet the new compliance requirements
assistant: [saves project memory: auth middleware rewrite is driven by legal/compliance requirements around session token storage, not tech-debt cleanup — scope decisions should favor compliance over ergonomics]
</examples>
</type>
<type>
<name>reference</name>
<description>Stores pointers to where information can be found in external systems. These memories allow you to remember where to look to find up-to-date information outside of the project directory.</description>
<when_to_save>When you learn about resources in external systems and their purpose. For example, that bugs are tracked in a specific project in Linear or that feedback can be found in a specific Slack channel.</when_to_save>
<how_to_use>When the user references an external system or information that may be in an external system.</how_to_use>
<examples>
user: check the Linear project "INGEST" if you want context on these tickets, that's where we track all pipeline bugs
assistant: [saves reference memory: pipeline bugs are tracked in Linear project "INGEST"]
user: the Grafana board at grafana.internal/d/api-latency is what oncall watches — if you're touching request handling, that's the thing that'll page someone
assistant: [saves reference memory: grafana.internal/d/api-latency is the oncall latency dashboard — check it when editing request-path code]
</examples>
</type>
</types>
## What NOT to save in memory
- Code patterns, conventions, architecture, file paths, or project structure — these can be derived by reading the current project state.
- Git history, recent changes, or who-changed-what — `git log` / `git blame` are authoritative.
- Debugging solutions or fix recipes — the fix is in the code; the commit message has the context.
- Anything already documented in CLAUDE.md files.
- Ephemeral task details: in-progress work, temporary state, current conversation context.
## How to save memories
Saving a memory is a two-step process:
**Step 1** — write the memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format:
```markdown
---
name: {{memory name}}
description: {{one-line description — used to decide relevance in future conversations, so be specific}}
type: {{user, feedback, project, reference}}
---
{{memory content — for feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines}}
```
**Step 2** — add a pointer to that file in `MEMORY.md`. `MEMORY.md` is an index, not a memory — it should contain only links to memory files with brief descriptions. It has no frontmatter. Never write memory content directly into `MEMORY.md`.
- `MEMORY.md` is always loaded into your conversation context — lines after 200 will be truncated, so keep the index concise
- Keep the name, description, and type fields in memory files up-to-date with the content
- Organize memory semantically by topic, not chronologically
- Update or remove memories that turn out to be wrong or outdated
- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.
## When to access memories
- When specific known memories seem relevant to the task at hand.
- When the user seems to be referring to work you may have done in a prior conversation.
- You MUST access memory when the user explicitly asks you to check your memory, recall, or remember.
- Memory records what was true when it was written. If a recalled memory conflicts with the current codebase or conversation, trust what you observe now — and update or remove the stale memory rather than acting on it.
## Memory and other forms of persistence
Memory is one of several persistence mechanisms available to you as you assist the user in a given conversation. The distinction is often that memory can be recalled in future conversations and should not be used for persisting information that is only useful within the scope of the current conversation.
- When to use or update a plan instead of memory: If you are about to start a non-trivial implementation task and would like to reach alignment with the user on your approach you should use a Plan rather than saving this information to memory. Similarly, if you already have a plan within the conversation and you have changed your approach persist that change by updating the plan rather than saving a memory.
- When to use or update tasks instead of memory: When you need to break your work in current conversation into discrete steps or keep track of your progress use tasks instead of saving to memory. Tasks are great for persisting information about the work that needs to be done in the current conversation, but memory should be reserved for information that will be useful in future conversations.
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
## MEMORY.md
Your MEMORY.md is currently empty. When you save new memories, they will appear here.

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

@@ -1,9 +1,10 @@
import 'dart:async';
import 'package:core_localization/core_localization.dart';
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 +29,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);
});
@@ -75,7 +85,7 @@ class _SessionListenerState extends State<SessionListener> {
if (!_isInitialState) {
debugPrint('[SessionListener] Session error: ${state.errorMessage}');
_showSessionErrorDialog(
state.errorMessage ?? 'Session error occurred',
state.errorMessage ?? t.session.error_title,
);
} else {
_isInitialState = false;
@@ -92,22 +102,21 @@ class _SessionListenerState extends State<SessionListener> {
/// Shows a dialog when the session expires.
void _showSessionExpiredDialog() {
final Translations translations = t;
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('Session Expired'),
content: const Text(
'Your session has expired. Please log in again to continue.',
),
title: Text(translations.session.expired_title),
content: Text(translations.session.expired_message),
actions: <Widget>[
TextButton(
onPressed: () {
Modular.to.popSafe();
Navigator.of(dialogContext).pop();
_proceedToLogin();
},
child: const Text('Log In'),
child: Text(translations.session.log_in),
),
],
);
@@ -117,27 +126,28 @@ class _SessionListenerState extends State<SessionListener> {
/// Shows a dialog when a session error occurs, with retry option.
void _showSessionErrorDialog(String errorMessage) {
final Translations translations = t;
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('Session Error'),
title: Text(translations.session.error_title),
content: Text(errorMessage),
actions: <Widget>[
TextButton(
onPressed: () {
// User can retry by dismissing and continuing
Modular.to.popSafe();
Navigator.of(dialogContext).pop();
},
child: const Text('Continue'),
child: Text(translations.common.continue_text),
),
TextButton(
onPressed: () {
Modular.to.popSafe();;
Navigator.of(dialogContext).pop();
_proceedToLogin();
},
child: const Text('Log Out'),
child: Text(translations.session.log_out),
),
],
);
@@ -147,8 +157,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

@@ -34,14 +34,14 @@ dependencies:
path: ../../packages/features/client/orders/create_order
krow_core:
path: ../../packages/core
krow_domain:
path: ../../packages/domain
cupertino_icons: ^1.0.8
flutter_modular: ^6.3.2
flutter_bloc: ^8.1.3
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
@@ -88,12 +76,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

@@ -1,9 +1,10 @@
import 'dart:async';
import 'package:core_localization/core_localization.dart';
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 +29,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 +75,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;
@@ -75,7 +98,7 @@ class _SessionListenerState extends State<SessionListener> {
if (!_isInitialState) {
debugPrint('[SessionListener] Session error: ${state.errorMessage}');
_showSessionErrorDialog(
state.errorMessage ?? 'Session error occurred',
state.errorMessage ?? t.session.error_title,
);
} else {
_isInitialState = false;
@@ -92,22 +115,21 @@ class _SessionListenerState extends State<SessionListener> {
/// Shows a dialog when the session expires.
void _showSessionExpiredDialog() {
final Translations translations = t;
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('Session Expired'),
content: const Text(
'Your session has expired. Please log in again to continue.',
),
title: Text(translations.session.expired_title),
content: Text(translations.session.expired_message),
actions: <Widget>[
TextButton(
onPressed: () {
Modular.to.popSafe();;
Navigator.of(dialogContext).pop();
_proceedToLogin();
},
child: const Text('Log In'),
child: Text(translations.session.log_in),
),
],
);
@@ -117,27 +139,28 @@ class _SessionListenerState extends State<SessionListener> {
/// Shows a dialog when a session error occurs, with retry option.
void _showSessionErrorDialog(String errorMessage) {
final Translations translations = t;
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('Session Error'),
title: Text(translations.session.error_title),
content: Text(errorMessage),
actions: <Widget>[
TextButton(
onPressed: () {
// User can retry by dismissing and continuing
Modular.to.popSafe();
Navigator.of(dialogContext).pop();
},
child: const Text('Continue'),
child: Text(translations.common.continue_text),
),
TextButton(
onPressed: () {
Modular.to.popSafe();;
Navigator.of(dialogContext).pop();
_proceedToLogin();
},
child: const Text('Log Out'),
child: Text(translations.session.log_out),
),
],
);
@@ -147,8 +170,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
@@ -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

@@ -28,8 +28,8 @@ dependencies:
path: ../../packages/features/staff/staff_main
krow_core:
path: ../../packages/core
krow_data_connect:
path: ../../packages/data_connect
krow_domain:
path: ../../packages/domain
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,15 @@ 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';
// Auth
export 'src/services/auth/auth_token_provider.dart';
export 'src/services/auth/firebase_auth_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

@@ -3,6 +3,10 @@ import 'package:flutter_modular/flutter_modular.dart';
import 'package:image_picker/image_picker.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:krow_core/src/services/auth/auth_token_provider.dart';
import 'package:krow_core/src/services/auth/firebase_auth_service.dart';
import 'package:krow_core/src/services/auth/firebase_auth_token_provider.dart';
import '../core.dart';
/// A module that provides core services and shared dependencies.
@@ -18,6 +22,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>()),
@@ -49,7 +61,13 @@ class CoreModule extends Module {
),
);
// 6. Register Geofence Device Services
// 6. Auth Token Provider
i.addLazySingleton<AuthTokenProvider>(FirebaseAuthTokenProvider.new);
// 7. Firebase Auth Service (so features never import firebase_auth)
i.addLazySingleton<FirebaseAuthService>(FirebaseAuthServiceImpl.new);
// 8. Register Geofence Device Services
i.addLazySingleton<LocationService>(() => const LocationService());
i.addLazySingleton<NotificationService>(() => NotificationService());
i.addLazySingleton<StorageService>(() => StorageService());

View File

@@ -60,6 +60,20 @@ extension StaffNavigator on IModularNavigator {
safePush(StaffPaths.benefits);
}
/// Navigates to the full history page for a specific benefit.
void toBenefitHistory({
required String benefitId,
required String benefitTitle,
}) {
safePush(
StaffPaths.benefitHistory,
arguments: <String, dynamic>{
'benefitId': benefitId,
'benefitTitle': benefitTitle,
},
);
}
void toStaffMain() {
safePushNamedAndRemoveUntil('${StaffPaths.main}/home/', (_) => false);
}
@@ -98,6 +112,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 +139,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 +153,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

@@ -75,6 +75,9 @@ class StaffPaths {
/// Benefits overview page.
static const String benefits = '/worker-main/home/benefits';
/// Benefit history page for a specific benefit.
static const String benefitHistory = '/worker-main/home/benefits/history';
/// Shifts tab - view and manage shifts.
///
/// Browse available shifts, accepted shifts, and shift history.

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,209 @@
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');
/// Coverage incidents.
static const ApiEndpoint coverageIncidents =
ApiEndpoint('/client/coverage/incidents');
/// Blocked staff.
static const ApiEndpoint coverageBlockedStaff =
ApiEndpoint('/client/coverage/blocked-staff');
/// Coverage swap requests.
static const ApiEndpoint coverageSwapRequests =
ApiEndpoint('/client/coverage/swap-requests');
/// Dispatch teams.
static const ApiEndpoint coverageDispatchTeams =
ApiEndpoint('/client/coverage/dispatch-teams');
/// Dispatch candidates.
static const ApiEndpoint coverageDispatchCandidates =
ApiEndpoint('/client/coverage/dispatch-candidates');
/// 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');
/// Register or delete device push token (POST to register, DELETE to remove).
static const ApiEndpoint devicesPushTokens =
ApiEndpoint('/client/devices/push-tokens');
/// Create shift manager.
static const ApiEndpoint shiftManagerCreate =
ApiEndpoint('/client/shift-managers');
/// Resolve coverage swap request by ID.
static ApiEndpoint coverageSwapRequestResolve(String id) =>
ApiEndpoint('/client/coverage/swap-requests/$id/resolve');
/// Cancel coverage swap request by ID.
static ApiEndpoint coverageSwapRequestCancel(String id) =>
ApiEndpoint('/client/coverage/swap-requests/$id/cancel');
/// Create dispatch team membership.
static const ApiEndpoint coverageDispatchTeamMembershipsCreate =
ApiEndpoint('/client/coverage/dispatch-teams/memberships');
/// Delete dispatch team membership by ID.
static ApiEndpoint coverageDispatchTeamMembershipsDelete(String id) =>
ApiEndpoint('/client/coverage/dispatch-teams/memberships/$id');
}

View File

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

View File

@@ -0,0 +1,200 @@
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');
/// Benefits history.
static const ApiEndpoint benefitsHistory =
ApiEndpoint('/staff/profile/benefits/history');
/// Time card.
static const ApiEndpoint timeCard =
ApiEndpoint('/staff/profile/time-card');
/// Privacy settings.
static const ApiEndpoint privacy = ApiEndpoint('/staff/profile/privacy');
/// Preferred locations.
static const ApiEndpoint locations =
ApiEndpoint('/staff/profile/locations');
/// 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');
/// Submit shift for approval.
static ApiEndpoint shiftSubmitForApproval(String shiftId) =>
ApiEndpoint('/staff/shifts/$shiftId/submit-for-approval');
/// Location streams.
static const ApiEndpoint locationStreams =
ApiEndpoint('/staff/location-streams');
/// Register or delete device push token (POST to register, DELETE to remove).
static const ApiEndpoint devicesPushTokens =
ApiEndpoint('/staff/devices/push-tokens');
}

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 const 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,11 @@
/// Provides the current Firebase ID token for API authentication.
///
/// Lives in core so feature packages can access auth tokens
/// without importing firebase_auth directly.
abstract interface class AuthTokenProvider {
/// Returns the current ID token, refreshing if expired.
///
/// Pass [forceRefresh] to force a token refresh from Firebase.
/// Returns null if no user is signed in.
Future<String?> getIdToken({bool forceRefresh});
}

View File

@@ -0,0 +1,258 @@
import 'dart:async';
import 'package:firebase_auth/firebase_auth.dart' as firebase;
import 'package:krow_domain/krow_domain.dart'
show
InvalidCredentialsException,
NetworkException,
SignInFailedException,
User,
UserStatus;
/// Abstraction over Firebase Auth client-side operations.
///
/// Provides phone-based and email-based authentication, sign-out,
/// auth state observation, and current user queries. Lives in core
/// so feature packages never import `firebase_auth` directly.
abstract interface class FirebaseAuthService {
/// Stream of the currently signed-in user mapped to a domain [User].
///
/// Emits `null` when the user signs out.
Stream<User?> get authStateChanges;
/// Returns the current user's phone number, or `null` if unavailable.
String? get currentUserPhoneNumber;
/// Returns the current user's UID, or `null` if not signed in.
String? get currentUserUid;
/// Initiates phone number verification via Firebase Auth SDK.
///
/// Returns a [Future] that completes with the verification ID when
/// the SMS code is sent. The [onAutoVerified] callback fires if the
/// device auto-retrieves the credential (Android only).
Future<String?> verifyPhoneNumber({
required String phoneNumber,
void Function()? onAutoVerified,
});
/// Cancels any pending phone verification request.
void cancelPendingPhoneVerification();
/// Signs in with a phone auth credential built from
/// [verificationId] and [smsCode].
///
/// Returns the signed-in domain [User] or throws a domain exception.
Future<PhoneSignInResult> signInWithPhoneCredential({
required String verificationId,
required String smsCode,
});
/// Signs in with email and password via Firebase Auth SDK.
///
/// Returns the Firebase UID on success or throws a domain exception.
Future<String> signInWithEmailAndPassword({
required String email,
required String password,
});
/// Signs out the current user from Firebase Auth locally.
Future<void> signOut();
/// Returns the current user's Firebase ID token.
///
/// Returns `null` if no user is signed in.
Future<String?> getIdToken();
}
/// Result of a phone credential sign-in.
///
/// Contains the Firebase user's UID, phone number, and ID token
/// so the caller can proceed with V2 API verification without
/// importing `firebase_auth`.
class PhoneSignInResult {
/// Creates a [PhoneSignInResult].
const PhoneSignInResult({
required this.uid,
required this.phoneNumber,
required this.idToken,
});
/// The Firebase user UID.
final String uid;
/// The phone number associated with the credential.
final String? phoneNumber;
/// The Firebase ID token for the signed-in user.
final String? idToken;
}
/// Firebase-backed implementation of [FirebaseAuthService].
///
/// Wraps the `firebase_auth` package so that feature packages
/// interact with Firebase Auth only through this core service.
class FirebaseAuthServiceImpl implements FirebaseAuthService {
/// Creates a [FirebaseAuthServiceImpl].
///
/// Optionally accepts a [firebase.FirebaseAuth] instance for testing.
FirebaseAuthServiceImpl({firebase.FirebaseAuth? auth})
: _auth = auth ?? firebase.FirebaseAuth.instance;
/// The Firebase Auth instance.
final firebase.FirebaseAuth _auth;
/// Completer for the pending phone verification request.
Completer<String?>? _pendingVerification;
@override
Stream<User?> get authStateChanges =>
_auth.authStateChanges().map((firebase.User? firebaseUser) {
if (firebaseUser == null) {
return null;
}
return User(
id: firebaseUser.uid,
email: firebaseUser.email,
displayName: firebaseUser.displayName,
phone: firebaseUser.phoneNumber,
status: UserStatus.active,
);
});
@override
String? get currentUserPhoneNumber => _auth.currentUser?.phoneNumber;
@override
String? get currentUserUid => _auth.currentUser?.uid;
@override
Future<String?> verifyPhoneNumber({
required String phoneNumber,
void Function()? onAutoVerified,
}) async {
final Completer<String?> completer = Completer<String?>();
_pendingVerification = completer;
await _auth.verifyPhoneNumber(
phoneNumber: phoneNumber,
verificationCompleted: (firebase.PhoneAuthCredential credential) {
onAutoVerified?.call();
},
verificationFailed: (firebase.FirebaseAuthException e) {
if (!completer.isCompleted) {
if (e.code == 'network-request-failed' ||
e.message?.contains('Unable to resolve host') == true) {
completer.completeError(
const NetworkException(
technicalMessage: 'Auth network failure',
),
);
} else {
completer.completeError(
SignInFailedException(
technicalMessage: 'Firebase ${e.code}: ${e.message}',
),
);
}
}
},
codeSent: (String verificationId, _) {
if (!completer.isCompleted) {
completer.complete(verificationId);
}
},
codeAutoRetrievalTimeout: (String verificationId) {
if (!completer.isCompleted) {
completer.complete(verificationId);
}
},
);
return completer.future;
}
@override
void cancelPendingPhoneVerification() {
final Completer<String?>? completer = _pendingVerification;
if (completer != null && !completer.isCompleted) {
completer.completeError(Exception('Phone verification cancelled.'));
}
_pendingVerification = null;
}
@override
Future<PhoneSignInResult> signInWithPhoneCredential({
required String verificationId,
required String smsCode,
}) async {
final firebase.PhoneAuthCredential credential =
firebase.PhoneAuthProvider.credential(
verificationId: verificationId,
smsCode: smsCode,
);
final firebase.UserCredential userCredential;
try {
userCredential = await _auth.signInWithCredential(credential);
} on firebase.FirebaseAuthException catch (e) {
if (e.code == 'invalid-verification-code') {
throw const InvalidCredentialsException(
technicalMessage: 'Invalid OTP code entered.',
);
}
rethrow;
}
final firebase.User? firebaseUser = userCredential.user;
if (firebaseUser == null) {
throw const SignInFailedException(
technicalMessage:
'Phone verification failed, no Firebase user received.',
);
}
final String? idToken = await firebaseUser.getIdToken();
if (idToken == null) {
throw const SignInFailedException(
technicalMessage: 'Failed to obtain Firebase ID token.',
);
}
return PhoneSignInResult(
uid: firebaseUser.uid,
phoneNumber: firebaseUser.phoneNumber,
idToken: idToken,
);
}
@override
Future<String> signInWithEmailAndPassword({
required String email,
required String password,
}) async {
final firebase.UserCredential credential =
await _auth.signInWithEmailAndPassword(email: email, password: password);
final firebase.User? firebaseUser = credential.user;
if (firebaseUser == null) {
throw const SignInFailedException(
technicalMessage: 'Local Firebase sign-in returned null user.',
);
}
return firebaseUser.uid;
}
@override
Future<void> signOut() async {
await _auth.signOut();
}
@override
Future<String?> getIdToken() async {
final firebase.User? user = _auth.currentUser;
return user?.getIdToken();
}
}

View File

@@ -0,0 +1,15 @@
import 'package:firebase_auth/firebase_auth.dart';
import 'package:krow_core/src/services/auth/auth_token_provider.dart';
/// Firebase-backed implementation of [AuthTokenProvider].
///
/// Delegates to [FirebaseAuth] to get the current user's
/// ID token. Must run in the main isolate (Firebase SDK requirement).
class FirebaseAuthTokenProvider implements AuthTokenProvider {
@override
Future<String?> getIdToken({bool forceRefresh = false}) async {
final User? user = FirebaseAuth.instance.currentUser;
return user?.getIdToken(forceRefresh);
}
}

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

@@ -1,5 +1,45 @@
import 'package:intl/intl.dart';
/// Converts a break duration label (e.g. `'MIN_30'`) to its value in minutes.
///
/// Recognised labels: `MIN_10`, `MIN_15`, `MIN_30`, `MIN_45`, `MIN_60`.
/// Returns `0` for any unrecognised value (including `'NO_BREAK'`).
int breakMinutesFromLabel(String label) {
switch (label) {
case 'MIN_10':
return 10;
case 'MIN_15':
return 15;
case 'MIN_30':
return 30;
case 'MIN_45':
return 45;
case 'MIN_60':
return 60;
default:
return 0;
}
}
/// Formats a [DateTime] to a `yyyy-MM-dd` date string.
///
/// Example: `DateTime(2026, 3, 5)` -> `'2026-03-05'`.
String formatDateToIso(DateTime date) {
return '${date.year.toString().padLeft(4, '0')}-'
'${date.month.toString().padLeft(2, '0')}-'
'${date.day.toString().padLeft(2, '0')}';
}
/// Formats a [DateTime] to `HH:mm` (24-hour) time string.
///
/// Converts to local time before formatting.
/// Example: a UTC DateTime of 14:30 in UTC-5 -> `'09:30'`.
String formatTimeHHmm(DateTime dt) {
final DateTime local = dt.toLocal();
return '${local.hour.toString().padLeft(2, '0')}:'
'${local.minute.toString().padLeft(2, '0')}';
}
/// Formats a time string (ISO 8601 or HH:mm) into 12-hour format
/// (e.g. "9:00 AM").
///

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

@@ -12,6 +12,13 @@
"english": "English",
"spanish": "Español"
},
"session": {
"expired_title": "Session Expired",
"expired_message": "Your session has expired. Please log in again to continue.",
"error_title": "Session Error",
"log_in": "Log In",
"log_out": "Log Out"
},
"settings": {
"language": "Language",
"change_language": "Change Language"
@@ -665,7 +672,14 @@
"status": {
"pending": "Pending",
"submitted": "Submitted"
}
},
"history_header": "HISTORY",
"no_history": "No history yet",
"show_all": "Show all",
"hours_accrued": "+${hours}h accrued",
"hours_used": "-${hours}h used",
"history_page_title": "$benefit History",
"loading_more": "Loading..."
}
},
"auto_match": {
@@ -785,6 +799,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 +837,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 +846,8 @@
"events": "Events",
"retail": "Retail",
"healthcare": "Healthcare",
"catering": "Catering",
"cafe": "Cafe",
"other": "Other"
},
"skills": {
@@ -957,11 +978,15 @@
"retry": "Retry",
"clock_in_anyway": "Clock In Anyway",
"override_title": "Justification Required",
"override_desc": "Your location could not be verified. Please explain why you are clocking in without location verification.",
"override_desc": "Your location could not be verified. Please explain why you are proceeding without location verification.",
"override_hint": "Enter your justification...",
"override_submit": "Clock In",
"override_submit": "Submit",
"overridden_title": "Location Not Verified",
"overridden_desc": "You are clocking in without location verification. Your justification has been recorded."
"overridden_desc": "You are proceeding without location verification. Your justification has been recorded.",
"outside_work_area_warning": "You've moved away from the work area",
"outside_work_area_title": "You've moved away from the work area",
"outside_work_area_desc": "You are $distance away from your shift location. To clock out, provide a reason below.",
"clock_out_anyway": "Clock out anyway"
}
},
"availability": {
@@ -1152,6 +1177,8 @@
"upload": {
"instructions": "Please select a valid PDF file to upload.",
"pdf_banner": "Only PDF files are accepted. Maximum file size is 10MB.",
"pdf_banner_title": "PDF files only",
"pdf_banner_description": "Upload a PDF document up to 10MB in size.",
"file_not_found": "File not found.",
"submit": "Submit Document",
"select_pdf": "Select PDF File",
@@ -1330,14 +1357,22 @@
"applying_dialog": {
"title": "Applying"
},
"eligibility_requirements": "Eligibility Requirements"
"eligibility_requirements": "Eligibility Requirements",
"missing_certifications": "You are missing required certifications or documents to claim this shift. Please upload them to continue.",
"go_to_certificates": "Go to Certificates",
"shift_booked": "Shift successfully booked!",
"shift_not_found": "Shift not found",
"shift_declined_success": "Shift declined",
"complete_account_title": "Complete Your Account",
"complete_account_description": "Complete your account to book this shift and start earning"
},
"my_shift_card": {
"submit_for_approval": "Submit for Approval",
"timesheet_submitted": "Timesheet submitted for client approval",
"checked_in": "Checked in",
"submitted": "SUBMITTED",
"ready_to_submit": "READY TO SUBMIT"
"ready_to_submit": "READY TO SUBMIT",
"submitting": "SUBMITTING..."
},
"shift_location": {
"could_not_open_maps": "Could not open maps"
@@ -1450,11 +1485,14 @@
"shift": {
"no_open_roles": "There are no open positions available for this shift.",
"application_not_found": "Your application couldn't be found.",
"no_active_shift": "You don't have an active shift to clock out from."
"no_active_shift": "You don't have an active shift to clock out from.",
"not_found": "Shift not found. It may have been removed or is no longer available."
},
"clock_in": {
"location_verification_required": "Please wait for location verification before clocking in.",
"notes_required_for_timeout": "Please add a note explaining why your location can't be verified."
"notes_required_for_timeout": "Please add a note explaining why your location can't be verified.",
"already_clocked_in": "You're already clocked in to this shift.",
"already_clocked_out": "You've already clocked out of this shift."
},
"generic": {
"unknown": "Something went wrong. Please try again.",
@@ -1755,7 +1793,9 @@
"workers": "Workers",
"error_occurred": "An error occurred",
"retry": "Retry",
"shifts": "Shifts"
"shifts": "Shifts",
"overall_coverage": "Overall Coverage",
"live_activity": "LIVE ACTIVITY"
},
"calendar": {
"prev_week": "\u2190 Prev Week",
@@ -1764,7 +1804,9 @@
},
"stats": {
"checked_in": "Checked In",
"en_route": "En Route"
"en_route": "En Route",
"on_site": "On Site",
"late": "Late"
},
"alert": {
"workers_running_late(count)": {
@@ -1772,6 +1814,45 @@
"other": "$count workers are running late"
},
"auto_backup_searching": "Auto-backup system is searching for replacements."
},
"review": {
"title": "Rate this worker",
"subtitle": "Share your feedback",
"rating_labels": {
"poor": "Poor",
"fair": "Fair",
"good": "Good",
"great": "Great",
"excellent": "Excellent"
},
"favorite_label": "Favorite",
"block_label": "Block",
"feedback_placeholder": "Share details about this worker's performance...",
"submit": "Submit Review",
"success": "Review submitted successfully",
"issue_flags": {
"late": "Late",
"uniform": "Uniform",
"misconduct": "Misconduct",
"no_show": "No Show",
"attitude": "Attitude",
"performance": "Performance",
"left_early": "Left Early"
}
},
"cancel": {
"title": "Cancel Worker?",
"subtitle": "This cannot be undone",
"confirm_message": "Are you sure you want to cancel $name?",
"helper_text": "They will receive a cancellation notification. A replacement will be automatically requested.",
"reason_placeholder": "Reason for cancellation (optional)",
"keep_worker": "Keep Worker",
"confirm": "Yes, Cancel",
"success": "Worker cancelled. Searching for replacement."
},
"actions": {
"rate": "Rate",
"cancel": "Cancel"
}
},
"client_reports_common": {

View File

@@ -12,6 +12,13 @@
"english": "English",
"spanish": "Español"
},
"session": {
"expired_title": "Sesión Expirada",
"expired_message": "Tu sesión ha expirado. Por favor inicia sesión de nuevo para continuar.",
"error_title": "Error de Sesión",
"log_in": "Iniciar Sesión",
"log_out": "Cerrar Sesión"
},
"settings": {
"language": "Idioma",
"change_language": "Cambiar Idioma"
@@ -660,7 +667,14 @@
"status": {
"pending": "Pendiente",
"submitted": "Enviado"
}
},
"history_header": "HISTORIAL",
"no_history": "Sin historial aún",
"show_all": "Ver todo",
"hours_accrued": "+${hours}h acumuladas",
"hours_used": "-${hours}h utilizadas",
"history_page_title": "Historial de $benefit",
"loading_more": "Cargando..."
}
},
"auto_match": {
@@ -780,6 +794,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 +832,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 +841,8 @@
"events": "Eventos",
"retail": "Venta al por menor",
"healthcare": "Cuidado de la salud",
"catering": "Catering",
"cafe": "Cafetería",
"other": "Otro"
},
"skills": {
@@ -952,11 +973,15 @@
"retry": "Reintentar",
"clock_in_anyway": "Registrar Entrada",
"override_title": "Justificación Requerida",
"override_desc": "No se pudo verificar su ubicación. Explique por qué registra entrada sin verificación de ubicación.",
"override_desc": "No se pudo verificar su ubicación. Explique por qué continúa sin verificación de ubicación.",
"override_hint": "Ingrese su justificación...",
"override_submit": "Registrar Entrada",
"override_submit": "Enviar",
"overridden_title": "Ubicación No Verificada",
"overridden_desc": "Está registrando entrada sin verificación de ubicación. Su justificación ha sido registrada."
"overridden_desc": "Está continuando sin verificación de ubicación. Su justificación ha sido registrada.",
"outside_work_area_warning": "Te has alejado del área de trabajo",
"outside_work_area_title": "Te has alejado del área de trabajo",
"outside_work_area_desc": "Estás a $distance de la ubicación de tu turno. Para registrar tu salida, proporciona una razón a continuación.",
"clock_out_anyway": "Registrar salida de todos modos"
}
},
"availability": {
@@ -1147,6 +1172,8 @@
"upload": {
"instructions": "Por favor selecciona un archivo PDF válido para subir.",
"pdf_banner": "Solo se aceptan archivos PDF. Tamaño máximo del archivo: 10MB.",
"pdf_banner_title": "Solo archivos PDF",
"pdf_banner_description": "Sube un documento PDF de hasta 10MB de tamaño.",
"submit": "Enviar Documento",
"select_pdf": "Seleccionar Archivo PDF",
"attestation": "Certifico que este documento es genuino y válido.",
@@ -1325,14 +1352,22 @@
"applying_dialog": {
"title": "Solicitando"
},
"eligibility_requirements": "Requisitos de Elegibilidad"
"eligibility_requirements": "Requisitos de Elegibilidad",
"missing_certifications": "Te faltan certificaciones o documentos requeridos para reclamar este turno. Por favor, súbelos para continuar.",
"go_to_certificates": "Ir a Certificados",
"shift_booked": "¡Turno reservado con éxito!",
"shift_not_found": "Turno no encontrado",
"shift_declined_success": "Turno rechazado",
"complete_account_title": "Completa Tu Cuenta",
"complete_account_description": "Completa tu cuenta para reservar este turno y comenzar a ganar"
},
"my_shift_card": {
"submit_for_approval": "Enviar para Aprobación",
"timesheet_submitted": "Hoja de tiempo enviada para aprobación del cliente",
"checked_in": "Registrado",
"submitted": "ENVIADO",
"ready_to_submit": "LISTO PARA ENVIAR"
"ready_to_submit": "LISTO PARA ENVIAR",
"submitting": "ENVIANDO..."
},
"shift_location": {
"could_not_open_maps": "No se pudo abrir mapas"
@@ -1445,11 +1480,14 @@
"shift": {
"no_open_roles": "No hay posiciones abiertas disponibles para este turno.",
"application_not_found": "No se pudo encontrar tu solicitud.",
"no_active_shift": "No tienes un turno activo para registrar salida."
"no_active_shift": "No tienes un turno activo para registrar salida.",
"not_found": "Turno no encontrado. Puede haber sido eliminado o ya no está disponible."
},
"clock_in": {
"location_verification_required": "Por favor, espera la verificaci\u00f3n de ubicaci\u00f3n antes de registrar entrada.",
"notes_required_for_timeout": "Por favor, agrega una nota explicando por qu\u00e9 no se puede verificar tu ubicaci\u00f3n."
"notes_required_for_timeout": "Por favor, agrega una nota explicando por qu\u00e9 no se puede verificar tu ubicaci\u00f3n.",
"already_clocked_in": "Ya est\u00e1s registrado en este turno.",
"already_clocked_out": "Ya registraste tu salida de este turno."
},
"generic": {
"unknown": "Algo sali\u00f3 mal. Por favor, intenta de nuevo.",
@@ -1755,7 +1793,9 @@
"workers": "Trabajadores",
"error_occurred": "Ocurri\u00f3 un error",
"retry": "Reintentar",
"shifts": "Turnos"
"shifts": "Turnos",
"overall_coverage": "Cobertura General",
"live_activity": "ACTIVIDAD EN VIVO"
},
"calendar": {
"prev_week": "\u2190 Semana Anterior",
@@ -1764,7 +1804,9 @@
},
"stats": {
"checked_in": "Registrado",
"en_route": "En Camino"
"en_route": "En Camino",
"on_site": "En Sitio",
"late": "Tarde"
},
"alert": {
"workers_running_late(count)": {
@@ -1772,6 +1814,45 @@
"other": "$count trabajadores est\u00e1n llegando tarde"
},
"auto_backup_searching": "El sistema de respaldo autom\u00e1tico est\u00e1 buscando reemplazos."
},
"review": {
"title": "Calificar a este trabajador",
"subtitle": "Comparte tu opini\u00f3n",
"rating_labels": {
"poor": "Malo",
"fair": "Regular",
"good": "Bueno",
"great": "Muy Bueno",
"excellent": "Excelente"
},
"favorite_label": "Favorito",
"block_label": "Bloquear",
"feedback_placeholder": "Comparte detalles sobre el desempe\u00f1o de este trabajador...",
"submit": "Enviar Rese\u00f1a",
"success": "Rese\u00f1a enviada exitosamente",
"issue_flags": {
"late": "Tarde",
"uniform": "Uniforme",
"misconduct": "Mala Conducta",
"no_show": "No Se Present\u00f3",
"attitude": "Actitud",
"performance": "Rendimiento",
"left_early": "Sali\u00f3 Temprano"
}
},
"cancel": {
"title": "\u00bfCancelar Trabajador?",
"subtitle": "Esta acci\u00f3n no se puede deshacer",
"confirm_message": "\u00bfEst\u00e1s seguro de que deseas cancelar a $name?",
"helper_text": "Recibir\u00e1n una notificaci\u00f3n de cancelaci\u00f3n. Se solicitar\u00e1 un reemplazo autom\u00e1ticamente.",
"reason_placeholder": "Raz\u00f3n de la cancelaci\u00f3n (opcional)",
"keep_worker": "Mantener Trabajador",
"confirm": "S\u00ed, Cancelar",
"success": "Trabajador cancelado. Buscando reemplazo."
},
"actions": {
"rate": "Calificar",
"cancel": "Cancelar"
}
},
"client_reports_common": {

View File

@@ -124,6 +124,8 @@ String _translateShiftError(String errorType) {
return t.errors.shift.application_not_found;
case 'no_active_shift':
return t.errors.shift.no_active_shift;
case 'not_found':
return t.errors.shift.not_found;
default:
return t.errors.generic.unknown;
}

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,373 +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:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/billing_connector_repository.dart';
/// Implementation of [BillingConnectorRepository].
class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
BillingConnectorRepositoryImpl({dc.DataConnectService? service})
: _service = service ?? dc.DataConnectService.instance;
final dc.DataConnectService _service;
@override
Future<List<BusinessBankAccount>> getBankAccounts({
required String businessId,
}) async {
return _service.run(() async {
final QueryResult<
dc.GetAccountsByOwnerIdData,
dc.GetAccountsByOwnerIdVariables
>
result = await _service.connector
.getAccountsByOwnerId(ownerId: businessId)
.execute();
return result.data.accounts.map(_mapBankAccount).toList();
});
}
@override
Future<double> getCurrentBillAmount({required String businessId}) async {
return _service.run(() async {
final QueryResult<
dc.ListInvoicesByBusinessIdData,
dc.ListInvoicesByBusinessIdVariables
>
result = await _service.connector
.listInvoicesByBusinessId(businessId: businessId)
.execute();
return result.data.invoices
.map(_mapInvoice)
.where((Invoice i) => i.status == InvoiceStatus.open)
.fold<double>(
0.0,
(double sum, Invoice item) => sum + item.totalAmount,
);
});
}
@override
Future<List<Invoice>> getInvoiceHistory({required String businessId}) async {
return _service.run(() async {
final QueryResult<
dc.ListInvoicesByBusinessIdData,
dc.ListInvoicesByBusinessIdVariables
>
result = await _service.connector
.listInvoicesByBusinessId(businessId: businessId)
.limit(20)
.execute();
return result.data.invoices
.map(_mapInvoice)
.where((Invoice i) => i.status == InvoiceStatus.paid)
.toList();
});
}
@override
Future<List<Invoice>> getPendingInvoices({required String businessId}) async {
return _service.run(() async {
final QueryResult<
dc.ListInvoicesByBusinessIdData,
dc.ListInvoicesByBusinessIdVariables
>
result = await _service.connector
.listInvoicesByBusinessId(businessId: businessId)
.execute();
return result.data.invoices
.map(_mapInvoice)
.where(
(Invoice i) =>
i.status != InvoiceStatus.paid &&
i.status != InvoiceStatus.disputed &&
i.status != InvoiceStatus.open,
)
.toList();
});
}
@override
Future<List<InvoiceItem>> getSpendingBreakdown({
required String businessId,
required BillingPeriod period,
}) async {
return _service.run(() async {
final DateTime now = DateTime.now();
final DateTime start;
final DateTime end;
if (period == BillingPeriod.week) {
final int daysFromMonday = now.weekday - DateTime.monday;
final DateTime monday = DateTime(
now.year,
now.month,
now.day,
).subtract(Duration(days: daysFromMonday));
start = monday;
end = monday.add(
const Duration(days: 6, hours: 23, minutes: 59, seconds: 59),
);
} else {
start = DateTime(now.year, now.month, 1);
end = DateTime(now.year, now.month + 1, 0, 23, 59, 59);
}
final QueryResult<
dc.ListShiftRolesByBusinessAndDatesSummaryData,
dc.ListShiftRolesByBusinessAndDatesSummaryVariables
>
result = await _service.connector
.listShiftRolesByBusinessAndDatesSummary(
businessId: businessId,
start: _service.toTimestamp(start),
end: _service.toTimestamp(end),
)
.execute();
final List<dc.ListShiftRolesByBusinessAndDatesSummaryShiftRoles>
shiftRoles = result.data.shiftRoles;
if (shiftRoles.isEmpty) return <InvoiceItem>[];
final Map<String, _RoleSummary> summary = <String, _RoleSummary>{};
for (final dc.ListShiftRolesByBusinessAndDatesSummaryShiftRoles role
in shiftRoles) {
final String roleId = role.roleId;
final String roleName = role.role.name;
final double hours = role.hours ?? 0.0;
final double totalValue = role.totalValue ?? 0.0;
final _RoleSummary? existing = summary[roleId];
if (existing == null) {
summary[roleId] = _RoleSummary(
roleId: roleId,
roleName: roleName,
totalHours: hours,
totalValue: totalValue,
);
} else {
summary[roleId] = existing.copyWith(
totalHours: existing.totalHours + hours,
totalValue: existing.totalValue + totalValue,
);
}
}
return summary.values
.map(
(_RoleSummary item) => InvoiceItem(
id: item.roleId,
invoiceId: item.roleId,
staffId: item.roleName,
workHours: item.totalHours,
rate: item.totalHours > 0 ? item.totalValue / item.totalHours : 0,
amount: item.totalValue,
),
)
.toList();
});
}
@override
Future<void> approveInvoice({required String id}) async {
return _service.run(() async {
await _service.connector
.updateInvoice(id: id)
.status(dc.InvoiceStatus.APPROVED)
.execute();
});
}
@override
Future<void> disputeInvoice({
required String id,
required String reason,
}) async {
return _service.run(() async {
await _service.connector
.updateInvoice(id: id)
.status(dc.InvoiceStatus.DISPUTED)
.disputeReason(reason)
.execute();
});
}
// --- MAPPERS ---
Invoice _mapInvoice(dynamic invoice) {
List<InvoiceWorker> workers = <InvoiceWorker>[];
// Try to get workers from denormalized 'roles' field first
final List<dynamic> rolesData = invoice.roles is List
? invoice.roles
: <dynamic>[];
if (rolesData.isNotEmpty) {
workers = rolesData.map((dynamic r) {
final Map<String, dynamic> role = r as Map<String, dynamic>;
// Handle various possible key naming conventions in the JSON data
final String name =
role['name'] ?? role['staffName'] ?? role['fullName'] ?? 'Unknown';
final String roleTitle =
role['role'] ?? role['roleName'] ?? role['title'] ?? 'Staff';
final double amount =
(role['amount'] as num?)?.toDouble() ??
(role['totalValue'] as num?)?.toDouble() ??
0.0;
final double hours =
(role['hours'] as num?)?.toDouble() ??
(role['workHours'] as num?)?.toDouble() ??
(role['totalHours'] as num?)?.toDouble() ??
0.0;
final double rate =
(role['rate'] as num?)?.toDouble() ??
(role['hourlyRate'] as num?)?.toDouble() ??
0.0;
final dynamic checkInVal =
role['checkInTime'] ?? role['startTime'] ?? role['check_in_time'];
final dynamic checkOutVal =
role['checkOutTime'] ?? role['endTime'] ?? role['check_out_time'];
return InvoiceWorker(
name: name,
role: roleTitle,
amount: amount,
hours: hours,
rate: rate,
checkIn: _service.toDateTime(checkInVal),
checkOut: _service.toDateTime(checkOutVal),
breakMinutes: role['breakMinutes'] ?? role['break_minutes'] ?? 0,
avatarUrl:
role['avatarUrl'] ?? role['photoUrl'] ?? role['staffPhoto'],
);
}).toList();
}
// Fallback: If roles is empty, try to get workers from shift applications
else if (invoice.shift != null &&
invoice.shift.applications_on_shift != null) {
final List<dynamic> apps = invoice.shift.applications_on_shift;
workers = apps.map((dynamic app) {
final String name = app.staff?.fullName ?? 'Unknown';
final String roleTitle = app.shiftRole?.role?.name ?? 'Staff';
final double amount =
(app.shiftRole?.totalValue as num?)?.toDouble() ?? 0.0;
final double hours = (app.shiftRole?.hours as num?)?.toDouble() ?? 0.0;
// Calculate rate if not explicitly provided
double rate = 0.0;
if (hours > 0) {
rate = amount / hours;
}
// Map break type to minutes
int breakMin = 0;
final String? breakType = app.shiftRole?.breakType?.toString();
if (breakType != null) {
if (breakType.contains('10'))
breakMin = 10;
else if (breakType.contains('15'))
breakMin = 15;
else if (breakType.contains('30'))
breakMin = 30;
else if (breakType.contains('45'))
breakMin = 45;
else if (breakType.contains('60'))
breakMin = 60;
}
return InvoiceWorker(
name: name,
role: roleTitle,
amount: amount,
hours: hours,
rate: rate,
checkIn: _service.toDateTime(app.checkInTime),
checkOut: _service.toDateTime(app.checkOutTime),
breakMinutes: breakMin,
avatarUrl: app.staff?.photoUrl,
);
}).toList();
}
return Invoice(
id: invoice.id,
eventId: invoice.orderId,
businessId: invoice.businessId,
status: _mapInvoiceStatus(invoice.status.stringValue),
totalAmount: invoice.amount,
workAmount: invoice.amount,
addonsAmount: invoice.otherCharges ?? 0,
invoiceNumber: invoice.invoiceNumber,
issueDate: _service.toDateTime(invoice.issueDate)!,
title: invoice.order?.eventName,
clientName: invoice.business?.businessName,
locationAddress:
invoice.order?.teamHub?.hubName ?? invoice.order?.teamHub?.address,
staffCount:
invoice.staffCount ?? (workers.isNotEmpty ? workers.length : 0),
totalHours: _calculateTotalHours(rolesData),
workers: workers,
);
}
double _calculateTotalHours(List<dynamic> roles) {
return roles.fold<double>(0.0, (sum, role) {
final hours = role['hours'] ?? role['workHours'] ?? role['totalHours'];
if (hours is num) return sum + hours.toDouble();
return sum;
});
}
BusinessBankAccount _mapBankAccount(dynamic account) {
return BusinessBankAccountAdapter.fromPrimitives(
id: account.id,
bank: account.bank,
last4: account.last4,
isPrimary: account.isPrimary ?? false,
expiryTime: _service.toDateTime(account.expiryTime),
);
}
InvoiceStatus _mapInvoiceStatus(String status) {
switch (status) {
case 'PAID':
return InvoiceStatus.paid;
case 'OVERDUE':
return InvoiceStatus.overdue;
case 'DISPUTED':
return InvoiceStatus.disputed;
case 'APPROVED':
return InvoiceStatus.verified;
default:
return InvoiceStatus.open;
}
}
}
class _RoleSummary {
const _RoleSummary({
required this.roleId,
required this.roleName,
required this.totalHours,
required this.totalValue,
});
final String roleId;
final String roleName;
final double totalHours;
final double totalValue;
_RoleSummary copyWith({double? totalHours, double? totalValue}) {
return _RoleSummary(
roleId: roleId,
roleName: roleName,
totalHours: totalHours ?? this.totalHours,
totalValue: totalValue ?? this.totalValue,
);
}
}

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,797 +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:intl/intl.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/shifts_connector_repository.dart';
/// Implementation of [ShiftsConnectorRepository].
///
/// Handles shift-related data operations by interacting with Data Connect.
class ShiftsConnectorRepositoryImpl implements ShiftsConnectorRepository {
/// Creates a new [ShiftsConnectorRepositoryImpl].
ShiftsConnectorRepositoryImpl({dc.DataConnectService? service})
: _service = service ?? dc.DataConnectService.instance;
final dc.DataConnectService _service;
@override
Future<List<Shift>> getMyShifts({
required String staffId,
required DateTime start,
required DateTime end,
}) async {
return _service.run(() async {
final dc.GetApplicationsByStaffIdVariablesBuilder query = _service
.connector
.getApplicationsByStaffId(staffId: staffId)
.dayStart(_service.toTimestamp(start))
.dayEnd(_service.toTimestamp(end));
final QueryResult<
dc.GetApplicationsByStaffIdData,
dc.GetApplicationsByStaffIdVariables
>
response = await query.execute();
return _mapApplicationsToShifts(response.data.applications);
});
}
@override
Future<List<Shift>> getAvailableShifts({
required String staffId,
String? query,
String? type,
}) async {
return _service.run(() async {
// First, fetch all available shift roles for the vendor/business
// Use the session owner ID (vendorId)
final String? vendorId = dc.StaffSessionStore.instance.session?.ownerId;
if (vendorId == null || vendorId.isEmpty) return <Shift>[];
final QueryResult<
dc.ListShiftRolesByVendorIdData,
dc.ListShiftRolesByVendorIdVariables
>
response = await _service.connector
.listShiftRolesByVendorId(vendorId: vendorId)
.execute();
final List<dc.ListShiftRolesByVendorIdShiftRoles> allShiftRoles =
response.data.shiftRoles;
// Fetch current applications to filter out already booked shifts
final QueryResult<
dc.GetApplicationsByStaffIdData,
dc.GetApplicationsByStaffIdVariables
>
myAppsResponse = await _service.connector
.getApplicationsByStaffId(staffId: staffId)
.execute();
final Set<String> appliedShiftIds = myAppsResponse.data.applications
.map((dc.GetApplicationsByStaffIdApplications a) => a.shiftId)
.toSet();
final List<Shift> mappedShifts = <Shift>[];
for (final dc.ListShiftRolesByVendorIdShiftRoles sr in allShiftRoles) {
if (appliedShiftIds.contains(sr.shiftId)) continue;
final DateTime? shiftDate = _service.toDateTime(sr.shift.date);
final DateTime? startDt = _service.toDateTime(sr.startTime);
final DateTime? endDt = _service.toDateTime(sr.endTime);
final DateTime? createdDt = _service.toDateTime(sr.createdAt);
// Normalise orderType to uppercase for consistent checks in the UI.
// RECURRING → groups shifts into Multi-Day cards.
// PERMANENT → groups shifts into Long Term cards.
final String orderTypeStr = sr.shift.order.orderType.stringValue
.toUpperCase();
final dc.ListShiftRolesByVendorIdShiftRolesShiftOrder order =
sr.shift.order;
final DateTime? startDate = _service.toDateTime(order.startDate);
final DateTime? endDate = _service.toDateTime(order.endDate);
final String startTime = startDt != null
? DateFormat('HH:mm').format(startDt)
: '';
final String endTime = endDt != null
? DateFormat('HH:mm').format(endDt)
: '';
final List<ShiftSchedule>? schedules = _generateSchedules(
orderType: orderTypeStr,
startDate: startDate,
endDate: endDate,
recurringDays: order.recurringDays,
permanentDays: order.permanentDays,
startTime: startTime,
endTime: endTime,
);
mappedShifts.add(
Shift(
id: sr.shiftId,
roleId: sr.roleId,
title: sr.role.name,
clientName: sr.shift.order.business.businessName,
logoUrl: null,
hourlyRate: sr.role.costPerHour,
location: sr.shift.location ?? '',
locationAddress: sr.shift.locationAddress ?? '',
date: shiftDate?.toIso8601String() ?? '',
startTime: startTime,
endTime: endTime,
createdDate: createdDt?.toIso8601String() ?? '',
status: sr.shift.status?.stringValue.toLowerCase() ?? 'open',
description: sr.shift.description,
durationDays: sr.shift.durationDays ?? schedules?.length,
requiredSlots: sr.count,
filledSlots: sr.assigned ?? 0,
latitude: sr.shift.latitude,
longitude: sr.shift.longitude,
// orderId + orderType power the grouping and type-badge logic in
// FindShiftsTab._groupMultiDayShifts and MyShiftCard._getShiftType.
orderId: sr.shift.orderId,
orderType: orderTypeStr,
startDate: startDate?.toIso8601String(),
endDate: endDate?.toIso8601String(),
recurringDays: sr.shift.order.recurringDays,
permanentDays: sr.shift.order.permanentDays,
schedules: schedules,
breakInfo: BreakAdapter.fromData(
isPaid: sr.isBreakPaid ?? false,
breakTime: sr.breakType?.stringValue,
),
),
);
}
if (query != null && query.isNotEmpty) {
final String lowerQuery = query.toLowerCase();
return mappedShifts.where((Shift s) {
return s.title.toLowerCase().contains(lowerQuery) ||
s.clientName.toLowerCase().contains(lowerQuery);
}).toList();
}
return mappedShifts;
});
}
@override
Future<List<Shift>> getPendingAssignments({required String staffId}) async {
return _service.run(() async {
// Current schema doesn't have a specific "pending assignment" query that differs from confirmed
// unless we filter by status. In the old repo it was returning an empty list.
return <Shift>[];
});
}
@override
Future<Shift?> getShiftDetails({
required String shiftId,
required String staffId,
String? roleId,
}) async {
return _service.run(() async {
if (roleId != null && roleId.isNotEmpty) {
final QueryResult<dc.GetShiftRoleByIdData, dc.GetShiftRoleByIdVariables>
roleResult = await _service.connector
.getShiftRoleById(shiftId: shiftId, roleId: roleId)
.execute();
final dc.GetShiftRoleByIdShiftRole? sr = roleResult.data.shiftRole;
if (sr == null) return null;
final DateTime? startDt = _service.toDateTime(sr.startTime);
final DateTime? endDt = _service.toDateTime(sr.endTime);
final DateTime? createdDt = _service.toDateTime(sr.createdAt);
bool hasApplied = false;
String status = 'open';
final QueryResult<
dc.GetApplicationsByStaffIdData,
dc.GetApplicationsByStaffIdVariables
>
appsResponse = await _service.connector
.getApplicationsByStaffId(staffId: staffId)
.execute();
final dc.GetApplicationsByStaffIdApplications? app = appsResponse
.data
.applications
.where(
(dc.GetApplicationsByStaffIdApplications a) =>
a.shiftId == shiftId && a.shiftRole.roleId == roleId,
)
.firstOrNull;
if (app != null) {
hasApplied = true;
final String s = app.status.stringValue;
status = _mapApplicationStatus(s);
}
return Shift(
id: sr.shiftId,
roleId: sr.roleId,
title: sr.shift.order.business.businessName,
clientName: sr.shift.order.business.businessName,
logoUrl: sr.shift.order.business.companyLogoUrl,
hourlyRate: sr.role.costPerHour,
location: sr.shift.location ?? sr.shift.order.teamHub.hubName,
locationAddress: sr.shift.locationAddress ?? '',
date: startDt?.toIso8601String() ?? '',
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
createdDate: createdDt?.toIso8601String() ?? '',
status: status,
description: sr.shift.description,
durationDays: null,
requiredSlots: sr.count,
filledSlots: sr.assigned ?? 0,
hasApplied: hasApplied,
totalValue: sr.totalValue,
latitude: sr.shift.latitude,
longitude: sr.shift.longitude,
breakInfo: BreakAdapter.fromData(
isPaid: sr.isBreakPaid ?? false,
breakTime: sr.breakType?.stringValue,
),
);
}
final QueryResult<dc.GetShiftByIdData, dc.GetShiftByIdVariables> result =
await _service.connector.getShiftById(id: shiftId).execute();
final dc.GetShiftByIdShift? s = result.data.shift;
if (s == null) return null;
int? required;
int? filled;
Break? breakInfo;
try {
final QueryResult<
dc.ListShiftRolesByShiftIdData,
dc.ListShiftRolesByShiftIdVariables
>
rolesRes = await _service.connector
.listShiftRolesByShiftId(shiftId: shiftId)
.execute();
if (rolesRes.data.shiftRoles.isNotEmpty) {
required = 0;
filled = 0;
for (dc.ListShiftRolesByShiftIdShiftRoles r
in rolesRes.data.shiftRoles) {
required = (required ?? 0) + r.count;
filled = (filled ?? 0) + (r.assigned ?? 0);
}
final dc.ListShiftRolesByShiftIdShiftRoles firstRole =
rolesRes.data.shiftRoles.first;
breakInfo = BreakAdapter.fromData(
isPaid: firstRole.isBreakPaid ?? false,
breakTime: firstRole.breakType?.stringValue,
);
}
} catch (_) {}
final DateTime? startDt = _service.toDateTime(s.startTime);
final DateTime? endDt = _service.toDateTime(s.endTime);
final DateTime? createdDt = _service.toDateTime(s.createdAt);
return Shift(
id: s.id,
title: s.title,
clientName: s.order.business.businessName,
logoUrl: null,
hourlyRate: s.cost ?? 0.0,
location: s.location ?? '',
locationAddress: s.locationAddress ?? '',
date: startDt?.toIso8601String() ?? '',
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
createdDate: createdDt?.toIso8601String() ?? '',
status: s.status?.stringValue ?? 'OPEN',
description: s.description,
durationDays: s.durationDays,
requiredSlots: required,
filledSlots: filled,
latitude: s.latitude,
longitude: s.longitude,
breakInfo: breakInfo,
);
});
}
@override
Future<void> applyForShift({
required String shiftId,
required String staffId,
bool isInstantBook = false,
String? roleId,
}) async {
return _service.run(() async {
final String targetRoleId = roleId ?? '';
if (targetRoleId.isEmpty) throw Exception('Missing role id.');
// 1. Fetch the initial shift to determine order type
final QueryResult<dc.GetShiftByIdData, dc.GetShiftByIdVariables>
shiftResult = await _service.connector
.getShiftById(id: shiftId)
.execute();
final dc.GetShiftByIdShift? initialShift = shiftResult.data.shift;
if (initialShift == null) throw Exception('Shift not found');
final dc.EnumValue<dc.OrderType> orderTypeEnum =
initialShift.order.orderType;
final bool isMultiDay =
orderTypeEnum is dc.Known<dc.OrderType> &&
(orderTypeEnum.value == dc.OrderType.RECURRING ||
orderTypeEnum.value == dc.OrderType.PERMANENT);
final List<_TargetShiftRole> targets = [];
if (isMultiDay) {
// 2. Fetch all shifts for this order to apply to all of them for the same role
final QueryResult<
dc.ListShiftRolesByBusinessAndOrderData,
dc.ListShiftRolesByBusinessAndOrderVariables
>
allRolesRes = await _service.connector
.listShiftRolesByBusinessAndOrder(
businessId: initialShift.order.businessId,
orderId: initialShift.orderId,
)
.execute();
for (final role in allRolesRes.data.shiftRoles) {
if (role.roleId == targetRoleId) {
targets.add(
_TargetShiftRole(
shiftId: role.shiftId,
roleId: role.roleId,
count: role.count,
assigned: role.assigned ?? 0,
shiftFilled: role.shift.filled ?? 0,
date: _service.toDateTime(role.shift.date),
),
);
}
}
} else {
// Single shift application
final QueryResult<dc.GetShiftRoleByIdData, dc.GetShiftRoleByIdVariables>
roleResult = await _service.connector
.getShiftRoleById(shiftId: shiftId, roleId: targetRoleId)
.execute();
final dc.GetShiftRoleByIdShiftRole? role = roleResult.data.shiftRole;
if (role == null) throw Exception('Shift role not found');
targets.add(
_TargetShiftRole(
shiftId: shiftId,
roleId: targetRoleId,
count: role.count,
assigned: role.assigned ?? 0,
shiftFilled: initialShift.filled ?? 0,
date: _service.toDateTime(initialShift.date),
),
);
}
if (targets.isEmpty) {
throw Exception('No valid shifts found to apply for.');
}
int appliedCount = 0;
final List<String> errors = [];
for (final target in targets) {
try {
await _applyToSingleShiftRole(target: target, staffId: staffId);
appliedCount++;
} catch (e) {
// For multi-shift apply, we might want to continue even if some fail due to conflicts
if (targets.length == 1) rethrow;
errors.add('Shift on ${target.date}: ${e.toString()}');
}
}
if (appliedCount == 0 && targets.length > 1) {
throw Exception('Failed to apply for any shifts: ${errors.join(", ")}');
}
});
}
Future<void> _applyToSingleShiftRole({
required _TargetShiftRole target,
required String staffId,
}) async {
// Validate daily limit
if (target.date != null) {
final DateTime dayStartUtc = DateTime.utc(
target.date!.year,
target.date!.month,
target.date!.day,
);
final DateTime dayEndUtc = dayStartUtc
.add(const Duration(days: 1))
.subtract(const Duration(microseconds: 1));
final QueryResult<
dc.VaidateDayStaffApplicationData,
dc.VaidateDayStaffApplicationVariables
>
validationResponse = await _service.connector
.vaidateDayStaffApplication(staffId: staffId)
.dayStart(_service.toTimestamp(dayStartUtc))
.dayEnd(_service.toTimestamp(dayEndUtc))
.execute();
// if (validationResponse.data.applications.isNotEmpty) {
// throw Exception('The user already has a shift that day.');
// }
}
// Check for existing application
final QueryResult<
dc.GetApplicationByStaffShiftAndRoleData,
dc.GetApplicationByStaffShiftAndRoleVariables
>
existingAppRes = await _service.connector
.getApplicationByStaffShiftAndRole(
staffId: staffId,
shiftId: target.shiftId,
roleId: target.roleId,
)
.execute();
if (existingAppRes.data.applications.isNotEmpty) {
throw Exception('Application already exists.');
}
if (target.assigned >= target.count) {
throw Exception('This shift is full.');
}
String? createdAppId;
try {
final OperationResult<
dc.CreateApplicationData,
dc.CreateApplicationVariables
>
createRes = await _service.connector
.createApplication(
shiftId: target.shiftId,
staffId: staffId,
roleId: target.roleId,
status: dc.ApplicationStatus.CONFIRMED,
origin: dc.ApplicationOrigin.STAFF,
)
.execute();
createdAppId = createRes.data.application_insert.id;
await _service.connector
.updateShiftRole(shiftId: target.shiftId, roleId: target.roleId)
.assigned(target.assigned + 1)
.execute();
await _service.connector
.updateShift(id: target.shiftId)
.filled(target.shiftFilled + 1)
.execute();
} catch (e) {
// Simple rollback attempt (not guaranteed)
if (createdAppId != null) {
await _service.connector.deleteApplication(id: createdAppId).execute();
}
rethrow;
}
}
@override
Future<void> acceptShift({required String shiftId, required String staffId}) {
return _updateApplicationStatus(
shiftId,
staffId,
dc.ApplicationStatus.CONFIRMED,
);
}
@override
Future<void> declineShift({
required String shiftId,
required String staffId,
}) {
return _updateApplicationStatus(
shiftId,
staffId,
dc.ApplicationStatus.REJECTED,
);
}
@override
Future<List<Shift>> getCancelledShifts({required String staffId}) async {
return _service.run(() async {
// Logic would go here to fetch by REJECTED status if needed
return <Shift>[];
});
}
@override
Future<List<Shift>> getHistoryShifts({required String staffId}) async {
return _service.run(() async {
final QueryResult<
dc.ListCompletedApplicationsByStaffIdData,
dc.ListCompletedApplicationsByStaffIdVariables
>
response = await _service.connector
.listCompletedApplicationsByStaffId(staffId: staffId)
.execute();
final List<Shift> shifts = <Shift>[];
for (final dc.ListCompletedApplicationsByStaffIdApplications app
in response.data.applications) {
final String roleName = app.shiftRole.role.name;
final String orderName =
(app.shift.order.eventName ?? '').trim().isNotEmpty
? app.shift.order.eventName!
: app.shift.order.business.businessName;
final String title = '$roleName - $orderName';
final DateTime? shiftDate = _service.toDateTime(app.shift.date);
final DateTime? startDt = _service.toDateTime(app.shiftRole.startTime);
final DateTime? endDt = _service.toDateTime(app.shiftRole.endTime);
final DateTime? createdDt = _service.toDateTime(app.createdAt);
shifts.add(
Shift(
id: app.shift.id,
roleId: app.shiftRole.roleId,
title: title,
clientName: app.shift.order.business.businessName,
logoUrl: app.shift.order.business.companyLogoUrl,
hourlyRate: app.shiftRole.role.costPerHour,
location: app.shift.location ?? '',
locationAddress: app.shift.order.teamHub.hubName,
date: shiftDate?.toIso8601String() ?? '',
startTime: startDt != null
? DateFormat('HH:mm').format(startDt)
: '',
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
createdDate: createdDt?.toIso8601String() ?? '',
status: 'completed', // Hardcoded as checked out implies completion
description: app.shift.description,
durationDays: app.shift.durationDays,
requiredSlots: app.shiftRole.count,
filledSlots: app.shiftRole.assigned ?? 0,
hasApplied: true,
latitude: app.shift.latitude,
longitude: app.shift.longitude,
breakInfo: BreakAdapter.fromData(
isPaid: app.shiftRole.isBreakPaid ?? false,
breakTime: app.shiftRole.breakType?.stringValue,
),
),
);
}
return shifts;
});
}
// --- PRIVATE HELPERS ---
List<Shift> _mapApplicationsToShifts(List<dynamic> apps) {
return apps.map((app) {
final String roleName = app.shiftRole.role.name;
final String orderName =
(app.shift.order.eventName ?? '').trim().isNotEmpty
? app.shift.order.eventName!
: app.shift.order.business.businessName;
final String title = '$roleName - $orderName';
final DateTime? shiftDate = _service.toDateTime(app.shift.date);
final DateTime? startDt = _service.toDateTime(app.shiftRole.startTime);
final DateTime? endDt = _service.toDateTime(app.shiftRole.endTime);
final DateTime? createdDt = _service.toDateTime(app.createdAt);
final bool hasCheckIn = app.checkInTime != null;
final bool hasCheckOut = app.checkOutTime != null;
String status;
if (hasCheckOut) {
status = 'completed';
} else if (hasCheckIn) {
status = 'checked_in';
} else {
status = _mapApplicationStatus(app.status.stringValue);
}
return Shift(
id: app.shift.id,
roleId: app.shiftRole.roleId,
title: title,
clientName: app.shift.order.business.businessName,
logoUrl: app.shift.order.business.companyLogoUrl,
hourlyRate: app.shiftRole.role.costPerHour,
location: app.shift.location ?? '',
locationAddress: app.shift.order.teamHub.hubName,
date: shiftDate?.toIso8601String() ?? '',
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
createdDate: createdDt?.toIso8601String() ?? '',
status: status,
description: app.shift.description,
durationDays: app.shift.durationDays,
requiredSlots: app.shiftRole.count,
filledSlots: app.shiftRole.assigned ?? 0,
hasApplied: true,
latitude: app.shift.latitude,
longitude: app.shift.longitude,
breakInfo: BreakAdapter.fromData(
isPaid: app.shiftRole.isBreakPaid ?? false,
breakTime: app.shiftRole.breakType?.stringValue,
),
);
}).toList();
}
String _mapApplicationStatus(String status) {
switch (status) {
case 'CONFIRMED':
return 'confirmed';
case 'PENDING':
return 'pending';
case 'CHECKED_OUT':
return 'completed';
case 'REJECTED':
return 'cancelled';
default:
return 'open';
}
}
Future<void> _updateApplicationStatus(
String shiftId,
String staffId,
dc.ApplicationStatus newStatus,
) async {
return _service.run(() async {
// First try to find the application
final QueryResult<
dc.GetApplicationsByStaffIdData,
dc.GetApplicationsByStaffIdVariables
>
appsResponse = await _service.connector
.getApplicationsByStaffId(staffId: staffId)
.execute();
final dc.GetApplicationsByStaffIdApplications? app = appsResponse
.data
.applications
.where(
(dc.GetApplicationsByStaffIdApplications a) => a.shiftId == shiftId,
)
.firstOrNull;
if (app != null) {
await _service.connector
.updateApplicationStatus(id: app.id)
.status(newStatus)
.execute();
} else if (newStatus == dc.ApplicationStatus.REJECTED) {
// If declining but no app found, create a rejected application
final QueryResult<
dc.ListShiftRolesByShiftIdData,
dc.ListShiftRolesByShiftIdVariables
>
rolesRes = await _service.connector
.listShiftRolesByShiftId(shiftId: shiftId)
.execute();
if (rolesRes.data.shiftRoles.isNotEmpty) {
final dc.ListShiftRolesByShiftIdShiftRoles firstRole =
rolesRes.data.shiftRoles.first;
await _service.connector
.createApplication(
shiftId: shiftId,
staffId: staffId,
roleId: firstRole.id,
status: dc.ApplicationStatus.REJECTED,
origin: dc.ApplicationOrigin.STAFF,
)
.execute();
}
} else {
throw Exception("Application not found for shift $shiftId");
}
});
}
/// Generates a list of [ShiftSchedule] for RECURRING or PERMANENT orders.
List<ShiftSchedule>? _generateSchedules({
required String orderType,
required DateTime? startDate,
required DateTime? endDate,
required List<String>? recurringDays,
required List<String>? permanentDays,
required String startTime,
required String endTime,
}) {
if (orderType != 'RECURRING' && orderType != 'PERMANENT') return null;
if (startDate == null || endDate == null) return null;
final List<String>? daysToInclude = orderType == 'RECURRING'
? recurringDays
: permanentDays;
if (daysToInclude == null || daysToInclude.isEmpty) return null;
final List<ShiftSchedule> schedules = <ShiftSchedule>[];
final Set<int> targetWeekdayIndex = daysToInclude
.map((String day) {
switch (day.toUpperCase()) {
case 'MONDAY':
return DateTime.monday;
case 'TUESDAY':
return DateTime.tuesday;
case 'WEDNESDAY':
return DateTime.wednesday;
case 'THURSDAY':
return DateTime.thursday;
case 'FRIDAY':
return DateTime.friday;
case 'SATURDAY':
return DateTime.saturday;
case 'SUNDAY':
return DateTime.sunday;
default:
return -1;
}
})
.where((int idx) => idx != -1)
.toSet();
DateTime current = startDate;
while (current.isBefore(endDate) ||
current.isAtSameMomentAs(endDate) ||
// Handle cases where the time component might differ slightly by checking date equality
(current.year == endDate.year &&
current.month == endDate.month &&
current.day == endDate.day)) {
if (targetWeekdayIndex.contains(current.weekday)) {
schedules.add(
ShiftSchedule(
date: current.toIso8601String(),
startTime: startTime,
endTime: endTime,
),
);
}
current = current.add(const Duration(days: 1));
// Safety break to prevent infinite loops if dates are messed up
if (schedules.length > 365) break;
}
return schedules;
}
}
class _TargetShiftRole {
final String shiftId;
final String roleId;
final int count;
final int assigned;
final int shiftFilled;
final DateTime? date;
_TargetShiftRole({
required this.shiftId,
required this.roleId,
required this.count,
required this.assigned,
required this.shiftFilled,
this.date,
});
}

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

@@ -82,6 +82,7 @@ class UiChip extends StatelessWidget {
final Row content = Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
if (leadingIcon != null) ...<Widget>[
Icon(leadingIcon, size: iconSize, color: contentColor),

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,26 @@ 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,
),
overflow: TextOverflow.ellipsis,
),
],
],
),
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 +108,6 @@ class UiNoticeBanner extends StatelessWidget {
],
],
),
),
],
),
);

View File

@@ -6,7 +6,27 @@
/// 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/review_issue_flag.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 +42,91 @@ 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/benefits/benefit_history.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,
);
}
}

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