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:
@@ -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)
|
- `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
|
- 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
|
## Design System Tokens
|
||||||
- Colors: `UiColors.*`
|
- Colors: `UiColors.*`
|
||||||
- Typography: `UiTypography.*`
|
- Typography: `UiTypography.*`
|
||||||
- Spacing: `UiConstants.space*` (e.g., `space3`, `space4`, `space6`)
|
- Spacing: `UiConstants.space*` (e.g., `space3`, `space4`, `space6`)
|
||||||
- App bar: `UiAppBar`
|
- 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)
|
## Review Patterns (grep-based checks)
|
||||||
- `Color(0x` for hardcoded colors
|
- `Color(0x` for hardcoded colors
|
||||||
- `TextStyle(` for custom text styles
|
- `TextStyle(` for custom text styles
|
||||||
|
|||||||
@@ -52,4 +52,175 @@
|
|||||||
- BenefitsOverviewPage also has CircularProgressIndicator (not shimmer-ified yet)
|
- 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
|
- 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
|
- 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
|
||||||
|
|||||||
@@ -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()`
|
||||||
3
.claude/agent-memory/mobile-feature-builder/MEMORY.md
Normal file
3
.claude/agent-memory/mobile-feature-builder/MEMORY.md
Normal 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
|
||||||
@@ -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`.
|
||||||
@@ -2,3 +2,5 @@
|
|||||||
|
|
||||||
## Project Context
|
## 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_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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
7
.claude/agent-memory/ui-ux-design/MEMORY.md
Normal file
7
.claude/agent-memory/ui-ux-design/MEMORY.md
Normal 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
|
||||||
87
.claude/agent-memory/ui-ux-design/component-patterns.md
Normal file
87
.claude/agent-memory/ui-ux-design/component-patterns.md
Normal 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.
|
||||||
22
.claude/agent-memory/ui-ux-design/design-gaps.md
Normal file
22
.claude/agent-memory/ui-ux-design/design-gaps.md
Normal 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.
|
||||||
102
.claude/agent-memory/ui-ux-design/design-system-tokens.md
Normal file
102
.claude/agent-memory/ui-ux-design/design-system-tokens.md
Normal 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.
|
||||||
247
.claude/agents/bug-reporter.md
Normal file
247
.claude/agents/bug-reporter.md
Normal 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.
|
||||||
@@ -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)
|
- Ensuring business logic lives in use cases (not BLoCs/widgets)
|
||||||
- Flagging design system violations (hardcoded colors, TextStyle, spacing, icons)
|
- Flagging design system violations (hardcoded colors, TextStyle, spacing, icons)
|
||||||
- Validating BLoC pattern usage (SessionHandlerMixin, BlocErrorHandler, singleton registration)
|
- 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)
|
- Ensuring safe navigation extensions are used (no direct Navigator usage)
|
||||||
- Verifying test coverage for business logic
|
- Verifying test coverage for business logic
|
||||||
- Checking documentation on public APIs
|
- Checking documentation on public APIs
|
||||||
@@ -205,6 +206,7 @@ Produce a structured report in this exact format:
|
|||||||
|------|--------|---------|
|
|------|--------|---------|
|
||||||
| Design System | ✅/❌ | [details] |
|
| Design System | ✅/❌ | [details] |
|
||||||
| Architecture Boundaries | ✅/❌ | [details] |
|
| Architecture Boundaries | ✅/❌ | [details] |
|
||||||
|
| DI / CoreModule Imports | ✅/❌ | [Every module using BaseApiService must import CoreModule] |
|
||||||
| State Management | ✅/❌ | [details] |
|
| State Management | ✅/❌ | [details] |
|
||||||
| Navigation | ✅/❌ | [details] |
|
| Navigation | ✅/❌ | [details] |
|
||||||
| Testing Coverage | ✅/❌ | [estimated %] |
|
| Testing Coverage | ✅/❌ | [estimated %] |
|
||||||
|
|||||||
@@ -43,10 +43,11 @@ If any of these files are missing or unreadable, notify the user before proceedi
|
|||||||
- Import icon libraries directly — use `UiIcons`
|
- Import icon libraries directly — use `UiIcons`
|
||||||
- Use `Navigator.push` directly — use Modular safe extensions
|
- Use `Navigator.push` directly — use Modular safe extensions
|
||||||
- Navigate without home fallback
|
- 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
|
- Skip tests for business logic
|
||||||
|
|
||||||
### ALWAYS:
|
### 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';`
|
- **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
|
- 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/`
|
- 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
|
## Standard Workflow
|
||||||
|
|
||||||
Follow these steps in order for every feature implementation:
|
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
|
- Create barrel file exporting the domain public API
|
||||||
|
|
||||||
### 4. Data Layer
|
### 4. Data Layer
|
||||||
- Create models with `fromJson`/`toJson` methods
|
- Implement repository classes using `ApiService` with `V2ApiEndpoints`
|
||||||
- Implement repository classes using `DataConnectService`
|
- Parse V2 API JSON responses into domain entities via `Entity.fromJson()`
|
||||||
- Map errors to domain `Failure` types
|
- Map errors to domain `Failure` types
|
||||||
- Create barrel file for data layer
|
- 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:
|
As you work on features, update your agent memory with discoveries about:
|
||||||
- Existing feature patterns and conventions in the codebase
|
- Existing feature patterns and conventions in the codebase
|
||||||
- Session store usage patterns and available stores
|
- 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
|
- Design token values and component patterns actually in use
|
||||||
- Module registration patterns and route conventions
|
- Module registration patterns and route conventions
|
||||||
- Recurring issues found during `melos analyze`
|
- Recurring issues found during `melos analyze`
|
||||||
|
|||||||
@@ -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.
|
- **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).
|
- **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.
|
- **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.
|
- **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**: `SessionHandlerMixin` + `SessionListener` widget.
|
- **Session Management**: `V2SessionService` + `SessionHandlerMixin` + `SessionListener` widget. Session stores (`StaffSessionStore`, `ClientSessionStore`) in `core`.
|
||||||
- **Localization**: Slang (`t.section.key`), not `context.strings`.
|
- **Localization**: Slang (`t.section.key`), not `context.strings`.
|
||||||
- **Design System**: Tokens from `UiColors`, `UiTypography`, `UiConstants`. No hardcoded values.
|
- **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
|
- **API integration issues**: Missing error handling, incorrect data mapping, async issues
|
||||||
- **Performance concerns**: Inefficient algorithms, unnecessary rebuilds, memory problems
|
- **Performance concerns**: Inefficient algorithms, unnecessary rebuilds, memory problems
|
||||||
- **Security vulnerabilities**: Hardcoded credentials, insecure data storage, authentication gaps
|
- **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
|
- **Data persistence issues**: Cache invalidation, concurrent access
|
||||||
|
|
||||||
## Analysis Methodology
|
## Analysis Methodology
|
||||||
@@ -64,7 +64,7 @@ Detect potential bugs including:
|
|||||||
1. Map the feature's architecture and key screens
|
1. Map the feature's architecture and key screens
|
||||||
2. Identify critical user flows and navigation paths
|
2. Identify critical user flows and navigation paths
|
||||||
3. Review state management implementation (BLoC states, events, transitions)
|
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
|
5. Document assumptions and expected behaviors
|
||||||
|
|
||||||
### Phase 2: Use Case Extraction
|
### Phase 2: Use Case Extraction
|
||||||
@@ -108,7 +108,7 @@ Analyze code for:
|
|||||||
- Missing error handling in `.then()` chains
|
- Missing error handling in `.then()` chains
|
||||||
- Mounted checks missing in async callbacks
|
- Mounted checks missing in async callbacks
|
||||||
- Race conditions in concurrent requests
|
- Race conditions in concurrent requests
|
||||||
- Missing `_service.run()` wrapper for Data Connect calls
|
- Missing `ApiErrorHandler.executeProtected()` wrapper for API calls
|
||||||
|
|
||||||
### Background Tasks & WorkManager
|
### Background Tasks & WorkManager
|
||||||
When reviewing code that uses WorkManager or background task scheduling, check these edge cases:
|
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
|
### Architecture Rules
|
||||||
- Features importing other features directly
|
- Features importing other features directly
|
||||||
- Business logic in BLoCs or widgets instead of Use Cases
|
- 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>()`
|
- `context.read<T>()` instead of `ReadContext(context).read<T>()`
|
||||||
|
|
||||||
## Output Format
|
## Output Format
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: krow-mobile-architecture
|
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
|
# 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
|
- Debugging state management or BLoC lifecycle issues
|
||||||
- Preventing prop drilling in UI code
|
- Preventing prop drilling in UI code
|
||||||
- Managing session state and authentication
|
- Managing session state and authentication
|
||||||
- Implementing Data Connect connector repositories
|
- Implementing V2 API repository patterns
|
||||||
- Setting up feature modules and dependency injection
|
- Setting up feature modules and dependency injection
|
||||||
- Understanding package boundaries and dependencies
|
- Understanding package boundaries and dependencies
|
||||||
- Refactoring legacy code to Clean Architecture
|
- Refactoring legacy code to Clean Architecture
|
||||||
@@ -46,13 +46,14 @@ KROW follows **Clean Architecture** in a **Melos Monorepo**. Dependencies flow *
|
|||||||
│ both depend on
|
│ both depend on
|
||||||
┌─────────────────▼───────────────────────────────────────┐
|
┌─────────────────▼───────────────────────────────────────┐
|
||||||
│ Services (Interface Adapters) │
|
│ Services (Interface Adapters) │
|
||||||
│ • data_connect: Backend integration, session mgmt │
|
│ • core: API service, session management, device │
|
||||||
│ • core: Extensions, base classes, utilities │
|
│ services, utilities, extensions, base classes │
|
||||||
└─────────────────┬───────────────────────────────────────┘
|
└─────────────────┬───────────────────────────────────────┘
|
||||||
│ both depend on
|
│ depends on
|
||||||
┌─────────────────▼───────────────────────────────────────┐
|
┌─────────────────▼───────────────────────────────────────┐
|
||||||
│ Domain (Stable Core) │
|
│ Domain (Stable Core) │
|
||||||
│ • Entities (immutable data models) │
|
│ • Entities (data models with fromJson/toJson) │
|
||||||
|
│ • Enums (shared enumerations) │
|
||||||
│ • Failures (domain-specific errors) │
|
│ • Failures (domain-specific errors) │
|
||||||
│ • Pure Dart only, zero Flutter dependencies │
|
│ • Pure Dart only, zero Flutter dependencies │
|
||||||
└─────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────┘
|
||||||
@@ -69,9 +70,9 @@ KROW follows **Clean Architecture** in a **Melos Monorepo**. Dependencies flow *
|
|||||||
**Responsibilities:**
|
**Responsibilities:**
|
||||||
- Initialize Flutter Modular
|
- Initialize Flutter Modular
|
||||||
- Assemble features into navigation tree
|
- 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)
|
- Configure environment-specific settings (dev/stage/prod)
|
||||||
- Initialize session management
|
- Initialize session management via `V2SessionService`
|
||||||
|
|
||||||
**Structure:**
|
**Structure:**
|
||||||
```
|
```
|
||||||
@@ -119,21 +120,22 @@ features/staff/profile/
|
|||||||
**Key Principles:**
|
**Key Principles:**
|
||||||
- **Presentation:** UI Pages and Widgets, BLoCs/Cubits for state
|
- **Presentation:** UI Pages and Widgets, BLoCs/Cubits for state
|
||||||
- **Application:** Use Cases (business logic orchestration)
|
- **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
|
- **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:
|
**RESTRICTION:** Features MUST NOT import other features. Communication happens via:
|
||||||
- Shared domain entities
|
- Shared domain entities
|
||||||
- Session stores (`StaffSessionStore`, `ClientSessionStore`)
|
- Session stores (`StaffSessionStore`, `ClientSessionStore`)
|
||||||
- Navigation via Modular
|
- Navigation via Modular
|
||||||
- Data Connect connector repositories
|
|
||||||
|
|
||||||
### 2.3 Domain (`apps/mobile/packages/domain`)
|
### 2.3 Domain (`apps/mobile/packages/domain`)
|
||||||
|
|
||||||
**Role:** The stable, pure heart of the system
|
**Role:** The stable, pure heart of the system
|
||||||
|
|
||||||
**Responsibilities:**
|
**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)
|
- Define **Failures** (domain-specific error types)
|
||||||
|
|
||||||
**Structure:**
|
**Structure:**
|
||||||
@@ -144,11 +146,17 @@ domain/
|
|||||||
│ ├── entities/
|
│ ├── entities/
|
||||||
│ │ ├── user.dart
|
│ │ ├── user.dart
|
||||||
│ │ ├── staff.dart
|
│ │ ├── staff.dart
|
||||||
│ │ └── shift.dart
|
│ │ ├── shift.dart
|
||||||
│ └── failures/
|
│ │ └── enums/
|
||||||
│ ├── failure.dart # Base class
|
│ │ ├── staff_status.dart
|
||||||
│ ├── auth_failure.dart
|
│ │ └── order_type.dart
|
||||||
│ └── network_failure.dart
|
│ ├── failures/
|
||||||
|
│ │ ├── failure.dart # Base class
|
||||||
|
│ │ ├── auth_failure.dart
|
||||||
|
│ │ └── network_failure.dart
|
||||||
|
│ └── core/
|
||||||
|
│ └── services/api_services/
|
||||||
|
│ └── base_api_service.dart
|
||||||
└── pubspec.yaml
|
└── pubspec.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -169,6 +177,22 @@ class Staff extends Equatable {
|
|||||||
required this.status,
|
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
|
@override
|
||||||
List<Object?> get props => [id, name, email, status];
|
List<Object?> get props => [id, name, email, status];
|
||||||
}
|
}
|
||||||
@@ -176,53 +200,89 @@ class Staff extends Equatable {
|
|||||||
|
|
||||||
**RESTRICTION:**
|
**RESTRICTION:**
|
||||||
- NO Flutter dependencies (no `import 'package:flutter/material.dart'`)
|
- NO Flutter dependencies (no `import 'package:flutter/material.dart'`)
|
||||||
- NO `json_annotation` or serialization code
|
|
||||||
- Only `equatable` for value equality
|
- Only `equatable` for value equality
|
||||||
- Pure Dart only
|
- 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:**
|
**Responsibilities:**
|
||||||
- Centralized connector repositories (see Data Connect Connectors Pattern section)
|
- `ApiService` — HTTP client wrapper around Dio with consistent response/error handling
|
||||||
- Implement Firebase Data Connect service layer
|
- `V2ApiEndpoints` — All V2 REST API endpoint constants
|
||||||
- Map Domain Entities ↔ Data Connect generated code
|
- `DioClient` — Pre-configured Dio with `AuthInterceptor` and `IdempotencyInterceptor`
|
||||||
- Handle Firebase exceptions → domain failures
|
- `AuthInterceptor` — Automatically attaches Firebase Auth ID token to requests
|
||||||
- Provide `DataConnectService` with session management
|
- `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:**
|
**Structure:**
|
||||||
```
|
```
|
||||||
data_connect/
|
core/
|
||||||
├── lib/
|
├── lib/
|
||||||
│ ├── src/
|
│ ├── core.dart # Barrel exports
|
||||||
│ │ ├── services/
|
│ └── src/
|
||||||
│ │ │ ├── data_connect_service.dart # Core service
|
│ ├── config/
|
||||||
│ │ │ └── mixins/
|
│ │ ├── app_config.dart # Env-specific config (V2_API_BASE_URL, etc.)
|
||||||
│ │ │ └── session_handler_mixin.dart
|
│ │ └── app_environment.dart
|
||||||
│ │ ├── connectors/ # Connector pattern (see below)
|
│ ├── services/
|
||||||
│ │ │ ├── staff/
|
│ │ ├── api_service/
|
||||||
│ │ │ │ ├── domain/
|
│ │ │ ├── api_service.dart # ApiService (get/post/put/patch/delete)
|
||||||
│ │ │ │ │ ├── repositories/
|
│ │ │ ├── dio_client.dart # Pre-configured Dio
|
||||||
│ │ │ │ │ │ └── staff_connector_repository.dart
|
│ │ │ ├── inspectors/
|
||||||
│ │ │ │ │ └── usecases/
|
│ │ │ │ ├── auth_interceptor.dart
|
||||||
│ │ │ │ │ └── get_profile_completion_usecase.dart
|
│ │ │ │ └── idempotency_interceptor.dart
|
||||||
│ │ │ │ └── data/
|
│ │ │ ├── mixins/
|
||||||
│ │ │ │ └── repositories/
|
│ │ │ │ ├── api_error_handler.dart
|
||||||
│ │ │ │ └── staff_connector_repository_impl.dart
|
│ │ │ │ └── session_handler_mixin.dart
|
||||||
│ │ │ ├── order/
|
│ │ │ └── core_api_services/
|
||||||
│ │ │ └── shifts/
|
│ │ │ ├── v2_api_endpoints.dart
|
||||||
│ │ └── session/
|
│ │ │ ├── core_api_endpoints.dart
|
||||||
│ │ ├── staff_session_store.dart
|
│ │ │ ├── file_upload/
|
||||||
│ │ └── client_session_store.dart
|
│ │ │ ├── signed_url/
|
||||||
│ └── krow_data_connect.dart # Exports
|
│ │ │ ├── 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
|
└── pubspec.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
**RESTRICTION:**
|
**RESTRICTION:**
|
||||||
- NO feature-specific logic
|
- NO feature-specific logic
|
||||||
- Connectors are domain-neutral and reusable
|
- Core services are domain-neutral and reusable
|
||||||
- All queries follow Clean Architecture (domain interfaces → data implementations)
|
- All V2 API access goes through `ApiService` — never use raw Dio directly in features
|
||||||
|
|
||||||
### 2.5 Design System (`apps/mobile/packages/design_system`)
|
### 2.5 Design System (`apps/mobile/packages/design_system`)
|
||||||
|
|
||||||
@@ -274,13 +334,13 @@ design_system/
|
|||||||
|
|
||||||
**Feature Integration:**
|
**Feature Integration:**
|
||||||
```dart
|
```dart
|
||||||
// ✅ CORRECT: Access via Slang's global `t` accessor
|
// CORRECT: Access via Slang's global `t` accessor
|
||||||
import 'package:core_localization/core_localization.dart';
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
|
||||||
Text(t.client_create_order.review.invalid_arguments)
|
Text(t.client_create_order.review.invalid_arguments)
|
||||||
Text(t.errors.order.creation_failed)
|
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('Invalid review arguments') // Must use localized key
|
||||||
Text('Order created!') // 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
|
## 3. Dependency Direction Rules
|
||||||
|
|
||||||
1. **Domain Independence:** `domain` knows NOTHING about outer layers
|
1. **Domain Independence:** `domain` knows NOTHING about outer layers
|
||||||
- Defines *what* needs to be done, not *how*
|
- Defines *what* needs to be done, not *how*
|
||||||
- Pure Dart, zero Flutter dependencies
|
- Pure Dart, zero Flutter dependencies
|
||||||
- Stable contracts that rarely change
|
- 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
|
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
|
- Backend changes don't affect feature implementation
|
||||||
|
|
||||||
3. **Data Isolation:** `data_connect` depends on `domain` to know interfaces
|
3. **Data Isolation:** Feature `data/` layer depends on `core` for API access and `domain` for entities
|
||||||
- Implements domain repository interfaces
|
- RepoImpl uses `ApiService` with `V2ApiEndpoints`
|
||||||
- Maps backend models to domain entities
|
- Maps JSON responses to domain entities via `Entity.fromJson()`
|
||||||
- Does NOT know about UI
|
- Does NOT know about UI
|
||||||
|
|
||||||
**Dependency Flow:**
|
**Dependency Flow:**
|
||||||
```
|
```
|
||||||
Apps → Features → Design System
|
Apps → Features → Design System
|
||||||
→ Core Localization
|
→ Core Localization
|
||||||
→ Data Connect → Domain
|
→ Core → Domain
|
||||||
→ Core
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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:**
|
**Responsibilities:**
|
||||||
- Automatic token refresh (triggered when <5 minutes to expiry)
|
- Wraps Dio HTTP methods (GET, POST, PUT, PATCH, DELETE)
|
||||||
- Firebase auth state listening
|
- Consistent response parsing via `ApiResponse`
|
||||||
- Role-based access validation
|
- Consistent error handling (maps `DioException` to `ApiResponse` with V2 error envelope)
|
||||||
- Session state stream emissions
|
|
||||||
- 3-attempt retry with exponential backoff (1s → 2s → 4s)
|
**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:**
|
**Key Method:**
|
||||||
```dart
|
```dart
|
||||||
// Call once on app startup
|
// Call once on app startup
|
||||||
DataConnectService.instance.initializeAuthListener(
|
V2SessionService.instance.initializeAuthListener(
|
||||||
allowedRoles: ['STAFF', 'BOTH'], // or ['CLIENT', 'BUSINESS', 'BOTH']
|
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`
|
**Location:** `apps/mobile/apps/<app>/lib/src/widgets/session_listener.dart`
|
||||||
|
|
||||||
@@ -381,13 +465,13 @@ DataConnectService.instance.initializeAuthListener(
|
|||||||
```dart
|
```dart
|
||||||
// main.dart
|
// main.dart
|
||||||
runApp(
|
runApp(
|
||||||
SessionListener( // ← Critical wrapper
|
SessionListener( // Critical wrapper
|
||||||
child: ModularApp(module: AppModule(), child: AppWidget()),
|
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:
|
**Step 1:** Define interface in feature domain:
|
||||||
```dart
|
```dart
|
||||||
@@ -397,32 +481,33 @@ abstract interface class ProfileRepositoryInterface {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 2:** Implement using `DataConnectService.run()`:
|
**Step 2:** Implement using `ApiService` with `V2ApiEndpoints`:
|
||||||
```dart
|
```dart
|
||||||
// features/staff/profile/lib/src/data/repositories_impl/
|
// features/staff/profile/lib/src/data/repositories_impl/
|
||||||
class ProfileRepositoryImpl implements ProfileRepositoryInterface {
|
class ProfileRepositoryImpl implements ProfileRepositoryInterface {
|
||||||
final DataConnectService _service = DataConnectService.instance;
|
final ApiService _apiService;
|
||||||
|
|
||||||
|
ProfileRepositoryImpl({required ApiService apiService})
|
||||||
|
: _apiService = apiService;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Staff> getProfile(String id) async {
|
Future<Staff> getProfile(String id) async {
|
||||||
return await _service.run(() async {
|
final response = await _apiService.get(
|
||||||
final response = await _service.connector
|
V2ApiEndpoints.staffSession,
|
||||||
.getStaffById(id: id)
|
params: {'staffId': id},
|
||||||
.execute();
|
);
|
||||||
return _mapToStaff(response.data.staff);
|
return Staff.fromJson(response.data as Map<String, dynamic>);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Benefits of `_service.run()`:**
|
**Benefits of `ApiService` + interceptors:**
|
||||||
- ✅ Auto validates user is authenticated
|
- AuthInterceptor auto-attaches Firebase Auth token
|
||||||
- ✅ Refreshes token if <5 min to expiry
|
- IdempotencyInterceptor prevents duplicate writes
|
||||||
- ✅ Executes the query
|
- Consistent error handling via `ApiResponse`
|
||||||
- ✅ 3-attempt retry with exponential backoff
|
- No manual token management in features
|
||||||
- ✅ Maps exceptions to domain failures
|
|
||||||
|
|
||||||
### 4.4 Session Store Pattern
|
### 4.6 Session Store Pattern
|
||||||
|
|
||||||
After successful auth, populate session stores:
|
After successful auth, populate session stores:
|
||||||
|
|
||||||
@@ -451,9 +536,10 @@ ClientSessionStore.instance.setSession(
|
|||||||
```dart
|
```dart
|
||||||
final session = StaffSessionStore.instance.session;
|
final session = StaffSessionStore.instance.session;
|
||||||
if (session?.staff == null) {
|
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(
|
StaffSessionStore.instance.setSession(
|
||||||
session.copyWith(staff: staff),
|
session!.copyWith(staff: staff),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -463,12 +549,12 @@ if (session?.staff == null) {
|
|||||||
### Zero Direct Imports
|
### Zero Direct Imports
|
||||||
|
|
||||||
```dart
|
```dart
|
||||||
// ❌ FORBIDDEN
|
// FORBIDDEN
|
||||||
import 'package:staff_profile/staff_profile.dart'; // in another feature
|
import 'package:staff_profile/staff_profile.dart'; // in another feature
|
||||||
|
|
||||||
// ✅ ALLOWED
|
// ALLOWED
|
||||||
import 'package:krow_domain/krow_domain.dart'; // shared domain
|
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
|
import 'package:design_system/design_system.dart'; // shared UI
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -522,13 +608,13 @@ extension StaffNavigator on IModularNavigator {
|
|||||||
|
|
||||||
**Usage in Features:**
|
**Usage in Features:**
|
||||||
```dart
|
```dart
|
||||||
// ✅ CORRECT
|
// CORRECT
|
||||||
Modular.to.toStaffHome();
|
Modular.to.toStaffHome();
|
||||||
Modular.to.toShiftDetails(shiftId: '123');
|
Modular.to.toShiftDetails(shiftId: '123');
|
||||||
Modular.to.popSafe();
|
Modular.to.popSafe();
|
||||||
|
|
||||||
// ❌ AVOID
|
// AVOID
|
||||||
Modular.to.navigate('/home'); // No safety
|
Modular.to.navigate('/profile'); // No safety
|
||||||
Navigator.push(...); // No Modular integration
|
Navigator.push(...); // No Modular integration
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -536,9 +622,9 @@ Navigator.push(...); // No Modular integration
|
|||||||
|
|
||||||
Features don't share state directly. Use:
|
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
|
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
|
4. **Navigation Arguments:** Pass IDs, not full objects
|
||||||
|
|
||||||
## 6. App-Specific Session Management
|
## 6. App-Specific Session Management
|
||||||
@@ -550,7 +636,7 @@ Features don't share state directly. Use:
|
|||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
DataConnectService.instance.initializeAuthListener(
|
V2SessionService.instance.initializeAuthListener(
|
||||||
allowedRoles: ['STAFF', 'BOTH'],
|
allowedRoles: ['STAFF', 'BOTH'],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -564,11 +650,11 @@ void main() async {
|
|||||||
|
|
||||||
**Session Store:** `StaffSessionStore`
|
**Session Store:** `StaffSessionStore`
|
||||||
- Fields: `user`, `staff`, `ownerId`
|
- Fields: `user`, `staff`, `ownerId`
|
||||||
- Lazy load: `getStaffById()` if staff is null
|
- Lazy load: fetch from `V2ApiEndpoints.staffSession` if staff is null
|
||||||
|
|
||||||
**Navigation:**
|
**Navigation:**
|
||||||
- Authenticated → `Modular.to.toStaffHome()`
|
- Authenticated -> `Modular.to.toStaffHome()`
|
||||||
- Unauthenticated → `Modular.to.toInitialPage()`
|
- Unauthenticated -> `Modular.to.toInitialPage()`
|
||||||
|
|
||||||
### Client App
|
### Client App
|
||||||
|
|
||||||
@@ -577,7 +663,7 @@ void main() async {
|
|||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
DataConnectService.instance.initializeAuthListener(
|
V2SessionService.instance.initializeAuthListener(
|
||||||
allowedRoles: ['CLIENT', 'BUSINESS', 'BOTH'],
|
allowedRoles: ['CLIENT', 'BUSINESS', 'BOTH'],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -591,137 +677,138 @@ void main() async {
|
|||||||
|
|
||||||
**Session Store:** `ClientSessionStore`
|
**Session Store:** `ClientSessionStore`
|
||||||
- Fields: `user`, `business`
|
- Fields: `user`, `business`
|
||||||
- Lazy load: `getBusinessById()` if business is null
|
- Lazy load: fetch from `V2ApiEndpoints.clientSession` if business is null
|
||||||
|
|
||||||
**Navigation:**
|
**Navigation:**
|
||||||
- Authenticated → `Modular.to.toClientHome()`
|
- Authenticated -> `Modular.to.toClientHome()`
|
||||||
- Unauthenticated → `Modular.to.toInitialPage()`
|
- 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
|
### Structure
|
||||||
|
|
||||||
Mirror backend connector structure:
|
Repository implementations live in the feature package:
|
||||||
|
|
||||||
```
|
```
|
||||||
data_connect/lib/src/connectors/
|
features/staff/profile/
|
||||||
├── staff/
|
├── lib/src/
|
||||||
│ ├── domain/
|
│ ├── domain/
|
||||||
│ │ ├── repositories/
|
│ │ └── repositories/
|
||||||
│ │ │ └── staff_connector_repository.dart # Interface
|
│ │ └── profile_repository_interface.dart # Interface
|
||||||
│ │ └── usecases/
|
│ ├── data/
|
||||||
│ │ └── get_profile_completion_usecase.dart
|
│ │ └── repositories_impl/
|
||||||
│ └── data/
|
│ │ └── profile_repository_impl.dart # Implementation
|
||||||
│ └── repositories/
|
│ └── presentation/
|
||||||
│ └── staff_connector_repository_impl.dart # Implementation
|
│ └── blocs/
|
||||||
├── order/
|
│ └── profile_cubit.dart
|
||||||
├── shifts/
|
|
||||||
└── user/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Maps to backend:**
|
### Repository Interface
|
||||||
```
|
|
||||||
backend/dataconnect/connector/
|
|
||||||
├── staff/
|
|
||||||
├── order/
|
|
||||||
├── shifts/
|
|
||||||
└── user/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Clean Architecture in Connectors
|
|
||||||
|
|
||||||
**Domain Interface:**
|
|
||||||
```dart
|
```dart
|
||||||
// staff_connector_repository.dart
|
// profile_repository_interface.dart
|
||||||
abstract interface class StaffConnectorRepository {
|
abstract interface class ProfileRepositoryInterface {
|
||||||
Future<bool> getProfileCompletion();
|
Future<Staff> getProfile();
|
||||||
Future<Staff> getStaffById(String id);
|
Future<void> updatePersonalInfo(Map<String, dynamic> data);
|
||||||
|
Future<List<ProfileSection>> getProfileSections();
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Use Case:**
|
### Repository Implementation
|
||||||
|
|
||||||
```dart
|
```dart
|
||||||
// get_profile_completion_usecase.dart
|
// profile_repository_impl.dart
|
||||||
class GetProfileCompletionUseCase {
|
class ProfileRepositoryImpl implements ProfileRepositoryInterface {
|
||||||
final StaffConnectorRepository _repository;
|
final ApiService _apiService;
|
||||||
|
|
||||||
GetProfileCompletionUseCase({required StaffConnectorRepository repository})
|
ProfileRepositoryImpl({required ApiService apiService})
|
||||||
: _repository = repository;
|
: _apiService = apiService;
|
||||||
|
|
||||||
Future<bool> call() => _repository.getProfileCompletion();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Data Implementation:**
|
|
||||||
```dart
|
|
||||||
// staff_connector_repository_impl.dart
|
|
||||||
class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
|
|
||||||
final DataConnectService _service;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> getProfileCompletion() async {
|
Future<Staff> getProfile() async {
|
||||||
return _service.run(() async {
|
final response = await _apiService.get(V2ApiEndpoints.staffSession);
|
||||||
final staffId = await _service.getStaffId();
|
final data = response.data as Map<String, dynamic>;
|
||||||
final response = await _service.connector
|
return Staff.fromJson(data['staff'] as Map<String, dynamic>);
|
||||||
.getStaffProfileCompletion(id: staffId)
|
}
|
||||||
.execute();
|
|
||||||
|
|
||||||
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
|
```dart
|
||||||
// staff_main_module.dart
|
// profile_module.dart
|
||||||
class StaffMainModule extends Module {
|
class ProfileModule extends Module {
|
||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
i.addLazySingleton<StaffConnectorRepository>(
|
i.addLazySingleton<ProfileRepositoryInterface>(
|
||||||
StaffConnectorRepositoryImpl.new,
|
() => ProfileRepositoryImpl(apiService: i.get<ApiService>()),
|
||||||
);
|
);
|
||||||
|
|
||||||
i.addLazySingleton(
|
i.addLazySingleton(
|
||||||
() => GetProfileCompletionUseCase(
|
() => GetProfileUseCase(
|
||||||
repository: i.get<StaffConnectorRepository>(),
|
repository: i.get<ProfileRepositoryInterface>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
i.addLazySingleton(
|
i.addLazySingleton(
|
||||||
() => StaffMainCubit(
|
() => ProfileCubit(
|
||||||
getProfileCompletionUsecase: i.get(),
|
getProfileUseCase: i.get(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 2:** BLoC uses it:
|
### BLoC Usage
|
||||||
```dart
|
|
||||||
class StaffMainCubit extends Cubit<StaffMainState> {
|
|
||||||
final GetProfileCompletionUseCase _getProfileCompletionUsecase;
|
|
||||||
|
|
||||||
Future<void> loadProfileCompletion() async {
|
```dart
|
||||||
final isComplete = await _getProfileCompletionUsecase();
|
class ProfileCubit extends Cubit<ProfileState> with BlocErrorHandler<ProfileState> {
|
||||||
emit(state.copyWith(isProfileComplete: isComplete));
|
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
|
### Benefits
|
||||||
|
|
||||||
✅ **No Duplication** - Query implemented once, used by many features
|
- **No Duplication** — Endpoint constants defined once in `V2ApiEndpoints`
|
||||||
✅ **Single Source of Truth** - Backend change → update one place
|
- **Consistent Auth** — `AuthInterceptor` handles token attachment automatically
|
||||||
✅ **Reusability** - Any feature can use any connector
|
- **Idempotent Writes** — `IdempotencyInterceptor` prevents duplicate mutations
|
||||||
✅ **Testability** - Mock connector repo to test features
|
- **Domain Purity** — Entities use `fromJson`/`toJson` directly, no mapping layers
|
||||||
✅ **Scalability** - Easy to add connectors as backend grows
|
- **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
|
## 8. Avoiding Prop Drilling: Direct BLoC Access
|
||||||
|
|
||||||
@@ -730,16 +817,16 @@ class StaffMainCubit extends Cubit<StaffMainState> {
|
|||||||
Passing data through intermediate widgets creates maintenance burden:
|
Passing data through intermediate widgets creates maintenance burden:
|
||||||
|
|
||||||
```dart
|
```dart
|
||||||
// ❌ BAD: Prop drilling
|
// BAD: Prop drilling
|
||||||
ProfilePage(status: status)
|
ProfilePage(status: status)
|
||||||
→ ProfileHeader(status: status)
|
-> ProfileHeader(status: status)
|
||||||
→ ProfileLevelBadge(status: status) // Only widget that needs it
|
-> ProfileLevelBadge(status: status) // Only widget that needs it
|
||||||
```
|
```
|
||||||
|
|
||||||
### The Solution: BlocBuilder in Leaf Widgets
|
### The Solution: BlocBuilder in Leaf Widgets
|
||||||
|
|
||||||
```dart
|
```dart
|
||||||
// ✅ GOOD: Direct BLoC access
|
// GOOD: Direct BLoC access
|
||||||
class ProfileLevelBadge extends StatelessWidget {
|
class ProfileLevelBadge extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -765,9 +852,9 @@ class ProfileLevelBadge extends StatelessWidget {
|
|||||||
**Decision Tree:**
|
**Decision Tree:**
|
||||||
```
|
```
|
||||||
Does this widget need data?
|
Does this widget need data?
|
||||||
├─ YES, leaf widget → Use BlocBuilder
|
├─ YES, leaf widget -> Use BlocBuilder
|
||||||
├─ YES, container → Use BlocBuilder in child
|
├─ YES, container -> Use BlocBuilder in child
|
||||||
└─ NO → Don't add prop
|
└─ NO -> Don't add prop
|
||||||
```
|
```
|
||||||
|
|
||||||
## 9. BLoC Lifecycle & State Emission Safety
|
## 9. BLoC Lifecycle & State Emission Safety
|
||||||
@@ -780,7 +867,7 @@ StateError: Cannot emit new states after calling close
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Root Causes:**
|
**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
|
2. Multiple BlocProviders disposing same singleton
|
||||||
3. User navigates away during async operation
|
3. User navigates away during async operation
|
||||||
|
|
||||||
@@ -789,26 +876,26 @@ StateError: Cannot emit new states after calling close
|
|||||||
#### Step 1: Register as Singleton
|
#### Step 1: Register as Singleton
|
||||||
|
|
||||||
```dart
|
```dart
|
||||||
// ✅ GOOD: Singleton registration
|
// GOOD: Singleton registration
|
||||||
i.addLazySingleton<ProfileCubit>(
|
i.addLazySingleton<ProfileCubit>(
|
||||||
() => ProfileCubit(useCase1, useCase2),
|
() => ProfileCubit(useCase1, useCase2),
|
||||||
);
|
);
|
||||||
|
|
||||||
// ❌ BAD: Creates new instance each time
|
// BAD: Creates new instance each time
|
||||||
i.add(ProfileCubit.new);
|
i.add(ProfileCubit.new);
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Step 2: Use BlocProvider.value()
|
#### Step 2: Use BlocProvider.value()
|
||||||
|
|
||||||
```dart
|
```dart
|
||||||
// ✅ GOOD: Reuse singleton
|
// GOOD: Reuse singleton
|
||||||
final cubit = Modular.get<ProfileCubit>();
|
final cubit = Modular.get<ProfileCubit>();
|
||||||
BlocProvider<ProfileCubit>.value(
|
BlocProvider<ProfileCubit>.value(
|
||||||
value: cubit,
|
value: cubit,
|
||||||
child: MyWidget(),
|
child: MyWidget(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// ❌ BAD: Creates duplicate
|
// BAD: Creates duplicate
|
||||||
BlocProvider<ProfileCubit>(
|
BlocProvider<ProfileCubit>(
|
||||||
create: (_) => Modular.get<ProfileCubit>(),
|
create: (_) => Modular.get<ProfileCubit>(),
|
||||||
child: MyWidget(),
|
child: MyWidget(),
|
||||||
@@ -845,7 +932,7 @@ class ProfileCubit extends Cubit<ProfileState> with BlocErrorHandler<ProfileStat
|
|||||||
action: () async {
|
action: () async {
|
||||||
final profile = await getProfile();
|
final profile = await getProfile();
|
||||||
emit(state.copyWith(status: ProfileStatus.loaded, profile: profile));
|
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),
|
onError: (errorKey) => state.copyWith(status: ProfileStatus.error),
|
||||||
);
|
);
|
||||||
@@ -863,43 +950,48 @@ class ProfileCubit extends Cubit<ProfileState> with BlocErrorHandler<ProfileStat
|
|||||||
|
|
||||||
## 10. Anti-Patterns to Avoid
|
## 10. Anti-Patterns to Avoid
|
||||||
|
|
||||||
❌ **Feature imports feature**
|
- **Feature imports feature**
|
||||||
```dart
|
```dart
|
||||||
import 'package:staff_profile/staff_profile.dart'; // in another feature
|
import 'package:staff_profile/staff_profile.dart'; // in another feature
|
||||||
```
|
```
|
||||||
|
|
||||||
❌ **Business logic in BLoC**
|
- **Business logic in BLoC**
|
||||||
```dart
|
```dart
|
||||||
on<LoginRequested>((event, emit) {
|
on<LoginRequested>((event, emit) {
|
||||||
if (event.email.isEmpty) { // ← Use case responsibility
|
if (event.email.isEmpty) { // Use case responsibility
|
||||||
emit(AuthError('Email required'));
|
emit(AuthError('Email required'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
❌ **Direct Data Connect in features**
|
- **Direct HTTP/Dio in features (use ApiService)**
|
||||||
```dart
|
```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
|
```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
|
```dart
|
||||||
Navigator.push(context, MaterialPageRoute(...)); // ← Use Modular
|
User? currentUser; // Use SessionStore
|
||||||
```
|
```
|
||||||
|
|
||||||
❌ **Hardcoded navigation**
|
- **Direct Navigator.push**
|
||||||
```dart
|
```dart
|
||||||
Modular.to.navigate('/profile'); // ← Use safe extensions
|
Navigator.push(context, MaterialPageRoute(...)); // Use Modular
|
||||||
```
|
```
|
||||||
|
|
||||||
❌ **Hardcoded user-facing strings**
|
- **Hardcoded navigation**
|
||||||
```dart
|
```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
|
## Summary
|
||||||
@@ -907,17 +999,20 @@ Text('Order created successfully!'); // ← Use t.section.key from core_localiz
|
|||||||
The architecture enforces:
|
The architecture enforces:
|
||||||
- **Clean Architecture** with strict layer boundaries
|
- **Clean Architecture** with strict layer boundaries
|
||||||
- **Feature Isolation** via zero cross-feature imports
|
- **Feature Isolation** via zero cross-feature imports
|
||||||
- **Session Management** via DataConnectService and SessionListener
|
- **V2 REST API** integration via `ApiService`, `V2ApiEndpoints`, and interceptors
|
||||||
- **Connector Pattern** for reusable backend queries
|
- **Session Management** via `V2SessionService`, session stores, and `SessionListener`
|
||||||
|
- **Repository Pattern** with feature-local RepoImpl using `ApiService`
|
||||||
- **BLoC Lifecycle** safety with singletons and safe emit
|
- **BLoC Lifecycle** safety with singletons and safe emit
|
||||||
- **Navigation Safety** with typed navigators and fallbacks
|
- **Navigation Safety** with typed navigators and fallbacks
|
||||||
|
|
||||||
When implementing features:
|
When implementing features:
|
||||||
1. Follow package structure strictly
|
1. Follow package structure strictly
|
||||||
2. Use connector repositories for backend access
|
2. Use `ApiService` with `V2ApiEndpoints` for all backend access
|
||||||
3. Register BLoCs as singletons with `.value()`
|
3. Domain entities use `fromJson`/`toJson` for V2 API serialization
|
||||||
4. Use safe navigation extensions
|
4. RepoImpl lives in the feature `data/` layer, not a shared package
|
||||||
5. Avoid prop drilling with direct BLoC access
|
5. Register BLoCs as singletons with `.value()`
|
||||||
6. Keep domain pure and stable
|
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.
|
Architecture is not negotiable. When in doubt, refer to existing well-structured features or ask for clarification.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: krow-mobile-development-rules
|
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
|
# 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
|
- Creating new mobile features or packages
|
||||||
- Implementing BLoCs, Use Cases, or Repositories
|
- Implementing BLoCs, Use Cases, or Repositories
|
||||||
- Integrating with Firebase Data Connect backend
|
- Integrating with V2 REST API backend
|
||||||
- Migrating code from prototypes
|
- Migrating code from prototypes
|
||||||
- Reviewing mobile code for compliance
|
- Reviewing mobile code for compliance
|
||||||
- Setting up new feature modules
|
- Setting up new feature modules
|
||||||
@@ -186,15 +186,17 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
```dart
|
```dart
|
||||||
// profile_repository_impl.dart
|
// profile_repository_impl.dart
|
||||||
class ProfileRepositoryImpl implements ProfileRepositoryInterface {
|
class ProfileRepositoryImpl implements ProfileRepositoryInterface {
|
||||||
|
ProfileRepositoryImpl({required BaseApiService apiService})
|
||||||
|
: _apiService = apiService;
|
||||||
|
final BaseApiService _apiService;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Staff> getProfile(String id) async {
|
Future<Staff> getProfile(String id) async {
|
||||||
final response = await dataConnect.getStaffById(id: id).execute();
|
final ApiResponse response = await _apiService.get(
|
||||||
// Data transformation happens here
|
V2ApiEndpoints.staffProfile(id),
|
||||||
return Staff(
|
|
||||||
id: response.data.staff.id,
|
|
||||||
name: response.data.staff.name,
|
|
||||||
// Map Data Connect model to Domain entity
|
|
||||||
);
|
);
|
||||||
|
// 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.
|
**PATTERN:** All navigation MUST have fallback to Home page. Safe extensions automatically handle this.
|
||||||
|
|
||||||
### Session Management → DataConnectService + SessionHandlerMixin
|
### Session Management → V2SessionService + SessionHandlerMixin
|
||||||
|
|
||||||
**✅ CORRECT:**
|
**✅ CORRECT:**
|
||||||
```dart
|
```dart
|
||||||
@@ -261,7 +263,7 @@ void main() async {
|
|||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
// Initialize session listener (pick allowed roles for app)
|
// Initialize session listener (pick allowed roles for app)
|
||||||
DataConnectService.instance.initializeAuthListener(
|
V2SessionService.instance.initializeAuthListener(
|
||||||
allowedRoles: ['STAFF', 'BOTH'], // for staff app
|
allowedRoles: ['STAFF', 'BOTH'], // for staff app
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -274,28 +276,24 @@ void main() async {
|
|||||||
|
|
||||||
// In repository:
|
// In repository:
|
||||||
class ProfileRepositoryImpl implements ProfileRepositoryInterface {
|
class ProfileRepositoryImpl implements ProfileRepositoryInterface {
|
||||||
final DataConnectService _service = DataConnectService.instance;
|
ProfileRepositoryImpl({required BaseApiService apiService})
|
||||||
|
: _apiService = apiService;
|
||||||
|
final BaseApiService _apiService;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Staff> getProfile(String id) async {
|
Future<Staff> getProfile(String id) async {
|
||||||
// _service.run() handles:
|
final ApiResponse response = await _apiService.get(
|
||||||
// - Auth validation
|
V2ApiEndpoints.staffProfile(id),
|
||||||
// - Token refresh (if <5 min to expiry)
|
);
|
||||||
// - Error handling with 3 retries
|
return Staff.fromJson(response.data as Map<String, dynamic>);
|
||||||
return await _service.run(() async {
|
|
||||||
final response = await _service.connector
|
|
||||||
.getStaffById(id: id)
|
|
||||||
.execute();
|
|
||||||
return _mapToStaff(response.data.staff);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**PATTERN:**
|
**PATTERN:**
|
||||||
- **SessionListener** widget wraps app and shows dialogs for session errors
|
- **SessionListener** widget wraps app and shows dialogs for session errors
|
||||||
- **SessionHandlerMixin** in `DataConnectService` provides automatic token refresh
|
- **V2SessionService** provides automatic token refresh and auth management
|
||||||
- **3-attempt retry logic** with exponential backoff (1s → 2s → 4s)
|
- **ApiService** handles HTTP requests with automatic auth headers
|
||||||
- **Role validation** configurable per app
|
- **Role validation** configurable per app
|
||||||
|
|
||||||
## 4. Localization Integration (core_localization)
|
## 4. Localization Integration (core_localization)
|
||||||
@@ -372,7 +370,7 @@ class AppModule extends Module {
|
|||||||
@override
|
@override
|
||||||
List<Module> get imports => [
|
List<Module> get imports => [
|
||||||
LocalizationModule(), // ← Required
|
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
|
### 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
|
```dart
|
||||||
// domain/repositories/profile_repository_interface.dart
|
// domain/repositories/shifts_repository_interface.dart
|
||||||
abstract interface class ProfileRepositoryInterface {
|
abstract interface class ShiftsRepositoryInterface {
|
||||||
Future<Staff> getProfile(String id);
|
Future<List<AssignedShift>> getAssignedShifts();
|
||||||
Future<bool> updateProfile(Staff profile);
|
Future<AssignedShift> getShiftById(String id);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 2:** Implement using `DataConnectService.run()`:
|
**Step 2:** Implement using `ApiService` + `V2ApiEndpoints`:
|
||||||
```dart
|
```dart
|
||||||
// data/repositories_impl/profile_repository_impl.dart
|
// data/repositories_impl/shifts_repository_impl.dart
|
||||||
class ProfileRepositoryImpl implements ProfileRepositoryInterface {
|
class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
||||||
final DataConnectService _service = DataConnectService.instance;
|
ShiftsRepositoryImpl({required BaseApiService apiService})
|
||||||
|
: _apiService = apiService;
|
||||||
|
final BaseApiService _apiService;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Staff> getProfile(String id) async {
|
Future<List<AssignedShift>> getAssignedShifts() async {
|
||||||
return await _service.run(() async {
|
final ApiResponse response = await _apiService.get(V2ApiEndpoints.staffShiftsAssigned);
|
||||||
final response = await _service.connector
|
final List<dynamic> items = response.data['items'] as List<dynamic>;
|
||||||
.getStaffById(id: id)
|
return items.map((dynamic json) => AssignedShift.fromJson(json as Map<String, dynamic>)).toList();
|
||||||
.execute();
|
}
|
||||||
return _mapToStaff(response.data.staff);
|
|
||||||
});
|
@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()`:**
|
### Key Conventions
|
||||||
- ✅ Automatic auth validation
|
|
||||||
- ✅ Token refresh if needed
|
- **Domain entities** have `fromJson` / `toJson` factory methods for serialization
|
||||||
- ✅ 3-attempt retry with exponential backoff
|
- **Status fields** use enums from `krow_domain` (e.g., `ShiftStatus`, `OrderStatus`)
|
||||||
- ✅ Consistent error handling
|
- **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
|
### 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
|
## 6. Prototype Migration Rules
|
||||||
|
|
||||||
@@ -462,7 +467,7 @@ When migrating from `prototypes/`:
|
|||||||
### ❌ MUST REJECT & REFACTOR
|
### ❌ MUST REJECT & REFACTOR
|
||||||
- `GetX`, `Provider`, or `MVC` patterns
|
- `GetX`, `Provider`, or `MVC` patterns
|
||||||
- Any state management not using BLoC
|
- 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)
|
- Hardcoded colors/typography (must use design system)
|
||||||
- Global state variables
|
- Global state variables
|
||||||
- Navigation without Modular
|
- Navigation without Modular
|
||||||
@@ -491,13 +496,12 @@ If requirements are unclear:
|
|||||||
|
|
||||||
### DO NOT
|
### DO NOT
|
||||||
- Add 3rd party packages without checking `apps/mobile/packages/core` first
|
- 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)
|
- Add `firebase_auth` or `firebase_data_connect` to Feature packages (they belong in `core` only)
|
||||||
- Use `addSingleton` for BLoCs (always use `add` method in Modular)
|
|
||||||
|
|
||||||
### DO
|
### DO
|
||||||
- Use `DataConnectService.instance` for backend operations
|
- Use `ApiService` with `V2ApiEndpoints` for backend operations
|
||||||
- Use Flutter Modular for dependency injection
|
- 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
|
- Register Use Cases as factories or singletons as needed
|
||||||
|
|
||||||
## 9. Error Handling Pattern
|
## 9. Error Handling Pattern
|
||||||
@@ -516,15 +520,12 @@ class InvalidCredentialsFailure extends AuthFailure {
|
|||||||
|
|
||||||
### Repository Error Mapping
|
### Repository Error Mapping
|
||||||
```dart
|
```dart
|
||||||
// Map Data Connect exceptions to Domain failures
|
// Map API errors to Domain failures using ApiErrorHandler
|
||||||
try {
|
try {
|
||||||
final response = await dataConnect.query();
|
final response = await _apiService.get(V2ApiEndpoints.staffProfile(id));
|
||||||
return Right(response);
|
return Right(Staff.fromJson(response.data as Map<String, dynamic>));
|
||||||
} on DataConnectException catch (e) {
|
} catch (e) {
|
||||||
if (e.message.contains('unauthorized')) {
|
return Left(ApiErrorHandler.mapToFailure(e));
|
||||||
return Left(InvalidCredentialsFailure());
|
|
||||||
}
|
|
||||||
return Left(ServerFailure(e.message));
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -579,7 +580,7 @@ testWidgets('shows loading indicator when logging in', (tester) async {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Integration Tests
|
### 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
|
- Use dependency injection to swap implementations if needed
|
||||||
|
|
||||||
## 11. Clean Code Principles
|
## 11. Clean Code Principles
|
||||||
@@ -635,12 +636,12 @@ Before merging any mobile feature code:
|
|||||||
- [ ] Zero analyzer warnings
|
- [ ] Zero analyzer warnings
|
||||||
|
|
||||||
### Integration
|
### Integration
|
||||||
- [ ] Data Connect queries via `_service.run()`
|
- [ ] V2 API calls via `ApiService` + `V2ApiEndpoints`
|
||||||
- [ ] Error handling with domain failures
|
- [ ] Error handling with domain failures
|
||||||
- [ ] Proper dependency injection in modules
|
- [ ] Proper dependency injection in modules
|
||||||
|
|
||||||
## Summary
|
## 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.
|
When in doubt, refer to existing features following these patterns or ask for clarification. It's better to ask than to introduce architectural debt.
|
||||||
|
|||||||
@@ -20,11 +20,6 @@ public final class GeneratedPluginRegistrant {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", 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 {
|
try {
|
||||||
flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.auth.FlutterFirebaseAuthPlugin());
|
flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.auth.FlutterFirebaseAuthPlugin());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
@@ -12,12 +12,6 @@
|
|||||||
@import file_picker;
|
@import file_picker;
|
||||||
#endif
|
#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>)
|
#if __has_include(<firebase_auth/FLTFirebaseAuthPlugin.h>)
|
||||||
#import <firebase_auth/FLTFirebaseAuthPlugin.h>
|
#import <firebase_auth/FLTFirebaseAuthPlugin.h>
|
||||||
#else
|
#else
|
||||||
@@ -82,7 +76,6 @@
|
|||||||
|
|
||||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
||||||
[FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]];
|
[FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]];
|
||||||
[FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]];
|
|
||||||
[FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]];
|
[FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]];
|
||||||
[FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]];
|
[FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]];
|
||||||
[FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]];
|
[FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]];
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
|
||||||
|
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
import 'src/widgets/session_listener.dart';
|
import 'src/widgets/session_listener.dart';
|
||||||
@@ -31,15 +30,6 @@ void main() async {
|
|||||||
logStateChanges: false, // Set to true for verbose debugging
|
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(
|
runApp(
|
||||||
ModularApp(
|
ModularApp(
|
||||||
module: AppModule(),
|
module: AppModule(),
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.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.
|
/// A widget that listens to session state changes and handles global reactions.
|
||||||
///
|
///
|
||||||
@@ -28,11 +29,20 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_setupSessionListener();
|
_initializeSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setupSessionListener() {
|
void _initializeSession() {
|
||||||
_sessionSubscription = DataConnectService.instance.onSessionStateChanged
|
// 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) {
|
.listen((SessionState state) {
|
||||||
_handleSessionChange(state);
|
_handleSessionChange(state);
|
||||||
});
|
});
|
||||||
@@ -75,7 +85,7 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
if (!_isInitialState) {
|
if (!_isInitialState) {
|
||||||
debugPrint('[SessionListener] Session error: ${state.errorMessage}');
|
debugPrint('[SessionListener] Session error: ${state.errorMessage}');
|
||||||
_showSessionErrorDialog(
|
_showSessionErrorDialog(
|
||||||
state.errorMessage ?? 'Session error occurred',
|
state.errorMessage ?? t.session.error_title,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
_isInitialState = false;
|
_isInitialState = false;
|
||||||
@@ -92,22 +102,21 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
|
|
||||||
/// Shows a dialog when the session expires.
|
/// Shows a dialog when the session expires.
|
||||||
void _showSessionExpiredDialog() {
|
void _showSessionExpiredDialog() {
|
||||||
|
final Translations translations = t;
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext dialogContext) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text('Session Expired'),
|
title: Text(translations.session.expired_title),
|
||||||
content: const Text(
|
content: Text(translations.session.expired_message),
|
||||||
'Your session has expired. Please log in again to continue.',
|
|
||||||
),
|
|
||||||
actions: <Widget>[
|
actions: <Widget>[
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Modular.to.popSafe();
|
Navigator.of(dialogContext).pop();
|
||||||
_proceedToLogin();
|
_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.
|
/// Shows a dialog when a session error occurs, with retry option.
|
||||||
void _showSessionErrorDialog(String errorMessage) {
|
void _showSessionErrorDialog(String errorMessage) {
|
||||||
|
final Translations translations = t;
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext dialogContext) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text('Session Error'),
|
title: Text(translations.session.error_title),
|
||||||
content: Text(errorMessage),
|
content: Text(errorMessage),
|
||||||
actions: <Widget>[
|
actions: <Widget>[
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// User can retry by dismissing and continuing
|
// 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(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Modular.to.popSafe();;
|
Navigator.of(dialogContext).pop();
|
||||||
_proceedToLogin();
|
_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.
|
/// Navigate to login screen and clear navigation stack.
|
||||||
void _proceedToLogin() {
|
void _proceedToLogin() {
|
||||||
// Clear service caches on sign-out
|
// Clear session stores on sign-out
|
||||||
DataConnectService.instance.handleSignOut();
|
V2SessionService.instance.handleSignOut();
|
||||||
|
ClientSessionStore.instance.clear();
|
||||||
|
|
||||||
// Navigate to authentication
|
// Navigate to authentication
|
||||||
Modular.to.toClientGetStartedPage();
|
Modular.to.toClientGetStartedPage();
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import Foundation
|
|||||||
|
|
||||||
import file_picker
|
import file_picker
|
||||||
import file_selector_macos
|
import file_selector_macos
|
||||||
import firebase_app_check
|
|
||||||
import firebase_auth
|
import firebase_auth
|
||||||
import firebase_core
|
import firebase_core
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
@@ -20,7 +19,6 @@ import url_launcher_macos
|
|||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
|
|
||||||
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
||||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
|
|||||||
@@ -34,14 +34,14 @@ dependencies:
|
|||||||
path: ../../packages/features/client/orders/create_order
|
path: ../../packages/features/client/orders/create_order
|
||||||
krow_core:
|
krow_core:
|
||||||
path: ../../packages/core
|
path: ../../packages/core
|
||||||
|
krow_domain:
|
||||||
|
path: ../../packages/domain
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
flutter_modular: ^6.3.2
|
flutter_modular: ^6.3.2
|
||||||
flutter_bloc: ^8.1.3
|
flutter_bloc: ^8.1.3
|
||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
firebase_core: ^4.4.0
|
firebase_core: ^4.4.0
|
||||||
krow_data_connect: ^0.0.1
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -20,11 +20,6 @@ public final class GeneratedPluginRegistrant {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", 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 {
|
try {
|
||||||
flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.auth.FlutterFirebaseAuthPlugin());
|
flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.auth.FlutterFirebaseAuthPlugin());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -50,11 +45,6 @@ public final class GeneratedPluginRegistrant {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Error registering plugin geolocator_android, com.baseflow.geolocator.GeolocatorPlugin", 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 {
|
try {
|
||||||
flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin());
|
flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
@@ -12,12 +12,6 @@
|
|||||||
@import file_picker;
|
@import file_picker;
|
||||||
#endif
|
#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>)
|
#if __has_include(<firebase_auth/FLTFirebaseAuthPlugin.h>)
|
||||||
#import <firebase_auth/FLTFirebaseAuthPlugin.h>
|
#import <firebase_auth/FLTFirebaseAuthPlugin.h>
|
||||||
#else
|
#else
|
||||||
@@ -42,12 +36,6 @@
|
|||||||
@import geolocator_apple;
|
@import geolocator_apple;
|
||||||
#endif
|
#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>)
|
#if __has_include(<image_picker_ios/FLTImagePickerPlugin.h>)
|
||||||
#import <image_picker_ios/FLTImagePickerPlugin.h>
|
#import <image_picker_ios/FLTImagePickerPlugin.h>
|
||||||
#else
|
#else
|
||||||
@@ -88,12 +76,10 @@
|
|||||||
|
|
||||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
||||||
[FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]];
|
[FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]];
|
||||||
[FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]];
|
|
||||||
[FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]];
|
[FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]];
|
||||||
[FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]];
|
[FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]];
|
||||||
[FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]];
|
[FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]];
|
||||||
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
|
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
|
||||||
[FLTGoogleMapsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTGoogleMapsPlugin"]];
|
|
||||||
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
||||||
[FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]];
|
[FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]];
|
||||||
[RecordIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"RecordIosPlugin"]];
|
[RecordIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"RecordIosPlugin"]];
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.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:krowwithus_staff/firebase_options.dart';
|
||||||
import 'package:staff_authentication/staff_authentication.dart'
|
import 'package:staff_authentication/staff_authentication.dart'
|
||||||
as staff_authentication;
|
as staff_authentication;
|
||||||
@@ -29,14 +28,6 @@ void main() async {
|
|||||||
logStateChanges: false, // Set to true for verbose debugging
|
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(
|
runApp(
|
||||||
ModularApp(
|
ModularApp(
|
||||||
module: AppModule(),
|
module: AppModule(),
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.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.
|
/// A widget that listens to session state changes and handles global reactions.
|
||||||
///
|
///
|
||||||
@@ -28,11 +29,20 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_setupSessionListener();
|
_initializeSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setupSessionListener() {
|
void _initializeSession() {
|
||||||
_sessionSubscription = DataConnectService.instance.onSessionStateChanged
|
// 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) {
|
.listen((SessionState state) {
|
||||||
_handleSessionChange(state);
|
_handleSessionChange(state);
|
||||||
});
|
});
|
||||||
@@ -65,6 +75,19 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
_sessionExpiredDialogShown = false;
|
_sessionExpiredDialogShown = false;
|
||||||
debugPrint('[SessionListener] Authenticated: ${state.userId}');
|
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
|
// Navigate to the main app
|
||||||
Modular.to.toStaffHome();
|
Modular.to.toStaffHome();
|
||||||
break;
|
break;
|
||||||
@@ -75,7 +98,7 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
if (!_isInitialState) {
|
if (!_isInitialState) {
|
||||||
debugPrint('[SessionListener] Session error: ${state.errorMessage}');
|
debugPrint('[SessionListener] Session error: ${state.errorMessage}');
|
||||||
_showSessionErrorDialog(
|
_showSessionErrorDialog(
|
||||||
state.errorMessage ?? 'Session error occurred',
|
state.errorMessage ?? t.session.error_title,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
_isInitialState = false;
|
_isInitialState = false;
|
||||||
@@ -92,22 +115,21 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
|
|
||||||
/// Shows a dialog when the session expires.
|
/// Shows a dialog when the session expires.
|
||||||
void _showSessionExpiredDialog() {
|
void _showSessionExpiredDialog() {
|
||||||
|
final Translations translations = t;
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext dialogContext) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text('Session Expired'),
|
title: Text(translations.session.expired_title),
|
||||||
content: const Text(
|
content: Text(translations.session.expired_message),
|
||||||
'Your session has expired. Please log in again to continue.',
|
|
||||||
),
|
|
||||||
actions: <Widget>[
|
actions: <Widget>[
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Modular.to.popSafe();;
|
Navigator.of(dialogContext).pop();
|
||||||
_proceedToLogin();
|
_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.
|
/// Shows a dialog when a session error occurs, with retry option.
|
||||||
void _showSessionErrorDialog(String errorMessage) {
|
void _showSessionErrorDialog(String errorMessage) {
|
||||||
|
final Translations translations = t;
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext dialogContext) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text('Session Error'),
|
title: Text(translations.session.error_title),
|
||||||
content: Text(errorMessage),
|
content: Text(errorMessage),
|
||||||
actions: <Widget>[
|
actions: <Widget>[
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// User can retry by dismissing and continuing
|
// 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(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Modular.to.popSafe();;
|
Navigator.of(dialogContext).pop();
|
||||||
_proceedToLogin();
|
_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.
|
/// Navigate to login screen and clear navigation stack.
|
||||||
void _proceedToLogin() {
|
void _proceedToLogin() {
|
||||||
// Clear service caches on sign-out
|
// Clear session stores on sign-out
|
||||||
DataConnectService.instance.handleSignOut();
|
V2SessionService.instance.handleSignOut();
|
||||||
|
StaffSessionStore.instance.clear();
|
||||||
|
|
||||||
// Navigate to authentication
|
// Navigate to authentication
|
||||||
Modular.to.toGetStartedPage();
|
Modular.to.toGetStartedPage();
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import Foundation
|
|||||||
|
|
||||||
import file_picker
|
import file_picker
|
||||||
import file_selector_macos
|
import file_selector_macos
|
||||||
import firebase_app_check
|
|
||||||
import firebase_auth
|
import firebase_auth
|
||||||
import firebase_core
|
import firebase_core
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
@@ -20,7 +19,6 @@ import url_launcher_macos
|
|||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
|
|
||||||
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
||||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ dependencies:
|
|||||||
path: ../../packages/features/staff/staff_main
|
path: ../../packages/features/staff/staff_main
|
||||||
krow_core:
|
krow_core:
|
||||||
path: ../../packages/core
|
path: ../../packages/core
|
||||||
krow_data_connect:
|
krow_domain:
|
||||||
path: ../../packages/data_connect
|
path: ../../packages/domain
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
flutter_modular: ^6.3.0
|
flutter_modular: ^6.3.0
|
||||||
firebase_core: ^4.4.0
|
firebase_core: ^4.4.0
|
||||||
|
|||||||
@@ -16,8 +16,16 @@ export 'src/routing/routing.dart';
|
|||||||
export 'src/services/api_service/api_service.dart';
|
export 'src/services/api_service/api_service.dart';
|
||||||
export 'src/services/api_service/dio_client.dart';
|
export 'src/services/api_service/dio_client.dart';
|
||||||
|
|
||||||
// Core API Services
|
// API Mixins
|
||||||
export 'src/services/api_service/core_api_services/core_api_endpoints.dart';
|
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_service.dart';
|
||||||
export 'src/services/api_service/core_api_services/file_upload/file_upload_response.dart';
|
export 'src/services/api_service/core_api_services/file_upload/file_upload_response.dart';
|
||||||
export 'src/services/api_service/core_api_services/signed_url/signed_url_service.dart';
|
export 'src/services/api_service/core_api_services/signed_url/signed_url_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_service.dart';
|
||||||
export 'src/services/api_service/core_api_services/rapid_order/rapid_order_response.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
|
// Device Services
|
||||||
export 'src/services/device/camera/camera_service.dart';
|
export 'src/services/device/camera/camera_service.dart';
|
||||||
export 'src/services/device/gallery/gallery_service.dart';
|
export 'src/services/device/gallery/gallery_service.dart';
|
||||||
|
|||||||
@@ -13,4 +13,10 @@ class AppConfig {
|
|||||||
static const String coreApiBaseUrl = String.fromEnvironment(
|
static const String coreApiBaseUrl = String.fromEnvironment(
|
||||||
'CORE_API_BASE_URL',
|
'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',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import 'package:flutter_modular/flutter_modular.dart';
|
|||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:krow_domain/krow_domain.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';
|
import '../core.dart';
|
||||||
|
|
||||||
/// A module that provides core services and shared dependencies.
|
/// A module that provides core services and shared dependencies.
|
||||||
@@ -18,6 +22,14 @@ class CoreModule extends Module {
|
|||||||
// 2. Register the base API service
|
// 2. Register the base API service
|
||||||
i.addLazySingleton<BaseApiService>(() => ApiService(i.get<Dio>()));
|
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)
|
// 3. Register Core API Services (Orchestrators)
|
||||||
i.addLazySingleton<FileUploadService>(
|
i.addLazySingleton<FileUploadService>(
|
||||||
() => FileUploadService(i.get<BaseApiService>()),
|
() => 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<LocationService>(() => const LocationService());
|
||||||
i.addLazySingleton<NotificationService>(() => NotificationService());
|
i.addLazySingleton<NotificationService>(() => NotificationService());
|
||||||
i.addLazySingleton<StorageService>(() => StorageService());
|
i.addLazySingleton<StorageService>(() => StorageService());
|
||||||
|
|||||||
@@ -60,6 +60,20 @@ extension StaffNavigator on IModularNavigator {
|
|||||||
safePush(StaffPaths.benefits);
|
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() {
|
void toStaffMain() {
|
||||||
safePushNamedAndRemoveUntil('${StaffPaths.main}/home/', (_) => false);
|
safePushNamedAndRemoveUntil('${StaffPaths.main}/home/', (_) => false);
|
||||||
}
|
}
|
||||||
@@ -98,6 +112,13 @@ extension StaffNavigator on IModularNavigator {
|
|||||||
safeNavigate(StaffPaths.shiftDetails(shift.id), arguments: shift);
|
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() {
|
void toPersonalInfo() {
|
||||||
safePush(StaffPaths.onboardingPersonalInfo);
|
safePush(StaffPaths.onboardingPersonalInfo);
|
||||||
}
|
}
|
||||||
@@ -118,7 +139,7 @@ extension StaffNavigator on IModularNavigator {
|
|||||||
safeNavigate(StaffPaths.attire);
|
safeNavigate(StaffPaths.attire);
|
||||||
}
|
}
|
||||||
|
|
||||||
void toAttireCapture({required AttireItem item, String? initialPhotoUrl}) {
|
void toAttireCapture({required AttireChecklist item, String? initialPhotoUrl}) {
|
||||||
safeNavigate(
|
safeNavigate(
|
||||||
StaffPaths.attireCapture,
|
StaffPaths.attireCapture,
|
||||||
arguments: <String, dynamic>{
|
arguments: <String, dynamic>{
|
||||||
@@ -132,7 +153,7 @@ extension StaffNavigator on IModularNavigator {
|
|||||||
safeNavigate(StaffPaths.documents);
|
safeNavigate(StaffPaths.documents);
|
||||||
}
|
}
|
||||||
|
|
||||||
void toDocumentUpload({required StaffDocument document, String? initialUrl}) {
|
void toDocumentUpload({required ProfileDocument document, String? initialUrl}) {
|
||||||
safeNavigate(
|
safeNavigate(
|
||||||
StaffPaths.documentUpload,
|
StaffPaths.documentUpload,
|
||||||
arguments: <String, dynamic>{
|
arguments: <String, dynamic>{
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ class StaffPaths {
|
|||||||
/// Benefits overview page.
|
/// Benefits overview page.
|
||||||
static const String benefits = '/worker-main/home/benefits';
|
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.
|
/// Shifts tab - view and manage shifts.
|
||||||
///
|
///
|
||||||
/// Browse available shifts, accepted shifts, and shift history.
|
/// Browse available shifts, accepted shifts, and shift history.
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
import 'feature_gate.dart';
|
||||||
|
|
||||||
/// A service that handles HTTP communication using the [Dio] client.
|
/// A service that handles HTTP communication using the [Dio] client.
|
||||||
///
|
///
|
||||||
/// This class provides a wrapper around [Dio]'s methods to handle
|
/// Integrates [FeatureGate] for scope validation and throws typed domain
|
||||||
/// response parsing and error handling in a consistent way.
|
/// exceptions ([ApiException], [NetworkException], [ServerException]) on
|
||||||
|
/// error responses so repositories never receive silent failures.
|
||||||
class ApiService implements BaseApiService {
|
class ApiService implements BaseApiService {
|
||||||
/// Creates an [ApiService] with the given [Dio] instance.
|
/// Creates an [ApiService] with the given [Dio] instance.
|
||||||
ApiService(this._dio);
|
ApiService(this._dio);
|
||||||
@@ -15,113 +18,164 @@ class ApiService implements BaseApiService {
|
|||||||
/// Performs a GET request to the specified [endpoint].
|
/// Performs a GET request to the specified [endpoint].
|
||||||
@override
|
@override
|
||||||
Future<ApiResponse> get(
|
Future<ApiResponse> get(
|
||||||
String endpoint, {
|
ApiEndpoint endpoint, {
|
||||||
Map<String, dynamic>? params,
|
Map<String, dynamic>? params,
|
||||||
}) async {
|
}) async {
|
||||||
|
FeatureGate.instance.validateAccess(endpoint);
|
||||||
try {
|
try {
|
||||||
final Response<dynamic> response = await _dio.get<dynamic>(
|
final Response<dynamic> response = await _dio.get<dynamic>(
|
||||||
endpoint,
|
endpoint.path,
|
||||||
queryParameters: params,
|
queryParameters: params,
|
||||||
);
|
);
|
||||||
return _handleResponse(response);
|
return _handleResponse(response);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
return _handleError(e);
|
throw _mapDioException(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Performs a POST request to the specified [endpoint].
|
/// Performs a POST request to the specified [endpoint].
|
||||||
@override
|
@override
|
||||||
Future<ApiResponse> post(
|
Future<ApiResponse> post(
|
||||||
String endpoint, {
|
ApiEndpoint endpoint, {
|
||||||
dynamic data,
|
dynamic data,
|
||||||
Map<String, dynamic>? params,
|
Map<String, dynamic>? params,
|
||||||
}) async {
|
}) async {
|
||||||
|
FeatureGate.instance.validateAccess(endpoint);
|
||||||
try {
|
try {
|
||||||
final Response<dynamic> response = await _dio.post<dynamic>(
|
final Response<dynamic> response = await _dio.post<dynamic>(
|
||||||
endpoint,
|
endpoint.path,
|
||||||
data: data,
|
data: data,
|
||||||
queryParameters: params,
|
queryParameters: params,
|
||||||
);
|
);
|
||||||
return _handleResponse(response);
|
return _handleResponse(response);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
return _handleError(e);
|
throw _mapDioException(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Performs a PUT request to the specified [endpoint].
|
/// Performs a PUT request to the specified [endpoint].
|
||||||
@override
|
@override
|
||||||
Future<ApiResponse> put(
|
Future<ApiResponse> put(
|
||||||
String endpoint, {
|
ApiEndpoint endpoint, {
|
||||||
dynamic data,
|
dynamic data,
|
||||||
Map<String, dynamic>? params,
|
Map<String, dynamic>? params,
|
||||||
}) async {
|
}) async {
|
||||||
|
FeatureGate.instance.validateAccess(endpoint);
|
||||||
try {
|
try {
|
||||||
final Response<dynamic> response = await _dio.put<dynamic>(
|
final Response<dynamic> response = await _dio.put<dynamic>(
|
||||||
endpoint,
|
endpoint.path,
|
||||||
data: data,
|
data: data,
|
||||||
queryParameters: params,
|
queryParameters: params,
|
||||||
);
|
);
|
||||||
return _handleResponse(response);
|
return _handleResponse(response);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
return _handleError(e);
|
throw _mapDioException(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Performs a PATCH request to the specified [endpoint].
|
/// Performs a PATCH request to the specified [endpoint].
|
||||||
@override
|
@override
|
||||||
Future<ApiResponse> patch(
|
Future<ApiResponse> patch(
|
||||||
String endpoint, {
|
ApiEndpoint endpoint, {
|
||||||
dynamic data,
|
dynamic data,
|
||||||
Map<String, dynamic>? params,
|
Map<String, dynamic>? params,
|
||||||
}) async {
|
}) async {
|
||||||
|
FeatureGate.instance.validateAccess(endpoint);
|
||||||
try {
|
try {
|
||||||
final Response<dynamic> response = await _dio.patch<dynamic>(
|
final Response<dynamic> response = await _dio.patch<dynamic>(
|
||||||
endpoint,
|
endpoint.path,
|
||||||
data: data,
|
data: data,
|
||||||
queryParameters: params,
|
queryParameters: params,
|
||||||
);
|
);
|
||||||
return _handleResponse(response);
|
return _handleResponse(response);
|
||||||
} on DioException catch (e) {
|
} 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].
|
/// Extracts [ApiResponse] from a successful [Response].
|
||||||
ApiResponse _handleResponse(Response<dynamic> 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(
|
return ApiResponse(
|
||||||
code: response.statusCode?.toString() ?? '200',
|
code: response.statusCode?.toString() ?? '200',
|
||||||
message: response.data['message']?.toString() ?? 'Success',
|
message: message,
|
||||||
data: response.data,
|
data: body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extracts [ApiResponse] from a [DioException].
|
/// Maps a [DioException] to a typed domain exception.
|
||||||
ApiResponse _handleError(DioException e) {
|
///
|
||||||
|
/// 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>) {
|
if (e.response?.data is Map<String, dynamic>) {
|
||||||
final Map<String, dynamic> body =
|
final Map<String, dynamic> body =
|
||||||
e.response!.data as Map<String, dynamic>;
|
e.response!.data as Map<String, dynamic>;
|
||||||
return ApiResponse(
|
|
||||||
code:
|
final String apiCode =
|
||||||
body['code']?.toString() ??
|
body['code']?.toString() ?? statusCode?.toString() ?? 'UNKNOWN';
|
||||||
e.response?.statusCode?.toString() ??
|
final String apiMessage =
|
||||||
'error',
|
body['message']?.toString() ?? e.message ?? 'An error occurred';
|
||||||
message: body['message']?.toString() ?? e.message ?? 'Error occurred',
|
|
||||||
data: body['data'],
|
// Map well-known codes to specific exceptions.
|
||||||
errors: _parseErrors(body['errors']),
|
if (apiCode == 'UNAUTHENTICATED' || statusCode == 401) {
|
||||||
);
|
return NotAuthenticatedException(technicalMessage: apiMessage);
|
||||||
}
|
}
|
||||||
return ApiResponse(
|
|
||||||
code: e.response?.statusCode?.toString() ?? 'error',
|
return ApiException(
|
||||||
message: e.message ?? 'Unknown error',
|
apiCode: apiCode,
|
||||||
errors: <String, dynamic>{'exception': e.type.toString()},
|
apiMessage: apiMessage,
|
||||||
|
statusCode: statusCode,
|
||||||
|
details: body['details'],
|
||||||
|
technicalMessage: '$apiCode: $apiMessage',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to parse the errors map from various possible formats.
|
// Server error without a parseable body.
|
||||||
Map<String, dynamic> _parseErrors(dynamic errors) {
|
if (statusCode != null && statusCode >= 500) {
|
||||||
if (errors is Map) {
|
return ServerException(technicalMessage: e.message);
|
||||||
return Map<String, dynamic>.from(errors);
|
|
||||||
}
|
}
|
||||||
return const <String, dynamic>{};
|
|
||||||
|
return UnknownException(technicalMessage: e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../core_api_endpoints.dart';
|
import '../../endpoints/core_endpoints.dart';
|
||||||
import 'file_upload_response.dart';
|
import 'file_upload_response.dart';
|
||||||
|
|
||||||
/// Service for uploading files to the Core API.
|
/// Service for uploading files to the Core API.
|
||||||
@@ -26,7 +26,7 @@ class FileUploadService extends BaseCoreService {
|
|||||||
if (category != null) 'category': category,
|
if (category != null) 'category': category,
|
||||||
});
|
});
|
||||||
|
|
||||||
return api.post(CoreApiEndpoints.uploadFile, data: formData);
|
return api.post(CoreEndpoints.uploadFile, data: formData);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.code.startsWith('2')) {
|
if (res.code.startsWith('2')) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../core_api_endpoints.dart';
|
import '../../endpoints/core_endpoints.dart';
|
||||||
import 'llm_response.dart';
|
import 'llm_response.dart';
|
||||||
|
|
||||||
/// Service for invoking Large Language Models (LLM).
|
/// Service for invoking Large Language Models (LLM).
|
||||||
@@ -19,7 +19,7 @@ class LlmService extends BaseCoreService {
|
|||||||
}) async {
|
}) async {
|
||||||
final ApiResponse res = await action(() async {
|
final ApiResponse res = await action(() async {
|
||||||
return api.post(
|
return api.post(
|
||||||
CoreApiEndpoints.invokeLlm,
|
CoreEndpoints.invokeLlm,
|
||||||
data: <String, dynamic>{
|
data: <String, dynamic>{
|
||||||
'prompt': prompt,
|
'prompt': prompt,
|
||||||
if (responseJsonSchema != null)
|
if (responseJsonSchema != null)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../core_api_endpoints.dart';
|
import '../../endpoints/core_endpoints.dart';
|
||||||
import 'rapid_order_response.dart';
|
import 'rapid_order_response.dart';
|
||||||
|
|
||||||
/// Service for handling RAPID order operations (Transcription and Parsing).
|
/// Service for handling RAPID order operations (Transcription and Parsing).
|
||||||
@@ -19,7 +19,7 @@ class RapidOrderService extends BaseCoreService {
|
|||||||
}) async {
|
}) async {
|
||||||
final ApiResponse res = await action(() async {
|
final ApiResponse res = await action(() async {
|
||||||
return api.post(
|
return api.post(
|
||||||
CoreApiEndpoints.transcribeRapidOrder,
|
CoreEndpoints.transcribeRapidOrder,
|
||||||
data: <String, dynamic>{
|
data: <String, dynamic>{
|
||||||
'audioFileUri': audioFileUri,
|
'audioFileUri': audioFileUri,
|
||||||
'locale': locale,
|
'locale': locale,
|
||||||
@@ -51,7 +51,7 @@ class RapidOrderService extends BaseCoreService {
|
|||||||
}) async {
|
}) async {
|
||||||
final ApiResponse res = await action(() async {
|
final ApiResponse res = await action(() async {
|
||||||
return api.post(
|
return api.post(
|
||||||
CoreApiEndpoints.parseRapidOrder,
|
CoreEndpoints.parseRapidOrder,
|
||||||
data: <String, dynamic>{
|
data: <String, dynamic>{
|
||||||
'text': text,
|
'text': text,
|
||||||
'locale': locale,
|
'locale': locale,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../core_api_endpoints.dart';
|
import '../../endpoints/core_endpoints.dart';
|
||||||
import 'signed_url_response.dart';
|
import 'signed_url_response.dart';
|
||||||
|
|
||||||
/// Service for creating signed URLs for Cloud Storage objects.
|
/// Service for creating signed URLs for Cloud Storage objects.
|
||||||
@@ -17,7 +17,7 @@ class SignedUrlService extends BaseCoreService {
|
|||||||
}) async {
|
}) async {
|
||||||
final ApiResponse res = await action(() async {
|
final ApiResponse res = await action(() async {
|
||||||
return api.post(
|
return api.post(
|
||||||
CoreApiEndpoints.createSignedUrl,
|
CoreEndpoints.createSignedUrl,
|
||||||
data: <String, dynamic>{
|
data: <String, dynamic>{
|
||||||
'fileUri': fileUri,
|
'fileUri': fileUri,
|
||||||
'expiresInSeconds': expiresInSeconds,
|
'expiresInSeconds': expiresInSeconds,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../core_api_endpoints.dart';
|
import '../../endpoints/core_endpoints.dart';
|
||||||
import 'verification_response.dart';
|
import 'verification_response.dart';
|
||||||
|
|
||||||
/// Service for handling async verification jobs.
|
/// Service for handling async verification jobs.
|
||||||
@@ -22,7 +22,7 @@ class VerificationService extends BaseCoreService {
|
|||||||
}) async {
|
}) async {
|
||||||
final ApiResponse res = await action(() async {
|
final ApiResponse res = await action(() async {
|
||||||
return api.post(
|
return api.post(
|
||||||
CoreApiEndpoints.verifications,
|
CoreEndpoints.verifications,
|
||||||
data: <String, dynamic>{
|
data: <String, dynamic>{
|
||||||
'type': type,
|
'type': type,
|
||||||
'subjectType': subjectType,
|
'subjectType': subjectType,
|
||||||
@@ -44,7 +44,7 @@ class VerificationService extends BaseCoreService {
|
|||||||
/// Polls the status of a specific verification.
|
/// Polls the status of a specific verification.
|
||||||
Future<VerificationResponse> getStatus(String verificationId) async {
|
Future<VerificationResponse> getStatus(String verificationId) async {
|
||||||
final ApiResponse res = await action(() async {
|
final ApiResponse res = await action(() async {
|
||||||
return api.get(CoreApiEndpoints.verificationStatus(verificationId));
|
return api.get(CoreEndpoints.verificationStatus(verificationId));
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.code.startsWith('2')) {
|
if (res.code.startsWith('2')) {
|
||||||
@@ -65,7 +65,7 @@ class VerificationService extends BaseCoreService {
|
|||||||
}) async {
|
}) async {
|
||||||
final ApiResponse res = await action(() async {
|
final ApiResponse res = await action(() async {
|
||||||
return api.post(
|
return api.post(
|
||||||
CoreApiEndpoints.verificationReview(verificationId),
|
CoreEndpoints.verificationReview(verificationId),
|
||||||
data: <String, dynamic>{
|
data: <String, dynamic>{
|
||||||
'decision': decision,
|
'decision': decision,
|
||||||
if (note != null) 'note': note,
|
if (note != null) 'note': note,
|
||||||
@@ -84,7 +84,7 @@ class VerificationService extends BaseCoreService {
|
|||||||
/// Retries a verification job that failed or needs re-processing.
|
/// Retries a verification job that failed or needs re-processing.
|
||||||
Future<VerificationResponse> retryVerification(String verificationId) async {
|
Future<VerificationResponse> retryVerification(String verificationId) async {
|
||||||
final ApiResponse res = await action(() async {
|
final ApiResponse res = await action(() async {
|
||||||
return api.post(CoreApiEndpoints.verificationRetry(verificationId));
|
return api.post(CoreEndpoints.verificationRetry(verificationId));
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.code.startsWith('2')) {
|
if (res.code.startsWith('2')) {
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import 'package:dio/dio.dart';
|
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/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
|
/// A custom Dio client for the KROW project that includes basic configuration,
|
||||||
/// and an [AuthInterceptor].
|
/// [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 {
|
class DioClient extends DioMixin implements Dio {
|
||||||
DioClient([BaseOptions? baseOptions]) {
|
DioClient([BaseOptions? baseOptions]) {
|
||||||
options =
|
options =
|
||||||
baseOptions ??
|
baseOptions ??
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
|
baseUrl: AppConfig.v2ApiBaseUrl,
|
||||||
connectTimeout: const Duration(seconds: 10),
|
connectTimeout: const Duration(seconds: 10),
|
||||||
receiveTimeout: const Duration(seconds: 10),
|
receiveTimeout: const Duration(seconds: 10),
|
||||||
);
|
);
|
||||||
@@ -18,10 +24,11 @@ class DioClient extends DioMixin implements Dio {
|
|||||||
// Add interceptors
|
// Add interceptors
|
||||||
interceptors.addAll(<Interceptor>[
|
interceptors.addAll(<Interceptor>[
|
||||||
AuthInterceptor(),
|
AuthInterceptor(),
|
||||||
|
IdempotencyInterceptor(),
|
||||||
LogInterceptor(
|
LogInterceptor(
|
||||||
requestBody: true,
|
requestBody: true,
|
||||||
responseBody: true,
|
responseBody: true,
|
||||||
), // Added for better debugging
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,79 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.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 {
|
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
|
@override
|
||||||
Future<void> onRequest(
|
Future<void> onRequest(
|
||||||
RequestOptions options,
|
RequestOptions options,
|
||||||
RequestInterceptorHandler handler,
|
RequestInterceptorHandler handler,
|
||||||
) async {
|
) 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;
|
final User? user = FirebaseAuth.instance.currentUser;
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
try {
|
|
||||||
final String? token = await user.getIdToken();
|
final String? token = await user.getIdToken();
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
options.headers['Authorization'] = 'Bearer $token';
|
options.headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
rethrow;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return handler.next(options);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
|
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart' show UserRole;
|
||||||
|
|
||||||
/// Enum representing the current session state.
|
/// Enum representing the current session state.
|
||||||
enum SessionStateType { loading, authenticated, unauthenticated, error }
|
enum SessionStateType { loading, authenticated, unauthenticated, error }
|
||||||
@@ -41,7 +42,12 @@ class SessionState {
|
|||||||
'SessionState(type: $type, userId: $userId, error: $errorMessage)';
|
'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 {
|
mixin SessionHandlerMixin {
|
||||||
/// Stream controller for session state changes.
|
/// Stream controller for session state changes.
|
||||||
final StreamController<SessionState> _sessionStateController =
|
final StreamController<SessionState> _sessionStateController =
|
||||||
@@ -53,17 +59,14 @@ mixin SessionHandlerMixin {
|
|||||||
/// Public stream for listening to session state changes.
|
/// Public stream for listening to session state changes.
|
||||||
/// Late subscribers will immediately receive the last emitted state.
|
/// Late subscribers will immediately receive the last emitted state.
|
||||||
Stream<SessionState> get onSessionStateChanged {
|
Stream<SessionState> get onSessionStateChanged {
|
||||||
// Create a custom stream that emits the last state before forwarding new events
|
|
||||||
return _createStreamWithLastState();
|
return _createStreamWithLastState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a stream that emits the last state before subscribing to new events.
|
/// Creates a stream that emits the last state before subscribing to new events.
|
||||||
Stream<SessionState> _createStreamWithLastState() async* {
|
Stream<SessionState> _createStreamWithLastState() async* {
|
||||||
// If we have a last state, emit it immediately to late subscribers
|
|
||||||
if (_lastSessionState != null) {
|
if (_lastSessionState != null) {
|
||||||
yield _lastSessionState!;
|
yield _lastSessionState!;
|
||||||
}
|
}
|
||||||
// Then forward all subsequent events
|
|
||||||
yield* _sessionStateController.stream;
|
yield* _sessionStateController.stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,17 +85,17 @@ mixin SessionHandlerMixin {
|
|||||||
/// Firebase Auth instance (to be provided by implementing class).
|
/// Firebase Auth instance (to be provided by implementing class).
|
||||||
firebase_auth.FirebaseAuth get auth;
|
firebase_auth.FirebaseAuth get auth;
|
||||||
|
|
||||||
/// List of allowed roles for this app (to be set during initialization).
|
/// List of allowed roles for this app (set during initialization).
|
||||||
List<String> _allowedRoles = <String>[];
|
List<UserRole> _allowedRoles = <UserRole>[];
|
||||||
|
|
||||||
/// Initialize the auth state listener (call once on app startup).
|
/// 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;
|
_allowedRoles = allowedRoles;
|
||||||
|
|
||||||
// Cancel any existing subscription first
|
|
||||||
_authStateSubscription?.cancel();
|
_authStateSubscription?.cancel();
|
||||||
|
|
||||||
// Listen to Firebase auth state changes
|
|
||||||
_authStateSubscription = auth.authStateChanges().listen(
|
_authStateSubscription = auth.authStateChanges().listen(
|
||||||
(firebase_auth.User? user) async {
|
(firebase_auth.User? user) async {
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
@@ -108,13 +111,12 @@ mixin SessionHandlerMixin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Validates if user has one of the allowed roles.
|
/// Validates if user has one of the allowed roles.
|
||||||
/// Returns true if user role is in allowed roles, false otherwise.
|
|
||||||
Future<bool> validateUserRole(
|
Future<bool> validateUserRole(
|
||||||
String userId,
|
String userId,
|
||||||
List<String> allowedRoles,
|
List<UserRole> allowedRoles,
|
||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
final String? userRole = await fetchUserRole(userId);
|
final UserRole? userRole = await fetchUserRole(userId);
|
||||||
return userRole != null && allowedRoles.contains(userRole);
|
return userRole != null && allowedRoles.contains(userRole);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Failed to validate user role: $e');
|
debugPrint('Failed to validate user role: $e');
|
||||||
@@ -122,26 +124,50 @@ mixin SessionHandlerMixin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches user role from Data Connect.
|
/// Fetches the user role from the backend by calling `GET /auth/session`
|
||||||
/// To be implemented by concrete class.
|
/// and deriving the [UserRole] from the response context.
|
||||||
Future<String?> fetchUserRole(String userId);
|
Future<UserRole?> fetchUserRole(String userId);
|
||||||
|
|
||||||
/// Ensures the Firebase auth token is valid and refreshes if needed.
|
/// Handle user sign-in event.
|
||||||
/// Retries up to 3 times with exponential backoff before emitting error.
|
Future<void> _handleSignIn(firebase_auth.User user) async {
|
||||||
Future<void> ensureSessionValid() async {
|
try {
|
||||||
final firebase_auth.User? user = auth.currentUser;
|
_emitSessionState(SessionState.loading());
|
||||||
|
|
||||||
// No user = not authenticated, skip check
|
if (_allowedRoles.isNotEmpty) {
|
||||||
if (user == null) return;
|
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();
|
final DateTime now = DateTime.now();
|
||||||
if (_lastTokenRefreshTime != null) {
|
if (_lastTokenRefreshTime != null) {
|
||||||
final Duration timeSinceLastCheck = now.difference(
|
final Duration timeSinceLastCheck = now.difference(
|
||||||
_lastTokenRefreshTime!,
|
_lastTokenRefreshTime!,
|
||||||
);
|
);
|
||||||
if (timeSinceLastCheck < _minRefreshCheckInterval) {
|
if (timeSinceLastCheck < _minRefreshCheckInterval) {
|
||||||
return; // Skip redundant check
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,35 +176,25 @@ mixin SessionHandlerMixin {
|
|||||||
|
|
||||||
while (retryCount < maxRetries) {
|
while (retryCount < maxRetries) {
|
||||||
try {
|
try {
|
||||||
// Get token result (doesn't fetch from network unless needed)
|
final firebase_auth.IdTokenResult idToken =
|
||||||
final firebase_auth.IdTokenResult idToken = await user
|
await user.getIdTokenResult();
|
||||||
.getIdTokenResult();
|
|
||||||
|
|
||||||
// Extract expiration time
|
|
||||||
final DateTime? expiryTime = idToken.expirationTime;
|
final DateTime? expiryTime = idToken.expirationTime;
|
||||||
|
|
||||||
if (expiryTime == null) {
|
if (expiryTime == null) return;
|
||||||
return; // Token info unavailable, proceed anyway
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate time until expiry
|
|
||||||
final Duration timeUntilExpiry = expiryTime.difference(now);
|
final Duration timeUntilExpiry = expiryTime.difference(now);
|
||||||
|
|
||||||
// If token expires within 5 minutes, refresh it
|
|
||||||
if (timeUntilExpiry <= _refreshThreshold) {
|
if (timeUntilExpiry <= _refreshThreshold) {
|
||||||
await user.getIdTokenResult();
|
await user.getIdTokenResult(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last refresh check timestamp
|
|
||||||
_lastTokenRefreshTime = now;
|
_lastTokenRefreshTime = now;
|
||||||
return; // Success, exit retry loop
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
retryCount++;
|
retryCount++;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'Token validation error (attempt $retryCount/$maxRetries): $e',
|
'Token validation error (attempt $retryCount/$maxRetries): $e',
|
||||||
);
|
);
|
||||||
|
|
||||||
// If we've exhausted retries, emit error
|
|
||||||
if (retryCount >= maxRetries) {
|
if (retryCount >= maxRetries) {
|
||||||
_emitSessionState(
|
_emitSessionState(
|
||||||
SessionState.error(
|
SessionState.error(
|
||||||
@@ -188,9 +204,8 @@ mixin SessionHandlerMixin {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exponential backoff: 1s, 2s, 4s
|
|
||||||
final Duration backoffDuration = Duration(
|
final Duration backoffDuration = Duration(
|
||||||
seconds: 1 << (retryCount - 1), // 2^(retryCount-1)
|
seconds: 1 << (retryCount - 1),
|
||||||
);
|
);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'Retrying token validation in ${backoffDuration.inSeconds}s',
|
'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.
|
/// Handle user sign-out event.
|
||||||
void handleSignOut() {
|
void handleSignOut() {
|
||||||
_emitSessionState(SessionState.unauthenticated());
|
_emitSessionState(SessionState.unauthenticated());
|
||||||
@@ -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});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,45 @@
|
|||||||
import 'package:intl/intl.dart';
|
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
|
/// Formats a time string (ISO 8601 or HH:mm) into 12-hour format
|
||||||
/// (e.g. "9:00 AM").
|
/// (e.g. "9:00 AM").
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -32,3 +32,4 @@ dependencies:
|
|||||||
flutter_local_notifications: ^21.0.0
|
flutter_local_notifications: ^21.0.0
|
||||||
shared_preferences: ^2.5.4
|
shared_preferences: ^2.5.4
|
||||||
workmanager: ^0.9.0+3
|
workmanager: ^0.9.0+3
|
||||||
|
uuid: ^4.5.1
|
||||||
|
|||||||
@@ -12,6 +12,13 @@
|
|||||||
"english": "English",
|
"english": "English",
|
||||||
"spanish": "Español"
|
"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": {
|
"settings": {
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"change_language": "Change Language"
|
"change_language": "Change Language"
|
||||||
@@ -665,7 +672,14 @@
|
|||||||
"status": {
|
"status": {
|
||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
"submitted": "Submitted"
|
"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": {
|
"auto_match": {
|
||||||
@@ -785,6 +799,9 @@
|
|||||||
"personal_info": {
|
"personal_info": {
|
||||||
"title": "Personal Info",
|
"title": "Personal Info",
|
||||||
"change_photo_hint": "Tap to change photo",
|
"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",
|
"full_name_label": "Full Name",
|
||||||
"email_label": "Email",
|
"email_label": "Email",
|
||||||
"phone_label": "Phone Number",
|
"phone_label": "Phone Number",
|
||||||
@@ -820,6 +837,8 @@
|
|||||||
"custom_skills_title": "Custom Skills:",
|
"custom_skills_title": "Custom Skills:",
|
||||||
"custom_skill_hint": "Add custom skill...",
|
"custom_skill_hint": "Add custom skill...",
|
||||||
"save_button": "Save & Continue",
|
"save_button": "Save & Continue",
|
||||||
|
"save_success": "Experience saved successfully",
|
||||||
|
"save_error": "An error occurred",
|
||||||
"industries": {
|
"industries": {
|
||||||
"hospitality": "Hospitality",
|
"hospitality": "Hospitality",
|
||||||
"food_service": "Food Service",
|
"food_service": "Food Service",
|
||||||
@@ -827,6 +846,8 @@
|
|||||||
"events": "Events",
|
"events": "Events",
|
||||||
"retail": "Retail",
|
"retail": "Retail",
|
||||||
"healthcare": "Healthcare",
|
"healthcare": "Healthcare",
|
||||||
|
"catering": "Catering",
|
||||||
|
"cafe": "Cafe",
|
||||||
"other": "Other"
|
"other": "Other"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
@@ -957,11 +978,15 @@
|
|||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"clock_in_anyway": "Clock In Anyway",
|
"clock_in_anyway": "Clock In Anyway",
|
||||||
"override_title": "Justification Required",
|
"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_hint": "Enter your justification...",
|
||||||
"override_submit": "Clock In",
|
"override_submit": "Submit",
|
||||||
"overridden_title": "Location Not Verified",
|
"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": {
|
"availability": {
|
||||||
@@ -1152,6 +1177,8 @@
|
|||||||
"upload": {
|
"upload": {
|
||||||
"instructions": "Please select a valid PDF file to upload.",
|
"instructions": "Please select a valid PDF file to upload.",
|
||||||
"pdf_banner": "Only PDF files are accepted. Maximum file size is 10MB.",
|
"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.",
|
"file_not_found": "File not found.",
|
||||||
"submit": "Submit Document",
|
"submit": "Submit Document",
|
||||||
"select_pdf": "Select PDF File",
|
"select_pdf": "Select PDF File",
|
||||||
@@ -1330,14 +1357,22 @@
|
|||||||
"applying_dialog": {
|
"applying_dialog": {
|
||||||
"title": "Applying"
|
"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": {
|
"my_shift_card": {
|
||||||
"submit_for_approval": "Submit for Approval",
|
"submit_for_approval": "Submit for Approval",
|
||||||
"timesheet_submitted": "Timesheet submitted for client approval",
|
"timesheet_submitted": "Timesheet submitted for client approval",
|
||||||
"checked_in": "Checked in",
|
"checked_in": "Checked in",
|
||||||
"submitted": "SUBMITTED",
|
"submitted": "SUBMITTED",
|
||||||
"ready_to_submit": "READY TO SUBMIT"
|
"ready_to_submit": "READY TO SUBMIT",
|
||||||
|
"submitting": "SUBMITTING..."
|
||||||
},
|
},
|
||||||
"shift_location": {
|
"shift_location": {
|
||||||
"could_not_open_maps": "Could not open maps"
|
"could_not_open_maps": "Could not open maps"
|
||||||
@@ -1450,11 +1485,14 @@
|
|||||||
"shift": {
|
"shift": {
|
||||||
"no_open_roles": "There are no open positions available for this shift.",
|
"no_open_roles": "There are no open positions available for this shift.",
|
||||||
"application_not_found": "Your application couldn't be found.",
|
"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": {
|
"clock_in": {
|
||||||
"location_verification_required": "Please wait for location verification before clocking 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": {
|
"generic": {
|
||||||
"unknown": "Something went wrong. Please try again.",
|
"unknown": "Something went wrong. Please try again.",
|
||||||
@@ -1755,7 +1793,9 @@
|
|||||||
"workers": "Workers",
|
"workers": "Workers",
|
||||||
"error_occurred": "An error occurred",
|
"error_occurred": "An error occurred",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"shifts": "Shifts"
|
"shifts": "Shifts",
|
||||||
|
"overall_coverage": "Overall Coverage",
|
||||||
|
"live_activity": "LIVE ACTIVITY"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"prev_week": "\u2190 Prev Week",
|
"prev_week": "\u2190 Prev Week",
|
||||||
@@ -1764,7 +1804,9 @@
|
|||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"checked_in": "Checked In",
|
"checked_in": "Checked In",
|
||||||
"en_route": "En Route"
|
"en_route": "En Route",
|
||||||
|
"on_site": "On Site",
|
||||||
|
"late": "Late"
|
||||||
},
|
},
|
||||||
"alert": {
|
"alert": {
|
||||||
"workers_running_late(count)": {
|
"workers_running_late(count)": {
|
||||||
@@ -1772,6 +1814,45 @@
|
|||||||
"other": "$count workers are running late"
|
"other": "$count workers are running late"
|
||||||
},
|
},
|
||||||
"auto_backup_searching": "Auto-backup system is searching for replacements."
|
"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": {
|
"client_reports_common": {
|
||||||
|
|||||||
@@ -12,6 +12,13 @@
|
|||||||
"english": "English",
|
"english": "English",
|
||||||
"spanish": "Español"
|
"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": {
|
"settings": {
|
||||||
"language": "Idioma",
|
"language": "Idioma",
|
||||||
"change_language": "Cambiar Idioma"
|
"change_language": "Cambiar Idioma"
|
||||||
@@ -660,7 +667,14 @@
|
|||||||
"status": {
|
"status": {
|
||||||
"pending": "Pendiente",
|
"pending": "Pendiente",
|
||||||
"submitted": "Enviado"
|
"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": {
|
"auto_match": {
|
||||||
@@ -780,6 +794,9 @@
|
|||||||
"personal_info": {
|
"personal_info": {
|
||||||
"title": "Informaci\u00f3n Personal",
|
"title": "Informaci\u00f3n Personal",
|
||||||
"change_photo_hint": "Toca para cambiar foto",
|
"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",
|
"full_name_label": "Nombre Completo",
|
||||||
"email_label": "Correo Electr\u00f3nico",
|
"email_label": "Correo Electr\u00f3nico",
|
||||||
"phone_label": "N\u00famero de Tel\u00e9fono",
|
"phone_label": "N\u00famero de Tel\u00e9fono",
|
||||||
@@ -815,6 +832,8 @@
|
|||||||
"custom_skills_title": "Habilidades personalizadas:",
|
"custom_skills_title": "Habilidades personalizadas:",
|
||||||
"custom_skill_hint": "A\u00f1adir habilidad...",
|
"custom_skill_hint": "A\u00f1adir habilidad...",
|
||||||
"save_button": "Guardar y continuar",
|
"save_button": "Guardar y continuar",
|
||||||
|
"save_success": "Experiencia guardada exitosamente",
|
||||||
|
"save_error": "Ocurrió un error",
|
||||||
"industries": {
|
"industries": {
|
||||||
"hospitality": "Hoteler\u00eda",
|
"hospitality": "Hoteler\u00eda",
|
||||||
"food_service": "Servicio de alimentos",
|
"food_service": "Servicio de alimentos",
|
||||||
@@ -822,6 +841,8 @@
|
|||||||
"events": "Eventos",
|
"events": "Eventos",
|
||||||
"retail": "Venta al por menor",
|
"retail": "Venta al por menor",
|
||||||
"healthcare": "Cuidado de la salud",
|
"healthcare": "Cuidado de la salud",
|
||||||
|
"catering": "Catering",
|
||||||
|
"cafe": "Cafetería",
|
||||||
"other": "Otro"
|
"other": "Otro"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
@@ -952,11 +973,15 @@
|
|||||||
"retry": "Reintentar",
|
"retry": "Reintentar",
|
||||||
"clock_in_anyway": "Registrar Entrada",
|
"clock_in_anyway": "Registrar Entrada",
|
||||||
"override_title": "Justificación Requerida",
|
"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_hint": "Ingrese su justificación...",
|
||||||
"override_submit": "Registrar Entrada",
|
"override_submit": "Enviar",
|
||||||
"overridden_title": "Ubicación No Verificada",
|
"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": {
|
"availability": {
|
||||||
@@ -1147,6 +1172,8 @@
|
|||||||
"upload": {
|
"upload": {
|
||||||
"instructions": "Por favor selecciona un archivo PDF válido para subir.",
|
"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": "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",
|
"submit": "Enviar Documento",
|
||||||
"select_pdf": "Seleccionar Archivo PDF",
|
"select_pdf": "Seleccionar Archivo PDF",
|
||||||
"attestation": "Certifico que este documento es genuino y válido.",
|
"attestation": "Certifico que este documento es genuino y válido.",
|
||||||
@@ -1325,14 +1352,22 @@
|
|||||||
"applying_dialog": {
|
"applying_dialog": {
|
||||||
"title": "Solicitando"
|
"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": {
|
"my_shift_card": {
|
||||||
"submit_for_approval": "Enviar para Aprobación",
|
"submit_for_approval": "Enviar para Aprobación",
|
||||||
"timesheet_submitted": "Hoja de tiempo enviada para aprobación del cliente",
|
"timesheet_submitted": "Hoja de tiempo enviada para aprobación del cliente",
|
||||||
"checked_in": "Registrado",
|
"checked_in": "Registrado",
|
||||||
"submitted": "ENVIADO",
|
"submitted": "ENVIADO",
|
||||||
"ready_to_submit": "LISTO PARA ENVIAR"
|
"ready_to_submit": "LISTO PARA ENVIAR",
|
||||||
|
"submitting": "ENVIANDO..."
|
||||||
},
|
},
|
||||||
"shift_location": {
|
"shift_location": {
|
||||||
"could_not_open_maps": "No se pudo abrir mapas"
|
"could_not_open_maps": "No se pudo abrir mapas"
|
||||||
@@ -1445,11 +1480,14 @@
|
|||||||
"shift": {
|
"shift": {
|
||||||
"no_open_roles": "No hay posiciones abiertas disponibles para este turno.",
|
"no_open_roles": "No hay posiciones abiertas disponibles para este turno.",
|
||||||
"application_not_found": "No se pudo encontrar tu solicitud.",
|
"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": {
|
"clock_in": {
|
||||||
"location_verification_required": "Por favor, espera la verificaci\u00f3n de ubicaci\u00f3n antes de registrar entrada.",
|
"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": {
|
"generic": {
|
||||||
"unknown": "Algo sali\u00f3 mal. Por favor, intenta de nuevo.",
|
"unknown": "Algo sali\u00f3 mal. Por favor, intenta de nuevo.",
|
||||||
@@ -1755,7 +1793,9 @@
|
|||||||
"workers": "Trabajadores",
|
"workers": "Trabajadores",
|
||||||
"error_occurred": "Ocurri\u00f3 un error",
|
"error_occurred": "Ocurri\u00f3 un error",
|
||||||
"retry": "Reintentar",
|
"retry": "Reintentar",
|
||||||
"shifts": "Turnos"
|
"shifts": "Turnos",
|
||||||
|
"overall_coverage": "Cobertura General",
|
||||||
|
"live_activity": "ACTIVIDAD EN VIVO"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"prev_week": "\u2190 Semana Anterior",
|
"prev_week": "\u2190 Semana Anterior",
|
||||||
@@ -1764,7 +1804,9 @@
|
|||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"checked_in": "Registrado",
|
"checked_in": "Registrado",
|
||||||
"en_route": "En Camino"
|
"en_route": "En Camino",
|
||||||
|
"on_site": "En Sitio",
|
||||||
|
"late": "Tarde"
|
||||||
},
|
},
|
||||||
"alert": {
|
"alert": {
|
||||||
"workers_running_late(count)": {
|
"workers_running_late(count)": {
|
||||||
@@ -1772,6 +1814,45 @@
|
|||||||
"other": "$count trabajadores est\u00e1n llegando tarde"
|
"other": "$count trabajadores est\u00e1n llegando tarde"
|
||||||
},
|
},
|
||||||
"auto_backup_searching": "El sistema de respaldo autom\u00e1tico est\u00e1 buscando reemplazos."
|
"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": {
|
"client_reports_common": {
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ String _translateShiftError(String errorType) {
|
|||||||
return t.errors.shift.application_not_found;
|
return t.errors.shift.application_not_found;
|
||||||
case 'no_active_shift':
|
case 'no_active_shift':
|
||||||
return t.errors.shift.no_active_shift;
|
return t.errors.shift.no_active_shift;
|
||||||
|
case 'not_found':
|
||||||
|
return t.errors.shift.not_found;
|
||||||
default:
|
default:
|
||||||
return t.errors.generic.unknown;
|
return t.errors.generic.unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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});
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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});
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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});
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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._();
|
|
||||||
}
|
|
||||||
@@ -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._();
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -245,7 +245,7 @@ class UiColors {
|
|||||||
static const Color buttonPrimaryStill = primary;
|
static const Color buttonPrimaryStill = primary;
|
||||||
|
|
||||||
/// Primary button hover (#082EB2)
|
/// Primary button hover (#082EB2)
|
||||||
static const Color buttonPrimaryHover = Color(0xFF082EB2);
|
static const Color buttonPrimaryHover = Color.fromARGB(255, 8, 46, 178);
|
||||||
|
|
||||||
/// Primary button inactive (#F1F3F5)
|
/// Primary button inactive (#F1F3F5)
|
||||||
static const Color buttonPrimaryInactive = secondary;
|
static const Color buttonPrimaryInactive = secondary;
|
||||||
|
|||||||
@@ -368,7 +368,6 @@ class UiTypography {
|
|||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
letterSpacing: -0.1,
|
|
||||||
color: UiColors.textPrimary,
|
color: UiColors.textPrimary,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ class UiChip extends StatelessWidget {
|
|||||||
|
|
||||||
final Row content = Row(
|
final Row content = Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
if (leadingIcon != null) ...<Widget>[
|
if (leadingIcon != null) ...<Widget>[
|
||||||
Icon(leadingIcon, size: iconSize, color: contentColor),
|
Icon(leadingIcon, size: iconSize, color: contentColor),
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ class UiNoticeBanner extends StatelessWidget {
|
|||||||
color: backgroundColor ?? UiColors.primary.withValues(alpha: 0.08),
|
color: backgroundColor ?? UiColors.primary.withValues(alpha: 0.08),
|
||||||
borderRadius: borderRadius ?? UiConstants.radiusLg,
|
borderRadius: borderRadius ?? UiConstants.radiusLg,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
if (leading != null) ...<Widget>[
|
if (leading != null) ...<Widget>[
|
||||||
@@ -76,21 +79,26 @@ class UiNoticeBanner extends StatelessWidget {
|
|||||||
] else if (icon != null) ...<Widget>[
|
] else if (icon != null) ...<Widget>[
|
||||||
Icon(icon, color: iconColor ?? UiColors.primary, size: 24),
|
Icon(icon, color: iconColor ?? UiColors.primary, size: 24),
|
||||||
const SizedBox(width: UiConstants.space3),
|
const SizedBox(width: UiConstants.space3),
|
||||||
],
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
Text(
|
Text(
|
||||||
title,
|
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>[
|
if (description != null) ...<Widget>[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: UiConstants.space2),
|
||||||
Text(
|
Text(
|
||||||
description!,
|
description!,
|
||||||
style: UiTypography.body3r.copyWith(
|
style: UiTypography.body3r.copyWith(
|
||||||
color: descriptionColor,
|
color: descriptionColor ?? UiColors.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -100,7 +108,6 @@ class UiNoticeBanner extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,27 @@
|
|||||||
/// Note: Repository Interfaces are now located in their respective Feature packages.
|
/// Note: Repository Interfaces are now located in their respective Feature packages.
|
||||||
library;
|
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
|
// 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/api_response.dart';
|
||||||
export 'src/core/services/api_services/base_api_service.dart';
|
export 'src/core/services/api_services/base_api_service.dart';
|
||||||
export 'src/core/services/api_services/base_core_service.dart';
|
export 'src/core/services/api_services/base_core_service.dart';
|
||||||
@@ -22,124 +42,91 @@ export 'src/core/models/device_location.dart';
|
|||||||
// Users & Membership
|
// Users & Membership
|
||||||
export 'src/entities/users/user.dart';
|
export 'src/entities/users/user.dart';
|
||||||
export 'src/entities/users/staff.dart';
|
export 'src/entities/users/staff.dart';
|
||||||
export 'src/entities/users/membership.dart';
|
|
||||||
export 'src/entities/users/biz_member.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
|
// Business & Organization
|
||||||
export 'src/entities/business/business.dart';
|
export 'src/entities/business/business.dart';
|
||||||
export 'src/entities/business/business_setting.dart';
|
|
||||||
export 'src/entities/business/hub.dart';
|
export 'src/entities/business/hub.dart';
|
||||||
export 'src/entities/business/hub_department.dart';
|
|
||||||
export 'src/entities/business/vendor.dart';
|
export 'src/entities/business/vendor.dart';
|
||||||
export 'src/entities/business/cost_center.dart';
|
export 'src/entities/business/cost_center.dart';
|
||||||
|
export 'src/entities/business/vendor_role.dart';
|
||||||
// Events & Assignments
|
export 'src/entities/business/hub_manager.dart';
|
||||||
export 'src/entities/events/event.dart';
|
export 'src/entities/business/team_member.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';
|
|
||||||
|
|
||||||
// Shifts
|
// Shifts
|
||||||
export 'src/entities/shifts/shift.dart';
|
export 'src/entities/shifts/shift.dart';
|
||||||
export 'src/adapters/shifts/shift_adapter.dart';
|
export 'src/entities/shifts/today_shift.dart';
|
||||||
export 'src/entities/shifts/break/break.dart';
|
export 'src/entities/shifts/assigned_shift.dart';
|
||||||
export 'src/adapters/shifts/break/break_adapter.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
|
// Orders
|
||||||
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';
|
|
||||||
export 'src/entities/orders/order_item.dart';
|
export 'src/entities/orders/order_item.dart';
|
||||||
export 'src/entities/orders/reorder_data.dart';
|
export 'src/entities/orders/assigned_worker_summary.dart';
|
||||||
|
export 'src/entities/orders/order_preview.dart';
|
||||||
// Skills & Certs
|
export 'src/entities/orders/recent_order.dart';
|
||||||
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';
|
|
||||||
|
|
||||||
// Financial & Payroll
|
// Financial & Payroll
|
||||||
export 'src/entities/benefits/benefit.dart';
|
export 'src/entities/benefits/benefit.dart';
|
||||||
|
export 'src/entities/benefits/benefit_history.dart';
|
||||||
export 'src/entities/financial/invoice.dart';
|
export 'src/entities/financial/invoice.dart';
|
||||||
export 'src/entities/financial/time_card.dart';
|
export 'src/entities/financial/billing_account.dart';
|
||||||
export 'src/entities/financial/invoice_item.dart';
|
export 'src/entities/financial/current_bill.dart';
|
||||||
export 'src/entities/financial/invoice_decline.dart';
|
export 'src/entities/financial/savings.dart';
|
||||||
export 'src/entities/financial/staff_payment.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/payment_summary.dart';
|
||||||
export 'src/entities/financial/billing_period.dart';
|
export 'src/entities/financial/staff_payment.dart';
|
||||||
export 'src/entities/financial/bank_account/bank_account.dart';
|
export 'src/entities/financial/payment_chart_point.dart';
|
||||||
export 'src/entities/financial/bank_account/business_bank_account.dart';
|
export 'src/entities/financial/time_card.dart';
|
||||||
export 'src/entities/financial/bank_account/staff_bank_account.dart';
|
|
||||||
export 'src/adapters/financial/bank_account/bank_account_adapter.dart';
|
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
export 'src/entities/profile/staff_document.dart';
|
export 'src/entities/profile/staff_personal_info.dart';
|
||||||
export 'src/entities/profile/document_verification_status.dart';
|
export 'src/entities/profile/profile_section_status.dart';
|
||||||
export 'src/entities/profile/staff_certificate.dart';
|
export 'src/entities/profile/profile_completion.dart';
|
||||||
export 'src/entities/profile/compliance_type.dart';
|
export 'src/entities/profile/profile_document.dart';
|
||||||
export 'src/entities/profile/staff_certificate_status.dart';
|
export 'src/entities/profile/certificate.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/emergency_contact.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/accessibility.dart';
|
||||||
export 'src/entities/profile/schedule.dart';
|
|
||||||
|
|
||||||
// Support & Config
|
// Ratings
|
||||||
export 'src/entities/support/addon.dart';
|
export 'src/entities/ratings/staff_rating.dart';
|
||||||
export 'src/entities/support/tag.dart';
|
|
||||||
export 'src/entities/support/media.dart';
|
|
||||||
export 'src/entities/support/working_area.dart';
|
|
||||||
|
|
||||||
// Home
|
// Home
|
||||||
export 'src/entities/home/home_dashboard_data.dart';
|
export 'src/entities/home/client_dashboard.dart';
|
||||||
export 'src/entities/home/reorder_item.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
|
// Clock-In & Availability
|
||||||
export 'src/adapters/availability/availability_adapter.dart';
|
|
||||||
export 'src/entities/clock_in/attendance_status.dart';
|
export 'src/entities/clock_in/attendance_status.dart';
|
||||||
export 'src/adapters/clock_in/clock_in_adapter.dart';
|
export 'src/entities/availability/availability_day.dart';
|
||||||
export 'src/entities/availability/availability_slot.dart';
|
export 'src/entities/availability/time_slot.dart';
|
||||||
export 'src/entities/availability/day_availability.dart';
|
|
||||||
|
|
||||||
// Coverage
|
// Coverage
|
||||||
export 'src/entities/coverage_domain/coverage_shift.dart';
|
export 'src/entities/coverage_domain/shift_with_workers.dart';
|
||||||
export 'src/entities/coverage_domain/coverage_worker.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/coverage_stats.dart';
|
||||||
|
export 'src/entities/coverage_domain/core_team_member.dart';
|
||||||
|
|
||||||
// Adapters
|
// Reports
|
||||||
export 'src/adapters/profile/emergency_contact_adapter.dart';
|
export 'src/entities/reports/report_summary.dart';
|
||||||
export 'src/adapters/profile/experience_adapter.dart';
|
export 'src/entities/reports/daily_ops_report.dart';
|
||||||
export 'src/entities/profile/experience_skill.dart';
|
export 'src/entities/reports/spend_data_point.dart';
|
||||||
export 'src/adapters/profile/bank_account_adapter.dart';
|
export 'src/entities/reports/coverage_report.dart';
|
||||||
export 'src/adapters/profile/tax_form_adapter.dart';
|
export 'src/entities/reports/forecast_report.dart';
|
||||||
export 'src/adapters/financial/payment_adapter.dart';
|
export 'src/entities/reports/performance_report.dart';
|
||||||
|
export 'src/entities/reports/no_show_report.dart';
|
||||||
|
|
||||||
// Exceptions
|
// Exceptions
|
||||||
export 'src/exceptions/app_exception.dart';
|
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';
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
Reference in New Issue
Block a user