feat: Migrate staff profile features from Data Connect to V2 REST API
- Removed data_connect package from mobile pubspec.yaml. - Added documentation for V2 profile migration status and QA findings. - Implemented new session management with ClientSessionStore and StaffSessionStore. - Created V2SessionService for handling user sessions via the V2 API. - Developed use cases for cancelling late worker assignments and submitting worker reviews. - Added arguments and use cases for payment chart retrieval and profile completion checks. - Implemented repository interfaces and their implementations for staff main and profile features. - Ensured proper error handling and validation in use cases.
This commit is contained in:
@@ -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()`
|
||||||
@@ -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.
|
||||||
@@ -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/`
|
||||||
@@ -72,7 +73,7 @@ If any of these files are missing or unreadable, notify the user before proceedi
|
|||||||
The mobile apps are migrating from Firebase Data Connect (direct DB) to V2 REST API. Follow these rules for ALL new and migrated features:
|
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
|
### Backend Access
|
||||||
- **Use `ApiService.get/post/put/delete`** for ALL backend calls — NEVER use Data Connect connectors
|
- **Use `ApiService.get/post/put/delete`** for ALL backend calls
|
||||||
- Import `ApiService` from `package:krow_core/core.dart`
|
- Import `ApiService` from `package:krow_core/core.dart`
|
||||||
- Use `V2ApiEndpoints` from `package:krow_core/core.dart` for endpoint URLs
|
- 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
|
- V2 API docs are at `docs/BACKEND/API_GUIDES/V2/` — check response shapes before writing code
|
||||||
@@ -87,7 +88,7 @@ The mobile apps are migrating from Firebase Data Connect (direct DB) to V2 REST
|
|||||||
- **RepoImpl lives in the feature package** at `data/repositories/`
|
- **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
|
- **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/`
|
- **Simple features** (read-only, no business logic) = just `data/` + `presentation/`
|
||||||
- Do NOT import from `packages/data_connect/` — it is deprecated
|
- Do NOT import from `packages/data_connect/` — deleted
|
||||||
|
|
||||||
### Status & Type Enums
|
### 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:
|
All status/type fields from the V2 API must use Dart enums, NOT raw strings. Parse at the `fromJson` boundary with a safe fallback:
|
||||||
@@ -169,7 +170,7 @@ 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
|
||||||
- Implement repository classes using `ApiService` with `V2ApiEndpoints` — NOT DataConnectService
|
- Implement repository classes using `ApiService` with `V2ApiEndpoints`
|
||||||
- Parse V2 API JSON responses into domain entities via `Entity.fromJson()`
|
- 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
|
||||||
@@ -266,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,8 +30,9 @@ 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
|
// Initialize V2 session listener for Firebase Auth state changes.
|
||||||
DataConnectService.instance.initializeAuthListener(
|
// Role validation calls GET /auth/session via the V2 API.
|
||||||
|
V2SessionService.instance.initializeAuthListener(
|
||||||
allowedRoles: <String>[
|
allowedRoles: <String>[
|
||||||
'CLIENT',
|
'CLIENT',
|
||||||
'BUSINESS',
|
'BUSINESS',
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'dart:async';
|
|||||||
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';
|
|
||||||
|
|
||||||
/// A widget that listens to session state changes and handles global reactions.
|
/// A widget that listens to session state changes and handles global reactions.
|
||||||
///
|
///
|
||||||
@@ -32,7 +31,7 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _setupSessionListener() {
|
void _setupSessionListener() {
|
||||||
_sessionSubscription = DataConnectService.instance.onSessionStateChanged
|
_sessionSubscription = V2SessionService.instance.onSessionStateChanged
|
||||||
.listen((SessionState state) {
|
.listen((SessionState state) {
|
||||||
_handleSessionChange(state);
|
_handleSessionChange(state);
|
||||||
});
|
});
|
||||||
@@ -134,7 +133,7 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Modular.to.popSafe();;
|
Modular.to.popSafe();
|
||||||
_proceedToLogin();
|
_proceedToLogin();
|
||||||
},
|
},
|
||||||
child: const Text('Log Out'),
|
child: const Text('Log Out'),
|
||||||
@@ -147,8 +146,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"))
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ dependencies:
|
|||||||
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,8 +28,9 @@ 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
|
// Initialize V2 session listener for Firebase Auth state changes.
|
||||||
DataConnectService.instance.initializeAuthListener(
|
// Role validation calls GET /auth/session via the V2 API.
|
||||||
|
V2SessionService.instance.initializeAuthListener(
|
||||||
allowedRoles: <String>[
|
allowedRoles: <String>[
|
||||||
'STAFF',
|
'STAFF',
|
||||||
'BOTH',
|
'BOTH',
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'dart:async';
|
|||||||
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';
|
|
||||||
|
|
||||||
/// A widget that listens to session state changes and handles global reactions.
|
/// A widget that listens to session state changes and handles global reactions.
|
||||||
///
|
///
|
||||||
@@ -32,7 +31,7 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _setupSessionListener() {
|
void _setupSessionListener() {
|
||||||
_sessionSubscription = DataConnectService.instance.onSessionStateChanged
|
_sessionSubscription = V2SessionService.instance.onSessionStateChanged
|
||||||
.listen((SessionState state) {
|
.listen((SessionState state) {
|
||||||
_handleSessionChange(state);
|
_handleSessionChange(state);
|
||||||
});
|
});
|
||||||
@@ -65,6 +64,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;
|
||||||
@@ -104,7 +116,7 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
actions: <Widget>[
|
actions: <Widget>[
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Modular.to.popSafe();;
|
Modular.to.popSafe();
|
||||||
_proceedToLogin();
|
_proceedToLogin();
|
||||||
},
|
},
|
||||||
child: const Text('Log In'),
|
child: const Text('Log In'),
|
||||||
@@ -134,7 +146,7 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Modular.to.popSafe();;
|
Modular.to.popSafe();
|
||||||
_proceedToLogin();
|
_proceedToLogin();
|
||||||
},
|
},
|
||||||
child: const Text('Log Out'),
|
child: const Text('Log Out'),
|
||||||
@@ -147,8 +159,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,6 @@ 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:
|
|
||||||
path: ../../packages/data_connect
|
|
||||||
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
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ export 'src/services/api_service/core_api_services/verification/verification_res
|
|||||||
export 'src/services/api_service/core_api_services/rapid_order/rapid_order_service.dart';
|
export 'src/services/api_service/core_api_services/rapid_order/rapid_order_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';
|
||||||
|
|
||||||
// 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';
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ 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. Wire the V2 session service with the API service.
|
||||||
|
// This uses a post-registration callback so the singleton gets
|
||||||
|
// its dependency as soon as the injector resolves BaseApiService.
|
||||||
|
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>()),
|
||||||
|
|||||||
@@ -98,6 +98,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 +125,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 +139,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>{
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ mixin SessionHandlerMixin {
|
|||||||
|
|
||||||
final Duration timeUntilExpiry = expiryTime.difference(now);
|
final Duration timeUntilExpiry = expiryTime.difference(now);
|
||||||
if (timeUntilExpiry <= _refreshThreshold) {
|
if (timeUntilExpiry <= _refreshThreshold) {
|
||||||
await user.getIdTokenResult();
|
await user.getIdTokenResult(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
_lastTokenRefreshTime = now;
|
_lastTokenRefreshTime = now;
|
||||||
@@ -212,9 +212,9 @@ mixin SessionHandlerMixin {
|
|||||||
final firebase_auth.IdTokenResult idToken =
|
final firebase_auth.IdTokenResult idToken =
|
||||||
await user.getIdTokenResult();
|
await user.getIdTokenResult();
|
||||||
if (idToken.expirationTime != null &&
|
if (idToken.expirationTime != null &&
|
||||||
DateTime.now().difference(idToken.expirationTime!) <
|
idToken.expirationTime!.difference(DateTime.now()) <
|
||||||
const Duration(minutes: 5)) {
|
const Duration(minutes: 5)) {
|
||||||
await user.getIdTokenResult();
|
await user.getIdTokenResult(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
_emitSessionState(SessionState.authenticated(userId: user.uid));
|
_emitSessionState(SessionState.authenticated(userId: user.uid));
|
||||||
|
|||||||
@@ -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,101 @@
|
|||||||
|
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/core_api_services/v2_api_endpoints.dart';
|
||||||
|
import '../api_service/mixins/session_handler_mixin.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 role string (e.g. `STAFF`, `BUSINESS`, `BOTH`) or `null` if
|
||||||
|
/// the call fails or the user has no role.
|
||||||
|
@override
|
||||||
|
Future<String?> fetchUserRole(String userId) async {
|
||||||
|
try {
|
||||||
|
// Wait for ApiService to be injected (happens after CoreModule.exportedBinds).
|
||||||
|
// On cold start, initializeAuthListener fires before DI is ready.
|
||||||
|
if (_apiService == null) {
|
||||||
|
debugPrint(
|
||||||
|
'[V2SessionService] ApiService not yet injected; '
|
||||||
|
'waiting for DI initialization...',
|
||||||
|
);
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||||
|
if (_apiService != null) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final BaseApiService? api = _apiService;
|
||||||
|
if (api == null) {
|
||||||
|
debugPrint(
|
||||||
|
'[V2SessionService] ApiService still null after waiting 2 s; '
|
||||||
|
'cannot fetch user role.',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ApiResponse response = await api.get(V2ApiEndpoints.session);
|
||||||
|
|
||||||
|
if (response.data is Map<String, dynamic>) {
|
||||||
|
final Map<String, dynamic> data =
|
||||||
|
response.data as Map<String, dynamic>;
|
||||||
|
final String? role = data['role'] as String?;
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[V2SessionService] Error fetching user role: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(V2ApiEndpoints.signOut);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[V2SessionService] Server sign-out failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await auth.signOut();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[V2SessionService] Error signing out: $e');
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
handleSignOut();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -785,6 +785,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",
|
||||||
|
|||||||
@@ -780,6 +780,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",
|
||||||
|
|||||||
@@ -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,265 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
|
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
|
|
||||||
/// Enum representing the current session state.
|
|
||||||
enum SessionStateType { loading, authenticated, unauthenticated, error }
|
|
||||||
|
|
||||||
/// Data class for session state.
|
|
||||||
class SessionState {
|
|
||||||
/// Creates a [SessionState].
|
|
||||||
SessionState({required this.type, this.userId, this.errorMessage});
|
|
||||||
|
|
||||||
/// Creates a loading state.
|
|
||||||
factory SessionState.loading() =>
|
|
||||||
SessionState(type: SessionStateType.loading);
|
|
||||||
|
|
||||||
/// Creates an authenticated state.
|
|
||||||
factory SessionState.authenticated({required String userId}) =>
|
|
||||||
SessionState(type: SessionStateType.authenticated, userId: userId);
|
|
||||||
|
|
||||||
/// Creates an unauthenticated state.
|
|
||||||
factory SessionState.unauthenticated() =>
|
|
||||||
SessionState(type: SessionStateType.unauthenticated);
|
|
||||||
|
|
||||||
/// Creates an error state.
|
|
||||||
factory SessionState.error(String message) =>
|
|
||||||
SessionState(type: SessionStateType.error, errorMessage: message);
|
|
||||||
|
|
||||||
/// The type of session state.
|
|
||||||
final SessionStateType type;
|
|
||||||
|
|
||||||
/// The current user ID (if authenticated).
|
|
||||||
final String? userId;
|
|
||||||
|
|
||||||
/// Error message (if error occurred).
|
|
||||||
final String? errorMessage;
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() =>
|
|
||||||
'SessionState(type: $type, userId: $userId, error: $errorMessage)';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mixin for handling Firebase Auth session management, token refresh, and state emissions.
|
|
||||||
mixin SessionHandlerMixin {
|
|
||||||
/// Stream controller for session state changes.
|
|
||||||
final StreamController<SessionState> _sessionStateController =
|
|
||||||
StreamController<SessionState>.broadcast();
|
|
||||||
|
|
||||||
/// Last emitted session state (for late subscribers).
|
|
||||||
SessionState? _lastSessionState;
|
|
||||||
|
|
||||||
/// Public stream for listening to session state changes.
|
|
||||||
/// Late subscribers will immediately receive the last emitted state.
|
|
||||||
Stream<SessionState> get onSessionStateChanged {
|
|
||||||
// Create a custom stream that emits the last state before forwarding new events
|
|
||||||
return _createStreamWithLastState();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a stream that emits the last state before subscribing to new events.
|
|
||||||
Stream<SessionState> _createStreamWithLastState() async* {
|
|
||||||
// If we have a last state, emit it immediately to late subscribers
|
|
||||||
if (_lastSessionState != null) {
|
|
||||||
yield _lastSessionState!;
|
|
||||||
}
|
|
||||||
// Then forward all subsequent events
|
|
||||||
yield* _sessionStateController.stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Last token refresh timestamp to avoid excessive checks.
|
|
||||||
DateTime? _lastTokenRefreshTime;
|
|
||||||
|
|
||||||
/// Subscription to auth state changes.
|
|
||||||
StreamSubscription<firebase_auth.User?>? _authStateSubscription;
|
|
||||||
|
|
||||||
/// Minimum interval between token refresh checks.
|
|
||||||
static const Duration _minRefreshCheckInterval = Duration(seconds: 2);
|
|
||||||
|
|
||||||
/// Time before token expiry to trigger a refresh.
|
|
||||||
static const Duration _refreshThreshold = Duration(minutes: 5);
|
|
||||||
|
|
||||||
/// Firebase Auth instance (to be provided by implementing class).
|
|
||||||
firebase_auth.FirebaseAuth get auth;
|
|
||||||
|
|
||||||
/// List of allowed roles for this app (to be set during initialization).
|
|
||||||
List<String> _allowedRoles = <String>[];
|
|
||||||
|
|
||||||
/// Initialize the auth state listener (call once on app startup).
|
|
||||||
void initializeAuthListener({List<String> allowedRoles = const <String>[]}) {
|
|
||||||
_allowedRoles = allowedRoles;
|
|
||||||
|
|
||||||
// Cancel any existing subscription first
|
|
||||||
_authStateSubscription?.cancel();
|
|
||||||
|
|
||||||
// Listen to Firebase auth state changes
|
|
||||||
_authStateSubscription = auth.authStateChanges().listen(
|
|
||||||
(firebase_auth.User? user) async {
|
|
||||||
if (user == null) {
|
|
||||||
handleSignOut();
|
|
||||||
} else {
|
|
||||||
await _handleSignIn(user);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (Object error) {
|
|
||||||
_emitSessionState(SessionState.error(error.toString()));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validates if user has one of the allowed roles.
|
|
||||||
/// Returns true if user role is in allowed roles, false otherwise.
|
|
||||||
Future<bool> validateUserRole(
|
|
||||||
String userId,
|
|
||||||
List<String> allowedRoles,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
final String? userRole = await fetchUserRole(userId);
|
|
||||||
return userRole != null && allowedRoles.contains(userRole);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Failed to validate user role: $e');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetches user role from Data Connect.
|
|
||||||
/// To be implemented by concrete class.
|
|
||||||
Future<String?> fetchUserRole(String userId);
|
|
||||||
|
|
||||||
/// Ensures the Firebase auth token is valid and refreshes if needed.
|
|
||||||
/// Retries up to 3 times with exponential backoff before emitting error.
|
|
||||||
Future<void> ensureSessionValid() async {
|
|
||||||
final firebase_auth.User? user = auth.currentUser;
|
|
||||||
|
|
||||||
// No user = not authenticated, skip check
|
|
||||||
if (user == null) return;
|
|
||||||
|
|
||||||
// Optimization: Skip if we just checked within the last 2 seconds
|
|
||||||
final DateTime now = DateTime.now();
|
|
||||||
if (_lastTokenRefreshTime != null) {
|
|
||||||
final Duration timeSinceLastCheck = now.difference(
|
|
||||||
_lastTokenRefreshTime!,
|
|
||||||
);
|
|
||||||
if (timeSinceLastCheck < _minRefreshCheckInterval) {
|
|
||||||
return; // Skip redundant check
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const int maxRetries = 3;
|
|
||||||
int retryCount = 0;
|
|
||||||
|
|
||||||
while (retryCount < maxRetries) {
|
|
||||||
try {
|
|
||||||
// Get token result (doesn't fetch from network unless needed)
|
|
||||||
final firebase_auth.IdTokenResult idToken = await user
|
|
||||||
.getIdTokenResult();
|
|
||||||
|
|
||||||
// Extract expiration time
|
|
||||||
final DateTime? expiryTime = idToken.expirationTime;
|
|
||||||
|
|
||||||
if (expiryTime == null) {
|
|
||||||
return; // Token info unavailable, proceed anyway
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate time until expiry
|
|
||||||
final Duration timeUntilExpiry = expiryTime.difference(now);
|
|
||||||
|
|
||||||
// If token expires within 5 minutes, refresh it
|
|
||||||
if (timeUntilExpiry <= _refreshThreshold) {
|
|
||||||
await user.getIdTokenResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update last refresh check timestamp
|
|
||||||
_lastTokenRefreshTime = now;
|
|
||||||
return; // Success, exit retry loop
|
|
||||||
} catch (e) {
|
|
||||||
retryCount++;
|
|
||||||
debugPrint(
|
|
||||||
'Token validation error (attempt $retryCount/$maxRetries): $e',
|
|
||||||
);
|
|
||||||
|
|
||||||
// If we've exhausted retries, emit error
|
|
||||||
if (retryCount >= maxRetries) {
|
|
||||||
_emitSessionState(
|
|
||||||
SessionState.error(
|
|
||||||
'Token validation failed after $maxRetries attempts: $e',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exponential backoff: 1s, 2s, 4s
|
|
||||||
final Duration backoffDuration = Duration(
|
|
||||||
seconds: 1 << (retryCount - 1), // 2^(retryCount-1)
|
|
||||||
);
|
|
||||||
debugPrint(
|
|
||||||
'Retrying token validation in ${backoffDuration.inSeconds}s',
|
|
||||||
);
|
|
||||||
await Future<void>.delayed(backoffDuration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle user sign-in event.
|
|
||||||
Future<void> _handleSignIn(firebase_auth.User user) async {
|
|
||||||
try {
|
|
||||||
_emitSessionState(SessionState.loading());
|
|
||||||
|
|
||||||
// Validate role only when allowed roles are specified.
|
|
||||||
if (_allowedRoles.isNotEmpty) {
|
|
||||||
final String? userRole = await fetchUserRole(user.uid);
|
|
||||||
|
|
||||||
if (userRole == null) {
|
|
||||||
// User has no record in the database yet. This is expected during
|
|
||||||
// the sign-up flow: Firebase Auth fires authStateChanges before the
|
|
||||||
// repository has created the PostgreSQL user record. Do NOT sign out —
|
|
||||||
// just emit unauthenticated and let the registration flow complete.
|
|
||||||
_emitSessionState(SessionState.unauthenticated());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_allowedRoles.contains(userRole)) {
|
|
||||||
// User IS in the database but has a role that is not permitted in
|
|
||||||
// this app (e.g., a STAFF-only user trying to use the Client app).
|
|
||||||
// Sign them out to force them to use the correct app.
|
|
||||||
await auth.signOut();
|
|
||||||
_emitSessionState(SessionState.unauthenticated());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get fresh token to validate session
|
|
||||||
final firebase_auth.IdTokenResult idToken = await user.getIdTokenResult();
|
|
||||||
if (idToken.expirationTime != null &&
|
|
||||||
DateTime.now().difference(idToken.expirationTime!) <
|
|
||||||
const Duration(minutes: 5)) {
|
|
||||||
// Token is expiring soon, refresh it
|
|
||||||
await user.getIdTokenResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit authenticated state
|
|
||||||
_emitSessionState(SessionState.authenticated(userId: user.uid));
|
|
||||||
} catch (e) {
|
|
||||||
_emitSessionState(SessionState.error(e.toString()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle user sign-out event.
|
|
||||||
void handleSignOut() {
|
|
||||||
_emitSessionState(SessionState.unauthenticated());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Emit session state update.
|
|
||||||
void _emitSessionState(SessionState state) {
|
|
||||||
_lastSessionState = state;
|
|
||||||
if (!_sessionStateController.isClosed) {
|
|
||||||
_sessionStateController.add(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispose session handler resources.
|
|
||||||
Future<void> disposeSessionHandler() async {
|
|
||||||
await _authStateSubscription?.cancel();
|
|
||||||
await _sessionStateController.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -13,10 +13,16 @@ class TimeSlot extends Equatable {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/// Deserialises from a JSON map inside the availability slots array.
|
/// Deserialises from a JSON map inside the availability slots array.
|
||||||
|
///
|
||||||
|
/// Supports both V2 API keys (`start`/`end`) and legacy keys
|
||||||
|
/// (`startTime`/`endTime`).
|
||||||
factory TimeSlot.fromJson(Map<String, dynamic> json) {
|
factory TimeSlot.fromJson(Map<String, dynamic> json) {
|
||||||
return TimeSlot(
|
return TimeSlot(
|
||||||
startTime: json['startTime'] as String? ?? '00:00',
|
startTime: json['start'] as String? ??
|
||||||
endTime: json['endTime'] as String? ?? '00:00',
|
json['startTime'] as String? ??
|
||||||
|
'00:00',
|
||||||
|
endTime:
|
||||||
|
json['end'] as String? ?? json['endTime'] as String? ?? '00:00',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,11 +32,11 @@ class TimeSlot extends Equatable {
|
|||||||
/// End time in `HH:MM` format.
|
/// End time in `HH:MM` format.
|
||||||
final String endTime;
|
final String endTime;
|
||||||
|
|
||||||
/// Serialises to JSON.
|
/// Serialises to JSON matching the V2 API contract.
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
'startTime': startTime,
|
'start': startTime,
|
||||||
'endTime': endTime,
|
'end': endTime,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
import '../benefits/benefit.dart';
|
import '../benefits/benefit.dart';
|
||||||
|
import '../shifts/assigned_shift.dart';
|
||||||
|
import '../shifts/open_shift.dart';
|
||||||
|
import '../shifts/today_shift.dart';
|
||||||
|
|
||||||
/// Staff dashboard data with shifts and benefits overview.
|
/// Staff dashboard data with shifts and benefits overview.
|
||||||
///
|
///
|
||||||
@@ -9,9 +12,9 @@ class StaffDashboard extends Equatable {
|
|||||||
/// Creates a [StaffDashboard] instance.
|
/// Creates a [StaffDashboard] instance.
|
||||||
const StaffDashboard({
|
const StaffDashboard({
|
||||||
required this.staffName,
|
required this.staffName,
|
||||||
this.todaysShifts = const <Map<String, dynamic>>[],
|
this.todaysShifts = const <TodayShift>[],
|
||||||
this.tomorrowsShifts = const <Map<String, dynamic>>[],
|
this.tomorrowsShifts = const <AssignedShift>[],
|
||||||
this.recommendedShifts = const <Map<String, dynamic>>[],
|
this.recommendedShifts = const <OpenShift>[],
|
||||||
this.benefits = const <Benefit>[],
|
this.benefits = const <Benefit>[],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -25,10 +28,19 @@ class StaffDashboard extends Equatable {
|
|||||||
: const <Benefit>[];
|
: const <Benefit>[];
|
||||||
|
|
||||||
return StaffDashboard(
|
return StaffDashboard(
|
||||||
staffName: json['staffName'] as String,
|
staffName: json['staffName'] as String? ?? '',
|
||||||
todaysShifts: _castShiftList(json['todaysShifts']),
|
todaysShifts: _parseList<TodayShift>(
|
||||||
tomorrowsShifts: _castShiftList(json['tomorrowsShifts']),
|
json['todaysShifts'],
|
||||||
recommendedShifts: _castShiftList(json['recommendedShifts']),
|
TodayShift.fromJson,
|
||||||
|
),
|
||||||
|
tomorrowsShifts: _parseList<AssignedShift>(
|
||||||
|
json['tomorrowsShifts'],
|
||||||
|
AssignedShift.fromJson,
|
||||||
|
),
|
||||||
|
recommendedShifts: _parseList<OpenShift>(
|
||||||
|
json['recommendedShifts'],
|
||||||
|
OpenShift.fromJson,
|
||||||
|
),
|
||||||
benefits: benefitsList,
|
benefits: benefitsList,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -37,13 +49,13 @@ class StaffDashboard extends Equatable {
|
|||||||
final String staffName;
|
final String staffName;
|
||||||
|
|
||||||
/// Shifts assigned for today.
|
/// Shifts assigned for today.
|
||||||
final List<Map<String, dynamic>> todaysShifts;
|
final List<TodayShift> todaysShifts;
|
||||||
|
|
||||||
/// Shifts assigned for tomorrow.
|
/// Shifts assigned for tomorrow.
|
||||||
final List<Map<String, dynamic>> tomorrowsShifts;
|
final List<AssignedShift> tomorrowsShifts;
|
||||||
|
|
||||||
/// Recommended open shifts.
|
/// Recommended open shifts.
|
||||||
final List<Map<String, dynamic>> recommendedShifts;
|
final List<OpenShift> recommendedShifts;
|
||||||
|
|
||||||
/// Active benefits.
|
/// Active benefits.
|
||||||
final List<Benefit> benefits;
|
final List<Benefit> benefits;
|
||||||
@@ -52,21 +64,27 @@ class StaffDashboard extends Equatable {
|
|||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
'staffName': staffName,
|
'staffName': staffName,
|
||||||
'todaysShifts': todaysShifts,
|
'todaysShifts':
|
||||||
'tomorrowsShifts': tomorrowsShifts,
|
todaysShifts.map((TodayShift s) => s.toJson()).toList(),
|
||||||
'recommendedShifts': recommendedShifts,
|
'tomorrowsShifts':
|
||||||
|
tomorrowsShifts.map((AssignedShift s) => s.toJson()).toList(),
|
||||||
|
'recommendedShifts':
|
||||||
|
recommendedShifts.map((OpenShift s) => s.toJson()).toList(),
|
||||||
'benefits': benefits.map((Benefit b) => b.toJson()).toList(),
|
'benefits': benefits.map((Benefit b) => b.toJson()).toList(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<Map<String, dynamic>> _castShiftList(dynamic raw) {
|
/// Safely parses a JSON list into a typed [List].
|
||||||
|
static List<T> _parseList<T>(
|
||||||
|
dynamic raw,
|
||||||
|
T Function(Map<String, dynamic>) fromJson,
|
||||||
|
) {
|
||||||
if (raw is List) {
|
if (raw is List) {
|
||||||
return raw
|
return raw
|
||||||
.map((dynamic e) =>
|
.map((dynamic e) => fromJson(e as Map<String, dynamic>))
|
||||||
Map<String, dynamic>.from(e as Map<dynamic, dynamic>))
|
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
return const <Map<String, dynamic>>[];
|
return <T>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class StaffPersonalInfo extends Equatable {
|
|||||||
this.skills = const <String>[],
|
this.skills = const <String>[],
|
||||||
this.email,
|
this.email,
|
||||||
this.phone,
|
this.phone,
|
||||||
|
this.photoUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Deserialises a [StaffPersonalInfo] from the V2 API JSON response.
|
/// Deserialises a [StaffPersonalInfo] from the V2 API JSON response.
|
||||||
@@ -32,6 +33,7 @@ class StaffPersonalInfo extends Equatable {
|
|||||||
skills: _parseStringList(json['skills']),
|
skills: _parseStringList(json['skills']),
|
||||||
email: json['email'] as String?,
|
email: json['email'] as String?,
|
||||||
phone: json['phone'] as String?,
|
phone: json['phone'] as String?,
|
||||||
|
photoUrl: json['photoUrl'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +67,9 @@ class StaffPersonalInfo extends Equatable {
|
|||||||
/// Contact phone number.
|
/// Contact phone number.
|
||||||
final String? phone;
|
final String? phone;
|
||||||
|
|
||||||
|
/// URL of the staff member's profile photo.
|
||||||
|
final String? photoUrl;
|
||||||
|
|
||||||
/// Serialises this [StaffPersonalInfo] to a JSON map.
|
/// Serialises this [StaffPersonalInfo] to a JSON map.
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
@@ -78,6 +83,7 @@ class StaffPersonalInfo extends Equatable {
|
|||||||
'skills': skills,
|
'skills': skills,
|
||||||
'email': email,
|
'email': email,
|
||||||
'phone': phone,
|
'phone': phone,
|
||||||
|
'photoUrl': photoUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +99,7 @@ class StaffPersonalInfo extends Equatable {
|
|||||||
skills,
|
skills,
|
||||||
email,
|
email,
|
||||||
phone,
|
phone,
|
||||||
|
photoUrl,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Parses a dynamic value into a list of strings.
|
/// Parses a dynamic value into a list of strings.
|
||||||
|
|||||||
@@ -1,31 +1,6 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
/// Lifecycle status of a staff account in V2.
|
import 'package:krow_domain/krow_domain.dart' show OnboardingStatus, StaffStatus;
|
||||||
enum StaffStatus {
|
|
||||||
/// Staff is active and eligible for work.
|
|
||||||
active,
|
|
||||||
|
|
||||||
/// Staff has been invited but has not completed onboarding.
|
|
||||||
invited,
|
|
||||||
|
|
||||||
/// Staff account has been deactivated.
|
|
||||||
inactive,
|
|
||||||
|
|
||||||
/// Staff account has been blocked by an admin.
|
|
||||||
blocked,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Onboarding progress of a staff member.
|
|
||||||
enum OnboardingStatus {
|
|
||||||
/// Onboarding has not started.
|
|
||||||
pending,
|
|
||||||
|
|
||||||
/// Onboarding is in progress.
|
|
||||||
inProgress,
|
|
||||||
|
|
||||||
/// Onboarding is complete.
|
|
||||||
completed,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Represents a worker profile in the KROW platform.
|
/// Represents a worker profile in the KROW platform.
|
||||||
///
|
///
|
||||||
@@ -63,9 +38,9 @@ class Staff extends Equatable {
|
|||||||
fullName: json['fullName'] as String,
|
fullName: json['fullName'] as String,
|
||||||
email: json['email'] as String?,
|
email: json['email'] as String?,
|
||||||
phone: json['phone'] as String?,
|
phone: json['phone'] as String?,
|
||||||
status: _parseStaffStatus(json['status'] as String?),
|
status: StaffStatus.fromJson(json['status'] as String?),
|
||||||
primaryRole: json['primaryRole'] as String?,
|
primaryRole: json['primaryRole'] as String?,
|
||||||
onboardingStatus: _parseOnboardingStatus(json['onboardingStatus'] as String?),
|
onboardingStatus: OnboardingStatus.fromJson(json['onboardingStatus'] as String?),
|
||||||
averageRating: _parseDouble(json['averageRating']),
|
averageRating: _parseDouble(json['averageRating']),
|
||||||
ratingCount: (json['ratingCount'] as num?)?.toInt() ?? 0,
|
ratingCount: (json['ratingCount'] as num?)?.toInt() ?? 0,
|
||||||
metadata: (json['metadata'] as Map<String, dynamic>?) ?? const <String, dynamic>{},
|
metadata: (json['metadata'] as Map<String, dynamic>?) ?? const <String, dynamic>{},
|
||||||
@@ -137,9 +112,9 @@ class Staff extends Equatable {
|
|||||||
'fullName': fullName,
|
'fullName': fullName,
|
||||||
'email': email,
|
'email': email,
|
||||||
'phone': phone,
|
'phone': phone,
|
||||||
'status': status.name.toUpperCase(),
|
'status': status.toJson(),
|
||||||
'primaryRole': primaryRole,
|
'primaryRole': primaryRole,
|
||||||
'onboardingStatus': onboardingStatus.name.toUpperCase(),
|
'onboardingStatus': onboardingStatus.toJson(),
|
||||||
'averageRating': averageRating,
|
'averageRating': averageRating,
|
||||||
'ratingCount': ratingCount,
|
'ratingCount': ratingCount,
|
||||||
'metadata': metadata,
|
'metadata': metadata,
|
||||||
@@ -172,36 +147,6 @@ class Staff extends Equatable {
|
|||||||
updatedAt,
|
updatedAt,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Parses a status string into a [StaffStatus].
|
|
||||||
static StaffStatus _parseStaffStatus(String? value) {
|
|
||||||
switch (value?.toUpperCase()) {
|
|
||||||
case 'ACTIVE':
|
|
||||||
return StaffStatus.active;
|
|
||||||
case 'INVITED':
|
|
||||||
return StaffStatus.invited;
|
|
||||||
case 'INACTIVE':
|
|
||||||
return StaffStatus.inactive;
|
|
||||||
case 'BLOCKED':
|
|
||||||
return StaffStatus.blocked;
|
|
||||||
default:
|
|
||||||
return StaffStatus.active;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses an onboarding status string into an [OnboardingStatus].
|
|
||||||
static OnboardingStatus _parseOnboardingStatus(String? value) {
|
|
||||||
switch (value?.toUpperCase()) {
|
|
||||||
case 'PENDING':
|
|
||||||
return OnboardingStatus.pending;
|
|
||||||
case 'IN_PROGRESS':
|
|
||||||
return OnboardingStatus.inProgress;
|
|
||||||
case 'COMPLETED':
|
|
||||||
return OnboardingStatus.completed;
|
|
||||||
default:
|
|
||||||
return OnboardingStatus.pending;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Safely parses a numeric value to double.
|
/// Safely parses a numeric value to double.
|
||||||
static double _parseDouble(Object? value) {
|
static double _parseDouble(Object? value) {
|
||||||
if (value is num) return value.toDouble();
|
if (value is num) return value.toDouble();
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ library;
|
|||||||
|
|
||||||
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';
|
||||||
|
|
||||||
import 'src/data/repositories_impl/auth_repository_impl.dart';
|
import 'src/data/repositories_impl/auth_repository_impl.dart';
|
||||||
import 'src/domain/repositories/auth_repository_interface.dart';
|
import 'src/domain/repositories/auth_repository_interface.dart';
|
||||||
import 'src/domain/usecases/sign_in_with_email_use_case.dart';
|
import 'src/domain/usecases/sign_in_with_email_use_case.dart';
|
||||||
@@ -21,14 +22,19 @@ export 'src/presentation/pages/client_sign_up_page.dart';
|
|||||||
export 'package:core_localization/core_localization.dart';
|
export 'package:core_localization/core_localization.dart';
|
||||||
|
|
||||||
/// A [Module] for the client authentication feature.
|
/// A [Module] for the client authentication feature.
|
||||||
|
///
|
||||||
|
/// Imports [CoreModule] for [BaseApiService] and registers repositories,
|
||||||
|
/// use cases, and BLoCs for the client authentication flow.
|
||||||
class ClientAuthenticationModule extends Module {
|
class ClientAuthenticationModule extends Module {
|
||||||
@override
|
@override
|
||||||
List<Module> get imports => <Module>[DataConnectModule()];
|
List<Module> get imports => <Module>[CoreModule()];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
// Repositories
|
// Repositories
|
||||||
i.addLazySingleton<AuthRepositoryInterface>(AuthRepositoryImpl.new);
|
i.addLazySingleton<AuthRepositoryInterface>(
|
||||||
|
() => AuthRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||||
|
);
|
||||||
|
|
||||||
// UseCases
|
// UseCases
|
||||||
i.addLazySingleton(
|
i.addLazySingleton(
|
||||||
|
|||||||
@@ -1,68 +1,96 @@
|
|||||||
import 'dart:developer' as developer;
|
import 'dart:developer' as developer;
|
||||||
|
|
||||||
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
||||||
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'
|
import 'package:krow_domain/krow_domain.dart'
|
||||||
show
|
show
|
||||||
|
AccountExistsException,
|
||||||
|
ApiResponse,
|
||||||
|
AppException,
|
||||||
|
BaseApiService,
|
||||||
|
ClientSession,
|
||||||
InvalidCredentialsException,
|
InvalidCredentialsException,
|
||||||
|
NetworkException,
|
||||||
|
PasswordMismatchException,
|
||||||
SignInFailedException,
|
SignInFailedException,
|
||||||
SignUpFailedException,
|
SignUpFailedException,
|
||||||
WeakPasswordException,
|
|
||||||
AccountExistsException,
|
|
||||||
UserNotFoundException,
|
|
||||||
UnauthorizedAppException,
|
UnauthorizedAppException,
|
||||||
PasswordMismatchException,
|
User,
|
||||||
NetworkException;
|
UserStatus,
|
||||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
WeakPasswordException;
|
||||||
|
|
||||||
import '../../domain/repositories/auth_repository_interface.dart';
|
import 'package:client_authentication/src/domain/repositories/auth_repository_interface.dart';
|
||||||
|
|
||||||
/// Production-ready implementation of the [AuthRepositoryInterface] for the client app.
|
/// Production implementation of the [AuthRepositoryInterface] for the client app.
|
||||||
///
|
///
|
||||||
/// This implementation integrates with Firebase Authentication for user
|
/// Uses Firebase Auth client-side for sign-in (to maintain local auth state for
|
||||||
/// identity management and KROW's Data Connect SDK for storing user profile data.
|
/// the [AuthInterceptor]), then calls V2 `GET /auth/session` to retrieve
|
||||||
|
/// business context. Sign-up provisioning (tenant, business, memberships) is
|
||||||
|
/// handled entirely server-side by the V2 API.
|
||||||
class AuthRepositoryImpl implements AuthRepositoryInterface {
|
class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||||
/// Creates an [AuthRepositoryImpl] with the real dependencies.
|
/// Creates an [AuthRepositoryImpl] with the given [BaseApiService].
|
||||||
AuthRepositoryImpl({dc.DataConnectService? service})
|
AuthRepositoryImpl({required BaseApiService apiService})
|
||||||
: _service = service ?? dc.DataConnectService.instance;
|
: _apiService = apiService;
|
||||||
|
|
||||||
final dc.DataConnectService _service;
|
/// The V2 API service for backend calls.
|
||||||
|
final BaseApiService _apiService;
|
||||||
|
|
||||||
|
/// Firebase Auth instance for client-side sign-in/sign-up.
|
||||||
|
firebase.FirebaseAuth get _auth => firebase.FirebaseAuth.instance;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<domain.User> signInWithEmail({
|
Future<User> signInWithEmail({
|
||||||
required String email,
|
required String email,
|
||||||
required String password,
|
required String password,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final firebase.UserCredential credential = await _service.auth
|
// Step 1: Call V2 sign-in endpoint — server handles Firebase Auth
|
||||||
.signInWithEmailAndPassword(email: email, password: password);
|
// via Identity Toolkit and returns a full auth envelope.
|
||||||
|
final ApiResponse response = await _apiService.post(
|
||||||
|
V2ApiEndpoints.clientSignIn,
|
||||||
|
data: <String, dynamic>{
|
||||||
|
'email': email,
|
||||||
|
'password': password,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final Map<String, dynamic> body =
|
||||||
|
response.data as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// Check for V2 error responses.
|
||||||
|
if (response.code != '200' && response.code != '201') {
|
||||||
|
final String errorCode = body['code']?.toString() ?? response.code;
|
||||||
|
if (errorCode == 'INVALID_CREDENTIALS' ||
|
||||||
|
response.message.contains('INVALID_LOGIN_CREDENTIALS')) {
|
||||||
|
throw InvalidCredentialsException(
|
||||||
|
technicalMessage: response.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw SignInFailedException(
|
||||||
|
technicalMessage: '$errorCode: ${response.message}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Sign in locally so AuthInterceptor can attach Bearer tokens
|
||||||
|
// to subsequent requests. The V2 API already validated credentials, so
|
||||||
|
// email/password sign-in establishes the local Firebase Auth state.
|
||||||
|
final firebase.UserCredential credential =
|
||||||
|
await _auth.signInWithEmailAndPassword(
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
|
||||||
final firebase.User? firebaseUser = credential.user;
|
final firebase.User? firebaseUser = credential.user;
|
||||||
if (firebaseUser == null) {
|
if (firebaseUser == null) {
|
||||||
throw const SignInFailedException(
|
throw const SignInFailedException(
|
||||||
technicalMessage: 'No Firebase user received after sign-in',
|
technicalMessage: 'Local Firebase sign-in failed after V2 sign-in',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _getUserProfile(
|
// Step 3: Populate session store from the V2 auth envelope directly
|
||||||
firebaseUserId: firebaseUser.uid,
|
// (no need for a separate GET /auth/session call).
|
||||||
fallbackEmail: firebaseUser.email ?? email,
|
return _populateStoreFromAuthEnvelope(body, firebaseUser, email);
|
||||||
requireBusinessRole: true,
|
} on AppException {
|
||||||
);
|
|
||||||
} on firebase.FirebaseAuthException catch (e) {
|
|
||||||
if (e.code == 'invalid-credential' || e.code == 'wrong-password') {
|
|
||||||
throw InvalidCredentialsException(
|
|
||||||
technicalMessage: 'Firebase error code: ${e.code}',
|
|
||||||
);
|
|
||||||
} else if (e.code == 'network-request-failed') {
|
|
||||||
throw NetworkException(technicalMessage: 'Firebase: ${e.message}');
|
|
||||||
} else {
|
|
||||||
throw SignInFailedException(
|
|
||||||
technicalMessage: 'Firebase auth error: ${e.message}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} on domain.AppException {
|
|
||||||
rethrow;
|
rethrow;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw SignInFailedException(technicalMessage: 'Unexpected error: $e');
|
throw SignInFailedException(technicalMessage: 'Unexpected error: $e');
|
||||||
@@ -70,50 +98,57 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<domain.User> signUpWithEmail({
|
Future<User> signUpWithEmail({
|
||||||
required String companyName,
|
required String companyName,
|
||||||
required String email,
|
required String email,
|
||||||
required String password,
|
required String password,
|
||||||
}) async {
|
}) async {
|
||||||
firebase.User? firebaseUser;
|
|
||||||
String? createdBusinessId;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Try to create Firebase Auth user
|
// Step 1: Call V2 sign-up endpoint which handles everything server-side:
|
||||||
final firebase.UserCredential credential = await _service.auth
|
// - Creates Firebase Auth account via Identity Toolkit
|
||||||
.createUserWithEmailAndPassword(email: email, password: password);
|
// - Creates user, tenant, business, memberships in one transaction
|
||||||
|
// - Returns full auth envelope with session tokens
|
||||||
|
final ApiResponse response = await _apiService.post(
|
||||||
|
V2ApiEndpoints.clientSignUp,
|
||||||
|
data: <String, dynamic>{
|
||||||
|
'companyName': companyName,
|
||||||
|
'email': email,
|
||||||
|
'password': password,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
firebaseUser = credential.user;
|
// Check for V2 error responses.
|
||||||
|
final Map<String, dynamic> body = response.data as Map<String, dynamic>;
|
||||||
|
if (response.code != '201' && response.code != '200') {
|
||||||
|
final String errorCode = body['code']?.toString() ?? response.code;
|
||||||
|
_throwSignUpError(errorCode, response.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Sign in locally to Firebase Auth so AuthInterceptor works
|
||||||
|
// for subsequent requests. The V2 API already created the Firebase
|
||||||
|
// account, so this should succeed.
|
||||||
|
final firebase.UserCredential credential =
|
||||||
|
await _auth.signInWithEmailAndPassword(
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
|
||||||
|
final firebase.User? firebaseUser = credential.user;
|
||||||
if (firebaseUser == null) {
|
if (firebaseUser == null) {
|
||||||
throw const SignUpFailedException(
|
throw const SignUpFailedException(
|
||||||
technicalMessage: 'Firebase user could not be created',
|
technicalMessage: 'Local Firebase sign-in failed after V2 sign-up',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force-refresh the ID token so the Data Connect SDK has a valid bearer
|
// Step 3: Populate store from the sign-up response envelope.
|
||||||
// token before we fire any mutations. Without this, there is a race
|
return _populateStoreFromAuthEnvelope(body, firebaseUser, email);
|
||||||
// condition where the gRPC layer sends the request unauthenticated
|
|
||||||
// immediately after account creation (gRPC code 16 UNAUTHENTICATED).
|
|
||||||
await firebaseUser.getIdToken(true);
|
|
||||||
|
|
||||||
// New user created successfully, proceed to create PostgreSQL entities
|
|
||||||
return await _createBusinessAndUser(
|
|
||||||
firebaseUser: firebaseUser,
|
|
||||||
companyName: companyName,
|
|
||||||
email: email,
|
|
||||||
onBusinessCreated: (String businessId) =>
|
|
||||||
createdBusinessId = businessId,
|
|
||||||
);
|
|
||||||
} on firebase.FirebaseAuthException catch (e) {
|
} on firebase.FirebaseAuthException catch (e) {
|
||||||
if (e.code == 'weak-password') {
|
if (e.code == 'email-already-in-use') {
|
||||||
throw WeakPasswordException(technicalMessage: 'Firebase: ${e.message}');
|
throw AccountExistsException(
|
||||||
} else if (e.code == 'email-already-in-use') {
|
technicalMessage: 'Firebase: ${e.message}',
|
||||||
// Email exists in Firebase Auth - try to sign in and complete registration
|
|
||||||
return await _handleExistingFirebaseAccount(
|
|
||||||
email: email,
|
|
||||||
password: password,
|
|
||||||
companyName: companyName,
|
|
||||||
);
|
);
|
||||||
|
} else if (e.code == 'weak-password') {
|
||||||
|
throw WeakPasswordException(technicalMessage: 'Firebase: ${e.message}');
|
||||||
} else if (e.code == 'network-request-failed') {
|
} else if (e.code == 'network-request-failed') {
|
||||||
throw NetworkException(technicalMessage: 'Firebase: ${e.message}');
|
throw NetworkException(technicalMessage: 'Firebase: ${e.message}');
|
||||||
} else {
|
} else {
|
||||||
@@ -121,304 +156,103 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
|||||||
technicalMessage: 'Firebase auth error: ${e.message}',
|
technicalMessage: 'Firebase auth error: ${e.message}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} on domain.AppException {
|
} on AppException {
|
||||||
// Rollback for our known exceptions
|
|
||||||
await _rollbackSignUp(
|
|
||||||
firebaseUser: firebaseUser,
|
|
||||||
businessId: createdBusinessId,
|
|
||||||
);
|
|
||||||
rethrow;
|
rethrow;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Rollback: Clean up any partially created resources
|
|
||||||
await _rollbackSignUp(
|
|
||||||
firebaseUser: firebaseUser,
|
|
||||||
businessId: createdBusinessId,
|
|
||||||
);
|
|
||||||
throw SignUpFailedException(technicalMessage: 'Unexpected error: $e');
|
throw SignUpFailedException(technicalMessage: 'Unexpected error: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the case where email already exists in Firebase Auth.
|
|
||||||
///
|
|
||||||
/// This can happen when:
|
|
||||||
/// 1. User signed up with Google in another app sharing the same Firebase project
|
|
||||||
/// 2. User already has a KROW account
|
|
||||||
///
|
|
||||||
/// The flow:
|
|
||||||
/// 1. Try to sign in with provided password
|
|
||||||
/// 2. If sign-in succeeds, check if BUSINESS user exists in PostgreSQL
|
|
||||||
/// 3. If not, create Business + User (user is new to KROW)
|
|
||||||
/// 4. If yes, they already have a KROW account
|
|
||||||
Future<domain.User> _handleExistingFirebaseAccount({
|
|
||||||
required String email,
|
|
||||||
required String password,
|
|
||||||
required String companyName,
|
|
||||||
}) async {
|
|
||||||
developer.log(
|
|
||||||
'Email exists in Firebase, attempting sign-in: $email',
|
|
||||||
name: 'AuthRepository',
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to sign in with the provided password
|
|
||||||
final firebase.UserCredential credential = await _service.auth
|
|
||||||
.signInWithEmailAndPassword(email: email, password: password);
|
|
||||||
|
|
||||||
final firebase.User? firebaseUser = credential.user;
|
|
||||||
if (firebaseUser == null) {
|
|
||||||
throw const SignUpFailedException(
|
|
||||||
technicalMessage: 'Sign-in succeeded but no user returned',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force-refresh the ID token so the Data Connect SDK receives a valid
|
|
||||||
// bearer token before any subsequent Data Connect queries run.
|
|
||||||
await firebaseUser.getIdToken(true);
|
|
||||||
|
|
||||||
// Sign-in succeeded! Check if user already has a BUSINESS account in PostgreSQL
|
|
||||||
final bool hasBusinessAccount = await _checkBusinessUserExists(
|
|
||||||
firebaseUser.uid,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasBusinessAccount) {
|
|
||||||
// User already has a KROW Client account
|
|
||||||
developer.log(
|
|
||||||
'User already has BUSINESS account: ${firebaseUser.uid}',
|
|
||||||
name: 'AuthRepository',
|
|
||||||
);
|
|
||||||
throw AccountExistsException(
|
|
||||||
technicalMessage:
|
|
||||||
'User ${firebaseUser.uid} already has BUSINESS role',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// User exists in Firebase but not in KROW PostgreSQL - create the entities
|
|
||||||
developer.log(
|
|
||||||
'Creating BUSINESS account for existing Firebase user: ${firebaseUser.uid}',
|
|
||||||
name: 'AuthRepository',
|
|
||||||
);
|
|
||||||
return await _createBusinessAndUser(
|
|
||||||
firebaseUser: firebaseUser,
|
|
||||||
companyName: companyName,
|
|
||||||
email: email,
|
|
||||||
onBusinessCreated:
|
|
||||||
(_) {}, // No rollback needed for existing Firebase user
|
|
||||||
);
|
|
||||||
} on firebase.FirebaseAuthException catch (e) {
|
|
||||||
// Sign-in failed - check why
|
|
||||||
developer.log(
|
|
||||||
'Sign-in failed with code: ${e.code}',
|
|
||||||
name: 'AuthRepository',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (e.code == 'wrong-password' || e.code == 'invalid-credential') {
|
|
||||||
// Password doesn't match - check what providers are available
|
|
||||||
return await _handlePasswordMismatch(email);
|
|
||||||
} else {
|
|
||||||
throw SignUpFailedException(
|
|
||||||
technicalMessage: 'Firebase sign-in error: ${e.message}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} on domain.AppException {
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handles the case where the password doesn't match the existing account.
|
|
||||||
///
|
|
||||||
/// Note: fetchSignInMethodsForEmail was deprecated by Firebase for security
|
|
||||||
/// reasons (email enumeration). We show a combined message that covers both
|
|
||||||
/// cases: wrong password OR account uses different sign-in method (Google).
|
|
||||||
Future<Never> _handlePasswordMismatch(String email) async {
|
|
||||||
// We can't distinguish between "wrong password" and "no password provider"
|
|
||||||
// due to Firebase deprecating fetchSignInMethodsForEmail.
|
|
||||||
// The PasswordMismatchException message covers both scenarios.
|
|
||||||
developer.log(
|
|
||||||
'Password mismatch or different provider for: $email',
|
|
||||||
name: 'AuthRepository',
|
|
||||||
);
|
|
||||||
throw PasswordMismatchException(
|
|
||||||
technicalMessage:
|
|
||||||
'Email $email: password mismatch or different auth provider',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if a user with BUSINESS role exists in PostgreSQL.
|
|
||||||
|
|
||||||
Future<bool> _checkBusinessUserExists(String firebaseUserId) async {
|
|
||||||
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response =
|
|
||||||
await _service.run(
|
|
||||||
() => _service.connector.getUserById(id: firebaseUserId).execute(),
|
|
||||||
);
|
|
||||||
final dc.GetUserByIdUser? user = response.data.user;
|
|
||||||
return user != null &&
|
|
||||||
(user.userRole == 'BUSINESS' || user.userRole == 'BOTH');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates Business and User entities in PostgreSQL for a Firebase user.
|
|
||||||
Future<domain.User> _createBusinessAndUser({
|
|
||||||
required firebase.User firebaseUser,
|
|
||||||
required String companyName,
|
|
||||||
required String email,
|
|
||||||
required void Function(String businessId) onBusinessCreated,
|
|
||||||
}) async {
|
|
||||||
// Create Business entity in PostgreSQL
|
|
||||||
|
|
||||||
final OperationResult<dc.CreateBusinessData, dc.CreateBusinessVariables>
|
|
||||||
createBusinessResponse = await _service.run(
|
|
||||||
() => _service.connector
|
|
||||||
.createBusiness(
|
|
||||||
businessName: companyName,
|
|
||||||
userId: firebaseUser.uid,
|
|
||||||
rateGroup: dc.BusinessRateGroup.STANDARD,
|
|
||||||
status: dc.BusinessStatus.PENDING,
|
|
||||||
)
|
|
||||||
.execute(),
|
|
||||||
);
|
|
||||||
|
|
||||||
final dc.CreateBusinessBusinessInsert businessData =
|
|
||||||
createBusinessResponse.data.business_insert;
|
|
||||||
onBusinessCreated(businessData.id);
|
|
||||||
|
|
||||||
// Check if User entity already exists in PostgreSQL
|
|
||||||
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> userResult =
|
|
||||||
await _service.run(
|
|
||||||
() => _service.connector.getUserById(id: firebaseUser.uid).execute(),
|
|
||||||
);
|
|
||||||
final dc.GetUserByIdUser? existingUser = userResult.data.user;
|
|
||||||
|
|
||||||
if (existingUser != null) {
|
|
||||||
// User exists (likely in another app like STAFF). Update role to BOTH.
|
|
||||||
await _service.run(
|
|
||||||
() => _service.connector
|
|
||||||
.updateUser(id: firebaseUser.uid)
|
|
||||||
.userRole('BOTH')
|
|
||||||
.execute(),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Create new User entity in PostgreSQL
|
|
||||||
await _service.run(
|
|
||||||
() => _service.connector
|
|
||||||
.createUser(id: firebaseUser.uid, role: dc.UserBaseRole.USER)
|
|
||||||
.email(email)
|
|
||||||
.userRole('BUSINESS')
|
|
||||||
.execute(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return _getUserProfile(
|
|
||||||
firebaseUserId: firebaseUser.uid,
|
|
||||||
fallbackEmail: firebaseUser.email ?? email,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rollback helper to clean up partially created resources during sign-up.
|
|
||||||
Future<void> _rollbackSignUp({
|
|
||||||
firebase.User? firebaseUser,
|
|
||||||
String? businessId,
|
|
||||||
}) async {
|
|
||||||
// Delete business first (if created)
|
|
||||||
if (businessId != null) {
|
|
||||||
try {
|
|
||||||
await _service.connector.deleteBusiness(id: businessId).execute();
|
|
||||||
} catch (_) {
|
|
||||||
// Log but don't throw - we're already in error recovery
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Delete Firebase user (if created)
|
|
||||||
if (firebaseUser != null) {
|
|
||||||
try {
|
|
||||||
await firebaseUser.delete();
|
|
||||||
} catch (_) {
|
|
||||||
// Log but don't throw - we're already in error recovery
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> signOut() async {
|
Future<User> signInWithSocial({required String provider}) {
|
||||||
try {
|
|
||||||
await _service.signOut();
|
|
||||||
} catch (e) {
|
|
||||||
throw Exception('Error signing out: ${e.toString()}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<domain.User> signInWithSocial({required String provider}) {
|
|
||||||
throw UnimplementedError(
|
throw UnimplementedError(
|
||||||
'Social authentication with $provider is not yet implemented.',
|
'Social authentication with $provider is not yet implemented.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<domain.User> _getUserProfile({
|
@override
|
||||||
required String firebaseUserId,
|
Future<void> signOut() async {
|
||||||
required String? fallbackEmail,
|
try {
|
||||||
bool requireBusinessRole = false,
|
// Step 1: Call V2 sign-out endpoint for server-side token revocation.
|
||||||
}) async {
|
await _apiService.post(V2ApiEndpoints.clientSignOut);
|
||||||
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response =
|
} catch (e) {
|
||||||
await _service.run(
|
developer.log(
|
||||||
() => _service.connector.getUserById(id: firebaseUserId).execute(),
|
'V2 sign-out request failed: $e',
|
||||||
);
|
name: 'AuthRepository',
|
||||||
final dc.GetUserByIdUser? user = response.data.user;
|
|
||||||
if (user == null) {
|
|
||||||
throw UserNotFoundException(
|
|
||||||
technicalMessage:
|
|
||||||
'Firebase UID $firebaseUserId not found in users table',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (requireBusinessRole &&
|
|
||||||
user.userRole != 'BUSINESS' &&
|
|
||||||
user.userRole != 'BOTH') {
|
|
||||||
await _service.signOut();
|
|
||||||
throw UnauthorizedAppException(
|
|
||||||
technicalMessage:
|
|
||||||
'User role is ${user.userRole}, expected BUSINESS or BOTH',
|
|
||||||
);
|
);
|
||||||
|
// Continue with local sign-out even if server-side fails.
|
||||||
}
|
}
|
||||||
|
|
||||||
final String? email = user.email ?? fallbackEmail;
|
try {
|
||||||
if (email == null || email.isEmpty) {
|
// Step 2: Sign out from local Firebase Auth.
|
||||||
throw UserNotFoundException(
|
await _auth.signOut();
|
||||||
technicalMessage: 'User email missing for UID $firebaseUserId',
|
} catch (e) {
|
||||||
);
|
throw Exception('Error signing out locally: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
final domain.User domainUser = domain.User(
|
// Step 3: Clear the client session store.
|
||||||
id: user.id,
|
ClientSessionStore.instance.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Private helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Populates the session store from a V2 auth envelope response and
|
||||||
|
/// returns a domain [User].
|
||||||
|
User _populateStoreFromAuthEnvelope(
|
||||||
|
Map<String, dynamic> envelope,
|
||||||
|
firebase.User firebaseUser,
|
||||||
|
String fallbackEmail,
|
||||||
|
) {
|
||||||
|
final Map<String, dynamic>? userJson =
|
||||||
|
envelope['user'] as Map<String, dynamic>?;
|
||||||
|
final Map<String, dynamic>? businessJson =
|
||||||
|
envelope['business'] as Map<String, dynamic>?;
|
||||||
|
|
||||||
|
if (businessJson != null) {
|
||||||
|
final ClientSession clientSession = ClientSession.fromJson(envelope);
|
||||||
|
ClientSessionStore.instance.setSession(clientSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String userId =
|
||||||
|
userJson?['id'] as String? ?? firebaseUser.uid;
|
||||||
|
final String? email = userJson?['email'] as String? ?? fallbackEmail;
|
||||||
|
|
||||||
|
return User(
|
||||||
|
id: userId,
|
||||||
email: email,
|
email: email,
|
||||||
role: user.role.stringValue,
|
displayName: userJson?['displayName'] as String?,
|
||||||
|
phone: userJson?['phone'] as String?,
|
||||||
|
status: _parseUserStatus(userJson?['status'] as String?),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final QueryResult<
|
/// Maps a V2 error code to the appropriate domain exception for sign-up.
|
||||||
dc.GetBusinessesByUserIdData,
|
Never _throwSignUpError(String errorCode, String message) {
|
||||||
dc.GetBusinessesByUserIdVariables
|
switch (errorCode) {
|
||||||
>
|
case 'AUTH_PROVIDER_ERROR' when message.contains('EMAIL_EXISTS'):
|
||||||
businessResponse = await _service.run(
|
throw AccountExistsException(technicalMessage: message);
|
||||||
() => _service.connector
|
case 'AUTH_PROVIDER_ERROR' when message.contains('WEAK_PASSWORD'):
|
||||||
.getBusinessesByUserId(userId: firebaseUserId)
|
throw WeakPasswordException(technicalMessage: message);
|
||||||
.execute(),
|
case 'FORBIDDEN':
|
||||||
);
|
throw PasswordMismatchException(technicalMessage: message);
|
||||||
final dc.GetBusinessesByUserIdBusinesses? business =
|
default:
|
||||||
businessResponse.data.businesses.isNotEmpty
|
throw SignUpFailedException(technicalMessage: '$errorCode: $message');
|
||||||
? businessResponse.data.businesses.first
|
}
|
||||||
: null;
|
}
|
||||||
|
|
||||||
dc.ClientSessionStore.instance.setSession(
|
/// Parses a status string from the API into a [UserStatus].
|
||||||
dc.ClientSession(
|
static UserStatus _parseUserStatus(String? value) {
|
||||||
business: business == null
|
switch (value?.toUpperCase()) {
|
||||||
? null
|
case 'ACTIVE':
|
||||||
: dc.ClientBusinessSession(
|
return UserStatus.active;
|
||||||
id: business.id,
|
case 'INVITED':
|
||||||
businessName: business.businessName,
|
return UserStatus.invited;
|
||||||
email: business.email,
|
case 'DISABLED':
|
||||||
city: business.city,
|
return UserStatus.disabled;
|
||||||
contactName: business.contactName,
|
default:
|
||||||
companyLogoUrl: business.companyLogoUrl,
|
return UserStatus.active;
|
||||||
),
|
}
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return domainUser;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,17 +14,13 @@ dependencies:
|
|||||||
flutter_bloc: ^8.1.0
|
flutter_bloc: ^8.1.0
|
||||||
flutter_modular: ^6.3.0
|
flutter_modular: ^6.3.0
|
||||||
equatable: ^2.0.5
|
equatable: ^2.0.5
|
||||||
firebase_core: ^4.2.1
|
firebase_auth: ^6.1.2
|
||||||
firebase_auth: ^6.1.2 # Updated for compatibility
|
|
||||||
firebase_data_connect: ^0.2.2+1
|
|
||||||
|
|
||||||
# Architecture Packages
|
# Architecture Packages
|
||||||
design_system:
|
design_system:
|
||||||
path: ../../../design_system
|
path: ../../../design_system
|
||||||
core_localization:
|
core_localization:
|
||||||
path: ../../../core_localization
|
path: ../../../core_localization
|
||||||
krow_data_connect:
|
|
||||||
path: ../../../data_connect
|
|
||||||
krow_domain:
|
krow_domain:
|
||||||
path: ../../../domain
|
path: ../../../domain
|
||||||
krow_core:
|
krow_core:
|
||||||
@@ -35,7 +31,6 @@ dev_dependencies:
|
|||||||
sdk: flutter
|
sdk: flutter
|
||||||
bloc_test: ^9.1.0
|
bloc_test: ^9.1.0
|
||||||
mocktail: ^1.0.0
|
mocktail: ^1.0.0
|
||||||
build_runner: ^2.4.15
|
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|||||||
@@ -1,30 +1,37 @@
|
|||||||
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_domain/krow_domain.dart';
|
||||||
|
|
||||||
import 'data/repositories_impl/billing_repository_impl.dart';
|
import 'package:billing/src/data/repositories_impl/billing_repository_impl.dart';
|
||||||
import 'domain/repositories/billing_repository.dart';
|
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||||
import 'domain/usecases/get_bank_accounts.dart';
|
import 'package:billing/src/domain/usecases/approve_invoice.dart';
|
||||||
import 'domain/usecases/get_current_bill_amount.dart';
|
import 'package:billing/src/domain/usecases/dispute_invoice.dart';
|
||||||
import 'domain/usecases/get_invoice_history.dart';
|
import 'package:billing/src/domain/usecases/get_bank_accounts.dart';
|
||||||
import 'domain/usecases/get_pending_invoices.dart';
|
import 'package:billing/src/domain/usecases/get_current_bill_amount.dart';
|
||||||
import 'domain/usecases/get_savings_amount.dart';
|
import 'package:billing/src/domain/usecases/get_invoice_history.dart';
|
||||||
import 'domain/usecases/get_spending_breakdown.dart';
|
import 'package:billing/src/domain/usecases/get_pending_invoices.dart';
|
||||||
import 'domain/usecases/approve_invoice.dart';
|
import 'package:billing/src/domain/usecases/get_savings_amount.dart';
|
||||||
import 'domain/usecases/dispute_invoice.dart';
|
import 'package:billing/src/domain/usecases/get_spending_breakdown.dart';
|
||||||
import 'presentation/blocs/billing_bloc.dart';
|
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||||
import 'presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart';
|
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart';
|
||||||
import 'presentation/models/billing_invoice_model.dart';
|
import 'package:billing/src/presentation/pages/billing_page.dart';
|
||||||
import 'presentation/pages/billing_page.dart';
|
import 'package:billing/src/presentation/pages/completion_review_page.dart';
|
||||||
import 'presentation/pages/completion_review_page.dart';
|
import 'package:billing/src/presentation/pages/invoice_ready_page.dart';
|
||||||
import 'presentation/pages/invoice_ready_page.dart';
|
import 'package:billing/src/presentation/pages/pending_invoices_page.dart';
|
||||||
import 'presentation/pages/pending_invoices_page.dart';
|
|
||||||
|
|
||||||
/// Modular module for the billing feature.
|
/// Modular module for the billing feature.
|
||||||
|
///
|
||||||
|
/// Uses [BaseApiService] for all backend access via V2 REST API.
|
||||||
class BillingModule extends Module {
|
class BillingModule extends Module {
|
||||||
|
@override
|
||||||
|
List<Module> get imports => <Module>[CoreModule()];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
// Repositories
|
// Repositories
|
||||||
i.addLazySingleton<BillingRepository>(BillingRepositoryImpl.new);
|
i.addLazySingleton<BillingRepository>(
|
||||||
|
() => BillingRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||||
|
);
|
||||||
|
|
||||||
// Use Cases
|
// Use Cases
|
||||||
i.addLazySingleton(GetBankAccountsUseCase.new);
|
i.addLazySingleton(GetBankAccountsUseCase.new);
|
||||||
@@ -32,7 +39,7 @@ class BillingModule extends Module {
|
|||||||
i.addLazySingleton(GetSavingsAmountUseCase.new);
|
i.addLazySingleton(GetSavingsAmountUseCase.new);
|
||||||
i.addLazySingleton(GetPendingInvoicesUseCase.new);
|
i.addLazySingleton(GetPendingInvoicesUseCase.new);
|
||||||
i.addLazySingleton(GetInvoiceHistoryUseCase.new);
|
i.addLazySingleton(GetInvoiceHistoryUseCase.new);
|
||||||
i.addLazySingleton(GetSpendingBreakdownUseCase.new);
|
i.addLazySingleton(GetSpendBreakdownUseCase.new);
|
||||||
i.addLazySingleton(ApproveInvoiceUseCase.new);
|
i.addLazySingleton(ApproveInvoiceUseCase.new);
|
||||||
i.addLazySingleton(DisputeInvoiceUseCase.new);
|
i.addLazySingleton(DisputeInvoiceUseCase.new);
|
||||||
|
|
||||||
@@ -44,7 +51,7 @@ class BillingModule extends Module {
|
|||||||
getSavingsAmount: i.get<GetSavingsAmountUseCase>(),
|
getSavingsAmount: i.get<GetSavingsAmountUseCase>(),
|
||||||
getPendingInvoices: i.get<GetPendingInvoicesUseCase>(),
|
getPendingInvoices: i.get<GetPendingInvoicesUseCase>(),
|
||||||
getInvoiceHistory: i.get<GetInvoiceHistoryUseCase>(),
|
getInvoiceHistory: i.get<GetInvoiceHistoryUseCase>(),
|
||||||
getSpendingBreakdown: i.get<GetSpendingBreakdownUseCase>(),
|
getSpendBreakdown: i.get<GetSpendBreakdownUseCase>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
i.add<ShiftCompletionReviewBloc>(
|
i.add<ShiftCompletionReviewBloc>(
|
||||||
@@ -62,16 +69,20 @@ class BillingModule extends Module {
|
|||||||
child: (_) => const BillingPage(),
|
child: (_) => const BillingPage(),
|
||||||
);
|
);
|
||||||
r.child(
|
r.child(
|
||||||
ClientPaths.childRoute(ClientPaths.billing, ClientPaths.completionReview),
|
ClientPaths.childRoute(
|
||||||
child: (_) =>
|
ClientPaths.billing, ClientPaths.completionReview),
|
||||||
ShiftCompletionReviewPage(invoice: r.args.data as BillingInvoice?),
|
child: (_) => ShiftCompletionReviewPage(
|
||||||
|
invoice:
|
||||||
|
r.args.data is Invoice ? r.args.data as Invoice : null,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
r.child(
|
r.child(
|
||||||
ClientPaths.childRoute(ClientPaths.billing, ClientPaths.invoiceReady),
|
ClientPaths.childRoute(ClientPaths.billing, ClientPaths.invoiceReady),
|
||||||
child: (_) => const InvoiceReadyPage(),
|
child: (_) => const InvoiceReadyPage(),
|
||||||
);
|
);
|
||||||
r.child(
|
r.child(
|
||||||
ClientPaths.childRoute(ClientPaths.billing, ClientPaths.awaitingApproval),
|
ClientPaths.childRoute(
|
||||||
|
ClientPaths.billing, ClientPaths.awaitingApproval),
|
||||||
child: (_) => const PendingInvoicesPage(),
|
child: (_) => const PendingInvoicesPage(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +1,103 @@
|
|||||||
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../../domain/repositories/billing_repository.dart';
|
|
||||||
|
|
||||||
/// Implementation of [BillingRepository] that delegates to [dc.BillingConnectorRepository].
|
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||||
|
|
||||||
|
/// Implementation of [BillingRepository] using the V2 REST API.
|
||||||
///
|
///
|
||||||
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
|
/// All backend calls go through [BaseApiService] with [V2ApiEndpoints].
|
||||||
/// connector repository from the data_connect package.
|
|
||||||
class BillingRepositoryImpl implements BillingRepository {
|
class BillingRepositoryImpl implements BillingRepository {
|
||||||
|
/// Creates a [BillingRepositoryImpl].
|
||||||
|
BillingRepositoryImpl({required BaseApiService apiService})
|
||||||
|
: _apiService = apiService;
|
||||||
|
|
||||||
BillingRepositoryImpl({
|
/// The API service used for all HTTP requests.
|
||||||
dc.BillingConnectorRepository? connectorRepository,
|
final BaseApiService _apiService;
|
||||||
dc.DataConnectService? service,
|
|
||||||
}) : _connectorRepository = connectorRepository ??
|
|
||||||
dc.DataConnectService.instance.getBillingRepository(),
|
|
||||||
_service = service ?? dc.DataConnectService.instance;
|
|
||||||
final dc.BillingConnectorRepository _connectorRepository;
|
|
||||||
final dc.DataConnectService _service;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<BusinessBankAccount>> getBankAccounts() async {
|
Future<List<BillingAccount>> getBankAccounts() async {
|
||||||
final String businessId = await _service.getBusinessId();
|
final ApiResponse response =
|
||||||
return _connectorRepository.getBankAccounts(businessId: businessId);
|
await _apiService.get(V2ApiEndpoints.clientBillingAccounts);
|
||||||
}
|
final List<dynamic> items =
|
||||||
|
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
|
||||||
@override
|
return items
|
||||||
Future<double> getCurrentBillAmount() async {
|
.map((dynamic json) =>
|
||||||
final String businessId = await _service.getBusinessId();
|
BillingAccount.fromJson(json as Map<String, dynamic>))
|
||||||
return _connectorRepository.getCurrentBillAmount(businessId: businessId);
|
.toList();
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<Invoice>> getInvoiceHistory() async {
|
|
||||||
final String businessId = await _service.getBusinessId();
|
|
||||||
return _connectorRepository.getInvoiceHistory(businessId: businessId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Invoice>> getPendingInvoices() async {
|
Future<List<Invoice>> getPendingInvoices() async {
|
||||||
final String businessId = await _service.getBusinessId();
|
final ApiResponse response =
|
||||||
return _connectorRepository.getPendingInvoices(businessId: businessId);
|
await _apiService.get(V2ApiEndpoints.clientBillingInvoicesPending);
|
||||||
|
final List<dynamic> items =
|
||||||
|
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
|
||||||
|
return items
|
||||||
|
.map(
|
||||||
|
(dynamic json) => Invoice.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<double> getSavingsAmount() async {
|
Future<List<Invoice>> getInvoiceHistory() async {
|
||||||
// Simulating savings calculation
|
final ApiResponse response =
|
||||||
return 0.0;
|
await _apiService.get(V2ApiEndpoints.clientBillingInvoicesHistory);
|
||||||
|
final List<dynamic> items =
|
||||||
|
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
|
||||||
|
return items
|
||||||
|
.map(
|
||||||
|
(dynamic json) => Invoice.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<InvoiceItem>> getSpendingBreakdown(BillingPeriod period) async {
|
Future<int> getCurrentBillCents() async {
|
||||||
final String businessId = await _service.getBusinessId();
|
final ApiResponse response =
|
||||||
return _connectorRepository.getSpendingBreakdown(
|
await _apiService.get(V2ApiEndpoints.clientBillingCurrentBill);
|
||||||
businessId: businessId,
|
final Map<String, dynamic> data =
|
||||||
period: period,
|
response.data as Map<String, dynamic>;
|
||||||
|
return (data['currentBillCents'] as num).toInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> getSavingsCents() async {
|
||||||
|
final ApiResponse response =
|
||||||
|
await _apiService.get(V2ApiEndpoints.clientBillingSavings);
|
||||||
|
final Map<String, dynamic> data =
|
||||||
|
response.data as Map<String, dynamic>;
|
||||||
|
return (data['savingsCents'] as num).toInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<SpendItem>> getSpendBreakdown({
|
||||||
|
required String startDate,
|
||||||
|
required String endDate,
|
||||||
|
}) async {
|
||||||
|
final ApiResponse response = await _apiService.get(
|
||||||
|
V2ApiEndpoints.clientBillingSpendBreakdown,
|
||||||
|
params: <String, dynamic>{
|
||||||
|
'startDate': startDate,
|
||||||
|
'endDate': endDate,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
final List<dynamic> items =
|
||||||
|
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
|
||||||
|
return items
|
||||||
|
.map((dynamic json) =>
|
||||||
|
SpendItem.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> approveInvoice(String id) async {
|
Future<void> approveInvoice(String id) async {
|
||||||
return _connectorRepository.approveInvoice(id: id);
|
await _apiService.post(V2ApiEndpoints.clientInvoiceApprove(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> disputeInvoice(String id, String reason) async {
|
Future<void> disputeInvoice(String id, String reason) async {
|
||||||
return _connectorRepository.disputeInvoice(id: id, reason: reason);
|
await _apiService.post(
|
||||||
|
V2ApiEndpoints.clientInvoiceDispute(id),
|
||||||
|
data: <String, dynamic>{'reason': reason},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import 'package:krow_domain/krow_domain.dart';
|
|||||||
/// It allows the Domain layer to remain independent of specific data sources.
|
/// It allows the Domain layer to remain independent of specific data sources.
|
||||||
abstract class BillingRepository {
|
abstract class BillingRepository {
|
||||||
/// Fetches bank accounts associated with the business.
|
/// Fetches bank accounts associated with the business.
|
||||||
Future<List<BusinessBankAccount>> getBankAccounts();
|
Future<List<BillingAccount>> getBankAccounts();
|
||||||
|
|
||||||
/// Fetches invoices that are pending approval or payment.
|
/// Fetches invoices that are pending approval or payment.
|
||||||
Future<List<Invoice>> getPendingInvoices();
|
Future<List<Invoice>> getPendingInvoices();
|
||||||
@@ -15,14 +15,17 @@ abstract class BillingRepository {
|
|||||||
/// Fetches historically paid invoices.
|
/// Fetches historically paid invoices.
|
||||||
Future<List<Invoice>> getInvoiceHistory();
|
Future<List<Invoice>> getInvoiceHistory();
|
||||||
|
|
||||||
/// Fetches the current bill amount for the period.
|
/// Fetches the current bill amount in cents for the period.
|
||||||
Future<double> getCurrentBillAmount();
|
Future<int> getCurrentBillCents();
|
||||||
|
|
||||||
/// Fetches the savings amount.
|
/// Fetches the savings amount in cents.
|
||||||
Future<double> getSavingsAmount();
|
Future<int> getSavingsCents();
|
||||||
|
|
||||||
/// Fetches invoice items for spending breakdown analysis.
|
/// Fetches spending breakdown by category for a date range.
|
||||||
Future<List<InvoiceItem>> getSpendingBreakdown(BillingPeriod period);
|
Future<List<SpendItem>> getSpendBreakdown({
|
||||||
|
required String startDate,
|
||||||
|
required String endDate,
|
||||||
|
});
|
||||||
|
|
||||||
/// Approves an invoice.
|
/// Approves an invoice.
|
||||||
Future<void> approveInvoice(String id);
|
Future<void> approveInvoice(String id);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import '../repositories/billing_repository.dart';
|
|
||||||
|
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||||
|
|
||||||
/// Use case for approving an invoice.
|
/// Use case for approving an invoice.
|
||||||
class ApproveInvoiceUseCase extends UseCase<String, void> {
|
class ApproveInvoiceUseCase extends UseCase<String, void> {
|
||||||
/// Creates an [ApproveInvoiceUseCase].
|
/// Creates an [ApproveInvoiceUseCase].
|
||||||
ApproveInvoiceUseCase(this._repository);
|
ApproveInvoiceUseCase(this._repository);
|
||||||
|
|
||||||
|
/// The billing repository.
|
||||||
final BillingRepository _repository;
|
final BillingRepository _repository;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import '../repositories/billing_repository.dart';
|
|
||||||
|
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||||
|
|
||||||
/// Params for [DisputeInvoiceUseCase].
|
/// Params for [DisputeInvoiceUseCase].
|
||||||
class DisputeInvoiceParams {
|
class DisputeInvoiceParams {
|
||||||
|
/// Creates [DisputeInvoiceParams].
|
||||||
const DisputeInvoiceParams({required this.id, required this.reason});
|
const DisputeInvoiceParams({required this.id, required this.reason});
|
||||||
|
|
||||||
|
/// The invoice ID to dispute.
|
||||||
final String id;
|
final String id;
|
||||||
|
|
||||||
|
/// The reason for the dispute.
|
||||||
final String reason;
|
final String reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,6 +19,7 @@ class DisputeInvoiceUseCase extends UseCase<DisputeInvoiceParams, void> {
|
|||||||
/// Creates a [DisputeInvoiceUseCase].
|
/// Creates a [DisputeInvoiceUseCase].
|
||||||
DisputeInvoiceUseCase(this._repository);
|
DisputeInvoiceUseCase(this._repository);
|
||||||
|
|
||||||
|
/// The billing repository.
|
||||||
final BillingRepository _repository;
|
final BillingRepository _repository;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../repositories/billing_repository.dart';
|
|
||||||
|
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||||
|
|
||||||
/// Use case for fetching the bank accounts associated with the business.
|
/// Use case for fetching the bank accounts associated with the business.
|
||||||
class GetBankAccountsUseCase extends NoInputUseCase<List<BusinessBankAccount>> {
|
class GetBankAccountsUseCase extends NoInputUseCase<List<BillingAccount>> {
|
||||||
/// Creates a [GetBankAccountsUseCase].
|
/// Creates a [GetBankAccountsUseCase].
|
||||||
GetBankAccountsUseCase(this._repository);
|
GetBankAccountsUseCase(this._repository);
|
||||||
|
|
||||||
|
/// The billing repository.
|
||||||
final BillingRepository _repository;
|
final BillingRepository _repository;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<BusinessBankAccount>> call() => _repository.getBankAccounts();
|
Future<List<BillingAccount>> call() => _repository.getBankAccounts();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import '../repositories/billing_repository.dart';
|
|
||||||
|
|
||||||
/// Use case for fetching the current bill amount.
|
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||||
|
|
||||||
|
/// Use case for fetching the current bill amount in cents.
|
||||||
///
|
///
|
||||||
/// This use case encapsulates the logic for retrieving the total amount due for the current billing period.
|
/// Delegates data retrieval to the [BillingRepository].
|
||||||
/// It delegates the data retrieval to the [BillingRepository].
|
class GetCurrentBillAmountUseCase extends NoInputUseCase<int> {
|
||||||
class GetCurrentBillAmountUseCase extends NoInputUseCase<double> {
|
|
||||||
/// Creates a [GetCurrentBillAmountUseCase].
|
/// Creates a [GetCurrentBillAmountUseCase].
|
||||||
GetCurrentBillAmountUseCase(this._repository);
|
GetCurrentBillAmountUseCase(this._repository);
|
||||||
|
|
||||||
|
/// The billing repository.
|
||||||
final BillingRepository _repository;
|
final BillingRepository _repository;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<double> call() => _repository.getCurrentBillAmount();
|
Future<int> call() => _repository.getCurrentBillCents();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../repositories/billing_repository.dart';
|
|
||||||
|
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||||
|
|
||||||
/// Use case for fetching the invoice history.
|
/// Use case for fetching the invoice history.
|
||||||
///
|
///
|
||||||
/// This use case encapsulates the logic for retrieving the list of past paid invoices.
|
/// Retrieves the list of past paid invoices.
|
||||||
/// It delegates the data retrieval to the [BillingRepository].
|
|
||||||
class GetInvoiceHistoryUseCase extends NoInputUseCase<List<Invoice>> {
|
class GetInvoiceHistoryUseCase extends NoInputUseCase<List<Invoice>> {
|
||||||
/// Creates a [GetInvoiceHistoryUseCase].
|
/// Creates a [GetInvoiceHistoryUseCase].
|
||||||
GetInvoiceHistoryUseCase(this._repository);
|
GetInvoiceHistoryUseCase(this._repository);
|
||||||
|
|
||||||
|
/// The billing repository.
|
||||||
final BillingRepository _repository;
|
final BillingRepository _repository;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../repositories/billing_repository.dart';
|
|
||||||
|
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||||
|
|
||||||
/// Use case for fetching the pending invoices.
|
/// Use case for fetching the pending invoices.
|
||||||
///
|
///
|
||||||
/// This use case encapsulates the logic for retrieving invoices that are currently open or disputed.
|
/// Retrieves invoices that are currently open or disputed.
|
||||||
/// It delegates the data retrieval to the [BillingRepository].
|
|
||||||
class GetPendingInvoicesUseCase extends NoInputUseCase<List<Invoice>> {
|
class GetPendingInvoicesUseCase extends NoInputUseCase<List<Invoice>> {
|
||||||
/// Creates a [GetPendingInvoicesUseCase].
|
/// Creates a [GetPendingInvoicesUseCase].
|
||||||
GetPendingInvoicesUseCase(this._repository);
|
GetPendingInvoicesUseCase(this._repository);
|
||||||
|
|
||||||
|
/// The billing repository.
|
||||||
final BillingRepository _repository;
|
final BillingRepository _repository;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import '../repositories/billing_repository.dart';
|
|
||||||
|
|
||||||
/// Use case for fetching the savings amount.
|
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||||
|
|
||||||
|
/// Use case for fetching the savings amount in cents.
|
||||||
///
|
///
|
||||||
/// This use case encapsulates the logic for retrieving the estimated savings for the client.
|
/// Delegates data retrieval to the [BillingRepository].
|
||||||
/// It delegates the data retrieval to the [BillingRepository].
|
class GetSavingsAmountUseCase extends NoInputUseCase<int> {
|
||||||
class GetSavingsAmountUseCase extends NoInputUseCase<double> {
|
|
||||||
/// Creates a [GetSavingsAmountUseCase].
|
/// Creates a [GetSavingsAmountUseCase].
|
||||||
GetSavingsAmountUseCase(this._repository);
|
GetSavingsAmountUseCase(this._repository);
|
||||||
|
|
||||||
|
/// The billing repository.
|
||||||
final BillingRepository _repository;
|
final BillingRepository _repository;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<double> call() => _repository.getSavingsAmount();
|
Future<int> call() => _repository.getSavingsCents();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,38 @@
|
|||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../repositories/billing_repository.dart';
|
|
||||||
|
|
||||||
/// Use case for fetching the spending breakdown items.
|
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||||
|
|
||||||
|
/// Parameters for [GetSpendBreakdownUseCase].
|
||||||
|
class SpendBreakdownParams {
|
||||||
|
/// Creates [SpendBreakdownParams].
|
||||||
|
const SpendBreakdownParams({
|
||||||
|
required this.startDate,
|
||||||
|
required this.endDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// ISO-8601 start date for the range.
|
||||||
|
final String startDate;
|
||||||
|
|
||||||
|
/// ISO-8601 end date for the range.
|
||||||
|
final String endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Use case for fetching the spending breakdown by category.
|
||||||
///
|
///
|
||||||
/// This use case encapsulates the logic for retrieving the spending breakdown by category or item.
|
/// Delegates data retrieval to the [BillingRepository].
|
||||||
/// It delegates the data retrieval to the [BillingRepository].
|
class GetSpendBreakdownUseCase
|
||||||
class GetSpendingBreakdownUseCase
|
extends UseCase<SpendBreakdownParams, List<SpendItem>> {
|
||||||
extends UseCase<BillingPeriod, List<InvoiceItem>> {
|
/// Creates a [GetSpendBreakdownUseCase].
|
||||||
/// Creates a [GetSpendingBreakdownUseCase].
|
GetSpendBreakdownUseCase(this._repository);
|
||||||
GetSpendingBreakdownUseCase(this._repository);
|
|
||||||
|
|
||||||
|
/// The billing repository.
|
||||||
final BillingRepository _repository;
|
final BillingRepository _repository;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<InvoiceItem>> call(BillingPeriod period) =>
|
Future<List<SpendItem>> call(SpendBreakdownParams input) =>
|
||||||
_repository.getSpendingBreakdown(period);
|
_repository.getSpendBreakdown(
|
||||||
|
startDate: input.startDate,
|
||||||
|
endDate: input.endDate,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
|
import 'dart:developer' as developer;
|
||||||
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../../domain/usecases/get_bank_accounts.dart';
|
|
||||||
import '../../domain/usecases/get_current_bill_amount.dart';
|
import 'package:billing/src/domain/usecases/get_bank_accounts.dart';
|
||||||
import '../../domain/usecases/get_invoice_history.dart';
|
import 'package:billing/src/domain/usecases/get_current_bill_amount.dart';
|
||||||
import '../../domain/usecases/get_pending_invoices.dart';
|
import 'package:billing/src/domain/usecases/get_invoice_history.dart';
|
||||||
import '../../domain/usecases/get_savings_amount.dart';
|
import 'package:billing/src/domain/usecases/get_pending_invoices.dart';
|
||||||
import '../../domain/usecases/get_spending_breakdown.dart';
|
import 'package:billing/src/domain/usecases/get_savings_amount.dart';
|
||||||
import '../models/billing_invoice_model.dart';
|
import 'package:billing/src/domain/usecases/get_spending_breakdown.dart';
|
||||||
import '../models/spending_breakdown_model.dart';
|
import 'package:billing/src/presentation/blocs/billing_event.dart';
|
||||||
import 'billing_event.dart';
|
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||||
import 'billing_state.dart';
|
|
||||||
|
|
||||||
/// BLoC for managing billing state and data loading.
|
/// BLoC for managing billing state and data loading.
|
||||||
class BillingBloc extends Bloc<BillingEvent, BillingState>
|
class BillingBloc extends Bloc<BillingEvent, BillingState>
|
||||||
@@ -23,14 +23,14 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
|||||||
required GetSavingsAmountUseCase getSavingsAmount,
|
required GetSavingsAmountUseCase getSavingsAmount,
|
||||||
required GetPendingInvoicesUseCase getPendingInvoices,
|
required GetPendingInvoicesUseCase getPendingInvoices,
|
||||||
required GetInvoiceHistoryUseCase getInvoiceHistory,
|
required GetInvoiceHistoryUseCase getInvoiceHistory,
|
||||||
required GetSpendingBreakdownUseCase getSpendingBreakdown,
|
required GetSpendBreakdownUseCase getSpendBreakdown,
|
||||||
}) : _getBankAccounts = getBankAccounts,
|
}) : _getBankAccounts = getBankAccounts,
|
||||||
_getCurrentBillAmount = getCurrentBillAmount,
|
_getCurrentBillAmount = getCurrentBillAmount,
|
||||||
_getSavingsAmount = getSavingsAmount,
|
_getSavingsAmount = getSavingsAmount,
|
||||||
_getPendingInvoices = getPendingInvoices,
|
_getPendingInvoices = getPendingInvoices,
|
||||||
_getInvoiceHistory = getInvoiceHistory,
|
_getInvoiceHistory = getInvoiceHistory,
|
||||||
_getSpendingBreakdown = getSpendingBreakdown,
|
_getSpendBreakdown = getSpendBreakdown,
|
||||||
super(const BillingState()) {
|
super(const BillingState()) {
|
||||||
on<BillingLoadStarted>(_onLoadStarted);
|
on<BillingLoadStarted>(_onLoadStarted);
|
||||||
on<BillingPeriodChanged>(_onPeriodChanged);
|
on<BillingPeriodChanged>(_onPeriodChanged);
|
||||||
}
|
}
|
||||||
@@ -40,61 +40,60 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
|||||||
final GetSavingsAmountUseCase _getSavingsAmount;
|
final GetSavingsAmountUseCase _getSavingsAmount;
|
||||||
final GetPendingInvoicesUseCase _getPendingInvoices;
|
final GetPendingInvoicesUseCase _getPendingInvoices;
|
||||||
final GetInvoiceHistoryUseCase _getInvoiceHistory;
|
final GetInvoiceHistoryUseCase _getInvoiceHistory;
|
||||||
final GetSpendingBreakdownUseCase _getSpendingBreakdown;
|
final GetSpendBreakdownUseCase _getSpendBreakdown;
|
||||||
|
|
||||||
|
/// Executes [loader] and returns null on failure, logging the error.
|
||||||
|
Future<T?> _loadSafe<T>(Future<T> Function() loader) async {
|
||||||
|
try {
|
||||||
|
return await loader();
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
developer.log(
|
||||||
|
'Partial billing load failed: $e',
|
||||||
|
name: 'BillingBloc',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onLoadStarted(
|
Future<void> _onLoadStarted(
|
||||||
BillingLoadStarted event,
|
BillingLoadStarted event,
|
||||||
Emitter<BillingState> emit,
|
Emitter<BillingState> emit,
|
||||||
) async {
|
) async {
|
||||||
emit(state.copyWith(status: BillingStatus.loading));
|
emit(state.copyWith(status: BillingStatus.loading));
|
||||||
await handleError(
|
|
||||||
emit: emit.call,
|
|
||||||
action: () async {
|
|
||||||
final List<dynamic> results =
|
|
||||||
await Future.wait<dynamic>(<Future<dynamic>>[
|
|
||||||
_getCurrentBillAmount.call(),
|
|
||||||
_getSavingsAmount.call(),
|
|
||||||
_getPendingInvoices.call(),
|
|
||||||
_getInvoiceHistory.call(),
|
|
||||||
_getSpendingBreakdown.call(state.period),
|
|
||||||
_getBankAccounts.call(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
final double savings = results[1] as double;
|
final SpendBreakdownParams spendParams = _dateRangeFor(state.periodTab);
|
||||||
final List<Invoice> pendingInvoices = results[2] as List<Invoice>;
|
|
||||||
final List<Invoice> invoiceHistory = results[3] as List<Invoice>;
|
|
||||||
final List<InvoiceItem> spendingItems = results[4] as List<InvoiceItem>;
|
|
||||||
final List<BusinessBankAccount> bankAccounts =
|
|
||||||
results[5] as List<BusinessBankAccount>;
|
|
||||||
|
|
||||||
// Map Domain Entities to Presentation Models
|
final List<Object?> results = await Future.wait<Object?>(
|
||||||
final List<BillingInvoice> uiPendingInvoices = pendingInvoices
|
<Future<Object?>>[
|
||||||
.map(_mapInvoiceToUiModel)
|
_loadSafe<int>(() => _getCurrentBillAmount.call()),
|
||||||
.toList();
|
_loadSafe<int>(() => _getSavingsAmount.call()),
|
||||||
final List<BillingInvoice> uiInvoiceHistory = invoiceHistory
|
_loadSafe<List<Invoice>>(() => _getPendingInvoices.call()),
|
||||||
.map(_mapInvoiceToUiModel)
|
_loadSafe<List<Invoice>>(() => _getInvoiceHistory.call()),
|
||||||
.toList();
|
_loadSafe<List<SpendItem>>(() => _getSpendBreakdown.call(spendParams)),
|
||||||
final List<SpendingBreakdownItem> uiSpendingBreakdown =
|
_loadSafe<List<BillingAccount>>(() => _getBankAccounts.call()),
|
||||||
_mapSpendingItemsToUiModel(spendingItems);
|
],
|
||||||
final double periodTotal = uiSpendingBreakdown.fold(
|
);
|
||||||
0.0,
|
|
||||||
(double sum, SpendingBreakdownItem item) => sum + item.amount,
|
|
||||||
);
|
|
||||||
|
|
||||||
emit(
|
final int? currentBillCents = results[0] as int?;
|
||||||
state.copyWith(
|
final int? savingsCents = results[1] as int?;
|
||||||
status: BillingStatus.success,
|
final List<Invoice>? pendingInvoices = results[2] as List<Invoice>?;
|
||||||
currentBill: periodTotal,
|
final List<Invoice>? invoiceHistory = results[3] as List<Invoice>?;
|
||||||
savings: savings,
|
final List<SpendItem>? spendBreakdown = results[4] as List<SpendItem>?;
|
||||||
pendingInvoices: uiPendingInvoices,
|
final List<BillingAccount>? bankAccounts =
|
||||||
invoiceHistory: uiInvoiceHistory,
|
results[5] as List<BillingAccount>?;
|
||||||
spendingBreakdown: uiSpendingBreakdown,
|
|
||||||
bankAccounts: bankAccounts,
|
emit(
|
||||||
),
|
state.copyWith(
|
||||||
);
|
status: BillingStatus.success,
|
||||||
},
|
currentBillCents: currentBillCents ?? state.currentBillCents,
|
||||||
onError: (String errorKey) =>
|
savingsCents: savingsCents ?? state.savingsCents,
|
||||||
state.copyWith(status: BillingStatus.failure, errorMessage: errorKey),
|
pendingInvoices: pendingInvoices ?? state.pendingInvoices,
|
||||||
|
invoiceHistory: invoiceHistory ?? state.invoiceHistory,
|
||||||
|
spendBreakdown: spendBreakdown ?? state.spendBreakdown,
|
||||||
|
bankAccounts: bankAccounts ?? state.bankAccounts,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,19 +104,15 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
|||||||
await handleError(
|
await handleError(
|
||||||
emit: emit.call,
|
emit: emit.call,
|
||||||
action: () async {
|
action: () async {
|
||||||
final List<InvoiceItem> spendingItems = await _getSpendingBreakdown
|
final SpendBreakdownParams params =
|
||||||
.call(event.period);
|
_dateRangeFor(event.periodTab);
|
||||||
final List<SpendingBreakdownItem> uiSpendingBreakdown =
|
final List<SpendItem> spendBreakdown =
|
||||||
_mapSpendingItemsToUiModel(spendingItems);
|
await _getSpendBreakdown.call(params);
|
||||||
final double periodTotal = uiSpendingBreakdown.fold(
|
|
||||||
0.0,
|
|
||||||
(double sum, SpendingBreakdownItem item) => sum + item.amount,
|
|
||||||
);
|
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
period: event.period,
|
periodTab: event.periodTab,
|
||||||
spendingBreakdown: uiSpendingBreakdown,
|
spendBreakdown: spendBreakdown,
|
||||||
currentBill: periodTotal,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -126,98 +121,14 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
BillingInvoice _mapInvoiceToUiModel(Invoice invoice) {
|
/// Computes ISO-8601 date range for the selected period tab.
|
||||||
final DateFormat formatter = DateFormat('EEEE, MMMM d');
|
SpendBreakdownParams _dateRangeFor(BillingPeriodTab tab) {
|
||||||
final String dateLabel = invoice.issueDate == null
|
final DateTime now = DateTime.now().toUtc();
|
||||||
? 'N/A'
|
final int days = tab == BillingPeriodTab.week ? 7 : 30;
|
||||||
: formatter.format(invoice.issueDate!);
|
final DateTime start = now.subtract(Duration(days: days));
|
||||||
|
return SpendBreakdownParams(
|
||||||
final List<BillingWorkerRecord> workers = invoice.workers.map((
|
startDate: start.toIso8601String(),
|
||||||
InvoiceWorker w,
|
endDate: now.toIso8601String(),
|
||||||
) {
|
|
||||||
final DateFormat timeFormat = DateFormat('h:mm a');
|
|
||||||
return BillingWorkerRecord(
|
|
||||||
workerName: w.name,
|
|
||||||
roleName: w.role,
|
|
||||||
totalAmount: w.amount,
|
|
||||||
hours: w.hours,
|
|
||||||
rate: w.rate,
|
|
||||||
startTime: w.checkIn != null ? timeFormat.format(w.checkIn!) : '--:--',
|
|
||||||
endTime: w.checkOut != null ? timeFormat.format(w.checkOut!) : '--:--',
|
|
||||||
breakMinutes: w.breakMinutes,
|
|
||||||
workerAvatarUrl: w.avatarUrl,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
String? overallStart;
|
|
||||||
String? overallEnd;
|
|
||||||
|
|
||||||
// Find valid times from actual DateTime checks to ensure chronological sorting
|
|
||||||
final List<DateTime> validCheckIns = invoice.workers
|
|
||||||
.where((InvoiceWorker w) => w.checkIn != null)
|
|
||||||
.map((InvoiceWorker w) => w.checkIn!)
|
|
||||||
.toList();
|
|
||||||
final List<DateTime> validCheckOuts = invoice.workers
|
|
||||||
.where((InvoiceWorker w) => w.checkOut != null)
|
|
||||||
.map((InvoiceWorker w) => w.checkOut!)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
final DateFormat timeFormat = DateFormat('h:mm a');
|
|
||||||
|
|
||||||
if (validCheckIns.isNotEmpty) {
|
|
||||||
validCheckIns.sort();
|
|
||||||
overallStart = timeFormat.format(validCheckIns.first);
|
|
||||||
} else if (workers.isNotEmpty) {
|
|
||||||
overallStart = workers.first.startTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validCheckOuts.isNotEmpty) {
|
|
||||||
validCheckOuts.sort();
|
|
||||||
overallEnd = timeFormat.format(validCheckOuts.last);
|
|
||||||
} else if (workers.isNotEmpty) {
|
|
||||||
overallEnd = workers.first.endTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
return BillingInvoice(
|
|
||||||
id: invoice.id,
|
|
||||||
title: invoice.title ?? 'N/A',
|
|
||||||
locationAddress: invoice.locationAddress ?? 'Remote',
|
|
||||||
clientName: invoice.clientName ?? 'N/A',
|
|
||||||
date: dateLabel,
|
|
||||||
totalAmount: invoice.totalAmount,
|
|
||||||
workersCount: invoice.staffCount ?? 0,
|
|
||||||
totalHours: invoice.totalHours ?? 0.0,
|
|
||||||
status: invoice.status.name.toUpperCase(),
|
|
||||||
workers: workers,
|
|
||||||
startTime: overallStart,
|
|
||||||
endTime: overallEnd,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<SpendingBreakdownItem> _mapSpendingItemsToUiModel(
|
|
||||||
List<InvoiceItem> items,
|
|
||||||
) {
|
|
||||||
final Map<String, SpendingBreakdownItem> aggregation =
|
|
||||||
<String, SpendingBreakdownItem>{};
|
|
||||||
|
|
||||||
for (final InvoiceItem item in items) {
|
|
||||||
final String category = item.staffId;
|
|
||||||
final SpendingBreakdownItem? existing = aggregation[category];
|
|
||||||
if (existing != null) {
|
|
||||||
aggregation[category] = SpendingBreakdownItem(
|
|
||||||
category: category,
|
|
||||||
hours: existing.hours + item.workHours.round(),
|
|
||||||
amount: existing.amount + item.amount,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
aggregation[category] = SpendingBreakdownItem(
|
|
||||||
category: category,
|
|
||||||
hours: item.workHours.round(),
|
|
||||||
amount: item.amount,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return aggregation.values.toList();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
|
||||||
|
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||||
|
|
||||||
/// Base class for all billing events.
|
/// Base class for all billing events.
|
||||||
abstract class BillingEvent extends Equatable {
|
abstract class BillingEvent extends Equatable {
|
||||||
@@ -16,11 +17,14 @@ class BillingLoadStarted extends BillingEvent {
|
|||||||
const BillingLoadStarted();
|
const BillingLoadStarted();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Event triggered when the spend breakdown period tab changes.
|
||||||
class BillingPeriodChanged extends BillingEvent {
|
class BillingPeriodChanged extends BillingEvent {
|
||||||
const BillingPeriodChanged(this.period);
|
/// Creates a [BillingPeriodChanged] event.
|
||||||
|
const BillingPeriodChanged(this.periodTab);
|
||||||
|
|
||||||
final BillingPeriod period;
|
/// The selected period tab.
|
||||||
|
final BillingPeriodTab periodTab;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => <Object?>[period];
|
List<Object?> get props => <Object?>[periodTab];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../models/billing_invoice_model.dart';
|
|
||||||
import '../models/spending_breakdown_model.dart';
|
|
||||||
|
|
||||||
/// The loading status of the billing feature.
|
/// The loading status of the billing feature.
|
||||||
enum BillingStatus {
|
enum BillingStatus {
|
||||||
@@ -18,83 +16,104 @@ enum BillingStatus {
|
|||||||
failure,
|
failure,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which period the spend breakdown covers.
|
||||||
|
enum BillingPeriodTab {
|
||||||
|
/// Last 7 days.
|
||||||
|
week,
|
||||||
|
|
||||||
|
/// Last 30 days.
|
||||||
|
month,
|
||||||
|
}
|
||||||
|
|
||||||
/// Represents the state of the billing feature.
|
/// Represents the state of the billing feature.
|
||||||
class BillingState extends Equatable {
|
class BillingState extends Equatable {
|
||||||
/// Creates a [BillingState].
|
/// Creates a [BillingState].
|
||||||
const BillingState({
|
const BillingState({
|
||||||
this.status = BillingStatus.initial,
|
this.status = BillingStatus.initial,
|
||||||
this.currentBill = 0.0,
|
this.currentBillCents = 0,
|
||||||
this.savings = 0.0,
|
this.savingsCents = 0,
|
||||||
this.pendingInvoices = const <BillingInvoice>[],
|
this.pendingInvoices = const <Invoice>[],
|
||||||
this.invoiceHistory = const <BillingInvoice>[],
|
this.invoiceHistory = const <Invoice>[],
|
||||||
this.spendingBreakdown = const <SpendingBreakdownItem>[],
|
this.spendBreakdown = const <SpendItem>[],
|
||||||
this.bankAccounts = const <BusinessBankAccount>[],
|
this.bankAccounts = const <BillingAccount>[],
|
||||||
this.period = BillingPeriod.week,
|
this.periodTab = BillingPeriodTab.week,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The current feature status.
|
/// The current feature status.
|
||||||
final BillingStatus status;
|
final BillingStatus status;
|
||||||
|
|
||||||
/// The total amount for the current billing period.
|
/// The total amount for the current billing period in cents.
|
||||||
final double currentBill;
|
final int currentBillCents;
|
||||||
|
|
||||||
/// Total savings achieved compared to traditional agencies.
|
/// Total savings in cents.
|
||||||
final double savings;
|
final int savingsCents;
|
||||||
|
|
||||||
/// Invoices awaiting client approval.
|
/// Invoices awaiting client approval.
|
||||||
final List<BillingInvoice> pendingInvoices;
|
final List<Invoice> pendingInvoices;
|
||||||
|
|
||||||
/// History of paid invoices.
|
/// History of paid invoices.
|
||||||
final List<BillingInvoice> invoiceHistory;
|
final List<Invoice> invoiceHistory;
|
||||||
|
|
||||||
/// Breakdown of spending by category.
|
/// Breakdown of spending by category.
|
||||||
final List<SpendingBreakdownItem> spendingBreakdown;
|
final List<SpendItem> spendBreakdown;
|
||||||
|
|
||||||
/// Bank accounts associated with the business.
|
/// Bank accounts associated with the business.
|
||||||
final List<BusinessBankAccount> bankAccounts;
|
final List<BillingAccount> bankAccounts;
|
||||||
|
|
||||||
/// Selected period for the breakdown.
|
/// Selected period tab for the breakdown.
|
||||||
final BillingPeriod period;
|
final BillingPeriodTab periodTab;
|
||||||
|
|
||||||
/// Error message if loading failed.
|
/// Error message if loading failed.
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
|
||||||
|
/// Current bill formatted as dollars.
|
||||||
|
double get currentBillDollars => currentBillCents / 100.0;
|
||||||
|
|
||||||
|
/// Savings formatted as dollars.
|
||||||
|
double get savingsDollars => savingsCents / 100.0;
|
||||||
|
|
||||||
|
/// Total spend across the breakdown in cents.
|
||||||
|
int get spendTotalCents => spendBreakdown.fold(
|
||||||
|
0,
|
||||||
|
(int sum, SpendItem item) => sum + item.amountCents,
|
||||||
|
);
|
||||||
|
|
||||||
/// Creates a copy of this state with updated fields.
|
/// Creates a copy of this state with updated fields.
|
||||||
BillingState copyWith({
|
BillingState copyWith({
|
||||||
BillingStatus? status,
|
BillingStatus? status,
|
||||||
double? currentBill,
|
int? currentBillCents,
|
||||||
double? savings,
|
int? savingsCents,
|
||||||
List<BillingInvoice>? pendingInvoices,
|
List<Invoice>? pendingInvoices,
|
||||||
List<BillingInvoice>? invoiceHistory,
|
List<Invoice>? invoiceHistory,
|
||||||
List<SpendingBreakdownItem>? spendingBreakdown,
|
List<SpendItem>? spendBreakdown,
|
||||||
List<BusinessBankAccount>? bankAccounts,
|
List<BillingAccount>? bankAccounts,
|
||||||
BillingPeriod? period,
|
BillingPeriodTab? periodTab,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
}) {
|
}) {
|
||||||
return BillingState(
|
return BillingState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
currentBill: currentBill ?? this.currentBill,
|
currentBillCents: currentBillCents ?? this.currentBillCents,
|
||||||
savings: savings ?? this.savings,
|
savingsCents: savingsCents ?? this.savingsCents,
|
||||||
pendingInvoices: pendingInvoices ?? this.pendingInvoices,
|
pendingInvoices: pendingInvoices ?? this.pendingInvoices,
|
||||||
invoiceHistory: invoiceHistory ?? this.invoiceHistory,
|
invoiceHistory: invoiceHistory ?? this.invoiceHistory,
|
||||||
spendingBreakdown: spendingBreakdown ?? this.spendingBreakdown,
|
spendBreakdown: spendBreakdown ?? this.spendBreakdown,
|
||||||
bankAccounts: bankAccounts ?? this.bankAccounts,
|
bankAccounts: bankAccounts ?? this.bankAccounts,
|
||||||
period: period ?? this.period,
|
periodTab: periodTab ?? this.periodTab,
|
||||||
errorMessage: errorMessage ?? this.errorMessage,
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => <Object?>[
|
List<Object?> get props => <Object?>[
|
||||||
status,
|
status,
|
||||||
currentBill,
|
currentBillCents,
|
||||||
savings,
|
savingsCents,
|
||||||
pendingInvoices,
|
pendingInvoices,
|
||||||
invoiceHistory,
|
invoiceHistory,
|
||||||
spendingBreakdown,
|
spendBreakdown,
|
||||||
bankAccounts,
|
bankAccounts,
|
||||||
period,
|
periodTab,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import '../../../domain/usecases/approve_invoice.dart';
|
|
||||||
import '../../../domain/usecases/dispute_invoice.dart';
|
|
||||||
import 'shift_completion_review_event.dart';
|
|
||||||
import 'shift_completion_review_state.dart';
|
|
||||||
|
|
||||||
|
import 'package:billing/src/domain/usecases/approve_invoice.dart';
|
||||||
|
import 'package:billing/src/domain/usecases/dispute_invoice.dart';
|
||||||
|
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_event.dart';
|
||||||
|
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_state.dart';
|
||||||
|
|
||||||
|
/// BLoC for approving or disputing an invoice from the review page.
|
||||||
class ShiftCompletionReviewBloc
|
class ShiftCompletionReviewBloc
|
||||||
extends Bloc<ShiftCompletionReviewEvent, ShiftCompletionReviewState>
|
extends Bloc<ShiftCompletionReviewEvent, ShiftCompletionReviewState>
|
||||||
with BlocErrorHandler<ShiftCompletionReviewState> {
|
with BlocErrorHandler<ShiftCompletionReviewState> {
|
||||||
|
/// Creates a [ShiftCompletionReviewBloc].
|
||||||
ShiftCompletionReviewBloc({
|
ShiftCompletionReviewBloc({
|
||||||
required ApproveInvoiceUseCase approveInvoice,
|
required ApproveInvoiceUseCase approveInvoice,
|
||||||
required DisputeInvoiceUseCase disputeInvoice,
|
required DisputeInvoiceUseCase disputeInvoice,
|
||||||
}) : _approveInvoice = approveInvoice,
|
}) : _approveInvoice = approveInvoice,
|
||||||
_disputeInvoice = disputeInvoice,
|
_disputeInvoice = disputeInvoice,
|
||||||
super(const ShiftCompletionReviewState()) {
|
super(const ShiftCompletionReviewState()) {
|
||||||
on<ShiftCompletionReviewApproved>(_onApproved);
|
on<ShiftCompletionReviewApproved>(_onApproved);
|
||||||
on<ShiftCompletionReviewDisputed>(_onDisputed);
|
on<ShiftCompletionReviewDisputed>(_onDisputed);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
|
||||||
|
|
||||||
class BillingInvoice extends Equatable {
|
|
||||||
const BillingInvoice({
|
|
||||||
required this.id,
|
|
||||||
required this.title,
|
|
||||||
required this.locationAddress,
|
|
||||||
required this.clientName,
|
|
||||||
required this.date,
|
|
||||||
required this.totalAmount,
|
|
||||||
required this.workersCount,
|
|
||||||
required this.totalHours,
|
|
||||||
required this.status,
|
|
||||||
this.workers = const <BillingWorkerRecord>[],
|
|
||||||
this.startTime,
|
|
||||||
this.endTime,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String id;
|
|
||||||
final String title;
|
|
||||||
final String locationAddress;
|
|
||||||
final String clientName;
|
|
||||||
final String date;
|
|
||||||
final double totalAmount;
|
|
||||||
final int workersCount;
|
|
||||||
final double totalHours;
|
|
||||||
final String status;
|
|
||||||
final List<BillingWorkerRecord> workers;
|
|
||||||
final String? startTime;
|
|
||||||
final String? endTime;
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => <Object?>[
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
locationAddress,
|
|
||||||
clientName,
|
|
||||||
date,
|
|
||||||
totalAmount,
|
|
||||||
workersCount,
|
|
||||||
totalHours,
|
|
||||||
status,
|
|
||||||
workers,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
class BillingWorkerRecord extends Equatable {
|
|
||||||
const BillingWorkerRecord({
|
|
||||||
required this.workerName,
|
|
||||||
required this.roleName,
|
|
||||||
required this.totalAmount,
|
|
||||||
required this.hours,
|
|
||||||
required this.rate,
|
|
||||||
required this.startTime,
|
|
||||||
required this.endTime,
|
|
||||||
required this.breakMinutes,
|
|
||||||
this.workerAvatarUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String workerName;
|
|
||||||
final String roleName;
|
|
||||||
final double totalAmount;
|
|
||||||
final double hours;
|
|
||||||
final double rate;
|
|
||||||
final String startTime;
|
|
||||||
final String endTime;
|
|
||||||
final int breakMinutes;
|
|
||||||
final String? workerAvatarUrl;
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => <Object?>[
|
|
||||||
workerName,
|
|
||||||
roleName,
|
|
||||||
totalAmount,
|
|
||||||
hours,
|
|
||||||
rate,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
breakMinutes,
|
|
||||||
workerAvatarUrl,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
|
||||||
|
|
||||||
/// Represents a single item in the spending breakdown.
|
|
||||||
class SpendingBreakdownItem extends Equatable {
|
|
||||||
/// Creates a [SpendingBreakdownItem].
|
|
||||||
const SpendingBreakdownItem({
|
|
||||||
required this.category,
|
|
||||||
required this.hours,
|
|
||||||
required this.amount,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// The category name (e.g., "Server Staff").
|
|
||||||
final String category;
|
|
||||||
|
|
||||||
/// The total hours worked in this category.
|
|
||||||
final int hours;
|
|
||||||
|
|
||||||
/// The total amount spent in this category.
|
|
||||||
final double amount;
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => <Object?>[category, hours, amount];
|
|
||||||
}
|
|
||||||
@@ -5,13 +5,13 @@ import 'package:flutter_bloc/flutter_bloc.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 '../blocs/billing_bloc.dart';
|
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||||
import '../blocs/billing_event.dart';
|
import 'package:billing/src/presentation/blocs/billing_event.dart';
|
||||||
import '../blocs/billing_state.dart';
|
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||||
import '../widgets/billing_page_skeleton.dart';
|
import 'package:billing/src/presentation/widgets/billing_page_skeleton.dart';
|
||||||
import '../widgets/invoice_history_section.dart';
|
import 'package:billing/src/presentation/widgets/invoice_history_section.dart';
|
||||||
import '../widgets/pending_invoices_section.dart';
|
import 'package:billing/src/presentation/widgets/pending_invoices_section.dart';
|
||||||
import '../widgets/spending_breakdown_card.dart';
|
import 'package:billing/src/presentation/widgets/spending_breakdown_card.dart';
|
||||||
|
|
||||||
/// The entry point page for the client billing feature.
|
/// The entry point page for the client billing feature.
|
||||||
///
|
///
|
||||||
@@ -32,8 +32,7 @@ class BillingPage extends StatelessWidget {
|
|||||||
|
|
||||||
/// The main view for the client billing feature.
|
/// The main view for the client billing feature.
|
||||||
///
|
///
|
||||||
/// This widget displays the billing dashboard content based on the current
|
/// Displays the billing dashboard content based on the current [BillingState].
|
||||||
/// state of the [BillingBloc].
|
|
||||||
class BillingView extends StatefulWidget {
|
class BillingView extends StatefulWidget {
|
||||||
/// Creates a [BillingView].
|
/// Creates a [BillingView].
|
||||||
const BillingView({super.key});
|
const BillingView({super.key});
|
||||||
@@ -125,7 +124,7 @@ class _BillingViewState extends State<BillingView> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space1),
|
const SizedBox(height: UiConstants.space1),
|
||||||
Text(
|
Text(
|
||||||
'\$${state.currentBill.toStringAsFixed(2)}',
|
'\$${state.currentBillDollars.toStringAsFixed(2)}',
|
||||||
style: UiTypography.displayM.copyWith(
|
style: UiTypography.displayM.copyWith(
|
||||||
color: UiColors.white,
|
color: UiColors.white,
|
||||||
fontSize: 40,
|
fontSize: 40,
|
||||||
@@ -152,7 +151,8 @@ class _BillingViewState extends State<BillingView> {
|
|||||||
const SizedBox(width: UiConstants.space2),
|
const SizedBox(width: UiConstants.space2),
|
||||||
Text(
|
Text(
|
||||||
t.client_billing.saved_amount(
|
t.client_billing.saved_amount(
|
||||||
amount: state.savings.toStringAsFixed(0),
|
amount: state.savingsDollars
|
||||||
|
.toStringAsFixed(0),
|
||||||
),
|
),
|
||||||
style: UiTypography.footnote2b.copyWith(
|
style: UiTypography.footnote2b.copyWith(
|
||||||
color: UiColors.accentForeground,
|
color: UiColors.accentForeground,
|
||||||
@@ -221,7 +221,6 @@ class _BillingViewState extends State<BillingView> {
|
|||||||
if (state.pendingInvoices.isNotEmpty) ...<Widget>[
|
if (state.pendingInvoices.isNotEmpty) ...<Widget>[
|
||||||
PendingInvoicesSection(invoices: state.pendingInvoices),
|
PendingInvoicesSection(invoices: state.pendingInvoices),
|
||||||
],
|
],
|
||||||
// const PaymentMethodCard(),
|
|
||||||
const SpendingBreakdownCard(),
|
const SpendingBreakdownCard(),
|
||||||
if (state.invoiceHistory.isNotEmpty)
|
if (state.invoiceHistory.isNotEmpty)
|
||||||
InvoiceHistorySection(invoices: state.invoiceHistory),
|
InvoiceHistorySection(invoices: state.invoiceHistory),
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.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:intl/intl.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
import '../models/billing_invoice_model.dart';
|
import 'package:billing/src/presentation/widgets/completion_review/completion_review_actions.dart';
|
||||||
import '../widgets/completion_review/completion_review_actions.dart';
|
import 'package:billing/src/presentation/widgets/completion_review/completion_review_amount.dart';
|
||||||
import '../widgets/completion_review/completion_review_amount.dart';
|
import 'package:billing/src/presentation/widgets/completion_review/completion_review_info.dart';
|
||||||
import '../widgets/completion_review/completion_review_info.dart';
|
|
||||||
import '../widgets/completion_review/completion_review_search_and_tabs.dart';
|
|
||||||
import '../widgets/completion_review/completion_review_worker_card.dart';
|
|
||||||
import '../widgets/completion_review/completion_review_workers_header.dart';
|
|
||||||
|
|
||||||
|
/// Page for reviewing and approving/disputing an invoice.
|
||||||
class ShiftCompletionReviewPage extends StatefulWidget {
|
class ShiftCompletionReviewPage extends StatefulWidget {
|
||||||
|
/// Creates a [ShiftCompletionReviewPage].
|
||||||
const ShiftCompletionReviewPage({this.invoice, super.key});
|
const ShiftCompletionReviewPage({this.invoice, super.key});
|
||||||
|
|
||||||
final BillingInvoice? invoice;
|
/// The invoice to review.
|
||||||
|
final Invoice? invoice;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ShiftCompletionReviewPage> createState() =>
|
State<ShiftCompletionReviewPage> createState() =>
|
||||||
@@ -21,31 +23,45 @@ class ShiftCompletionReviewPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
||||||
late BillingInvoice invoice;
|
/// The resolved invoice, or null if route data is missing/invalid.
|
||||||
String searchQuery = '';
|
late final Invoice? invoice;
|
||||||
int selectedTab = 0; // 0: Needs Review (mocked as empty), 1: All
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Use widget.invoice if provided, else try to get from arguments
|
invoice = widget.invoice ??
|
||||||
invoice = widget.invoice ?? Modular.args.data as BillingInvoice;
|
(Modular.args.data is Invoice
|
||||||
|
? Modular.args.data as Invoice
|
||||||
|
: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final List<BillingWorkerRecord> filteredWorkers = invoice.workers.where((
|
final Invoice? resolvedInvoice = invoice;
|
||||||
BillingWorkerRecord w,
|
if (resolvedInvoice == null) {
|
||||||
) {
|
return Scaffold(
|
||||||
if (searchQuery.isEmpty) return true;
|
appBar: UiAppBar(
|
||||||
return w.workerName.toLowerCase().contains(searchQuery.toLowerCase()) ||
|
title: t.client_billing.review_and_approve,
|
||||||
w.roleName.toLowerCase().contains(searchQuery.toLowerCase());
|
showBackButton: true,
|
||||||
}).toList();
|
),
|
||||||
|
body: Center(
|
||||||
|
child: Text(
|
||||||
|
t.errors.generic.unknown,
|
||||||
|
style: UiTypography.body1m.textError,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final DateFormat formatter = DateFormat('EEEE, MMMM d');
|
||||||
|
final String dateLabel = resolvedInvoice.dueDate != null
|
||||||
|
? formatter.format(resolvedInvoice.dueDate!)
|
||||||
|
: 'N/A';
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: UiAppBar(
|
appBar: UiAppBar(
|
||||||
title: invoice.title,
|
title: resolvedInvoice.invoiceNumber,
|
||||||
subtitle: invoice.clientName,
|
subtitle: resolvedInvoice.vendorName ?? '',
|
||||||
showBackButton: true,
|
showBackButton: true,
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
@@ -55,26 +71,13 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
CompletionReviewInfo(invoice: invoice),
|
CompletionReviewInfo(
|
||||||
|
dateLabel: dateLabel,
|
||||||
|
vendorName: resolvedInvoice.vendorName,
|
||||||
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
CompletionReviewAmount(invoice: invoice),
|
CompletionReviewAmount(amountCents: resolvedInvoice.amountCents),
|
||||||
const SizedBox(height: UiConstants.space6),
|
const SizedBox(height: UiConstants.space6),
|
||||||
// CompletionReviewWorkersHeader(workersCount: invoice.workersCount),
|
|
||||||
// const SizedBox(height: UiConstants.space4),
|
|
||||||
// CompletionReviewSearchAndTabs(
|
|
||||||
// selectedTab: selectedTab,
|
|
||||||
// workersCount: invoice.workersCount,
|
|
||||||
// onTabChanged: (int index) =>
|
|
||||||
// setState(() => selectedTab = index),
|
|
||||||
// onSearchChanged: (String val) =>
|
|
||||||
// setState(() => searchQuery = val),
|
|
||||||
// ),
|
|
||||||
// const SizedBox(height: UiConstants.space4),
|
|
||||||
// ...filteredWorkers.map(
|
|
||||||
// (BillingWorkerRecord worker) =>
|
|
||||||
// CompletionReviewWorkerCard(worker: worker),
|
|
||||||
// ),
|
|
||||||
// const SizedBox(height: UiConstants.space4),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -87,7 +90,9 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
|||||||
top: BorderSide(color: UiColors.border.withValues(alpha: 0.5)),
|
top: BorderSide(color: UiColors.border.withValues(alpha: 0.5)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: SafeArea(child: CompletionReviewActions(invoiceId: invoice.id)),
|
child: SafeArea(
|
||||||
|
child: CompletionReviewActions(invoiceId: resolvedInvoice.invoiceId),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ import 'package:design_system/design_system.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
import '../blocs/billing_bloc.dart';
|
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||||
import '../blocs/billing_event.dart';
|
import 'package:billing/src/presentation/blocs/billing_event.dart';
|
||||||
import '../blocs/billing_state.dart';
|
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||||
import '../models/billing_invoice_model.dart';
|
import 'package:billing/src/presentation/widgets/invoices_list_skeleton.dart';
|
||||||
import '../widgets/invoices_list_skeleton.dart';
|
|
||||||
|
|
||||||
|
/// Page displaying invoices that are ready.
|
||||||
class InvoiceReadyPage extends StatelessWidget {
|
class InvoiceReadyPage extends StatelessWidget {
|
||||||
|
/// Creates an [InvoiceReadyPage].
|
||||||
const InvoiceReadyPage({super.key});
|
const InvoiceReadyPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -21,7 +24,9 @@ class InvoiceReadyPage extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// View for the invoice ready page.
|
||||||
class InvoiceReadyView extends StatelessWidget {
|
class InvoiceReadyView extends StatelessWidget {
|
||||||
|
/// Creates an [InvoiceReadyView].
|
||||||
const InvoiceReadyView({super.key});
|
const InvoiceReadyView({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -60,7 +65,7 @@ class InvoiceReadyView extends StatelessWidget {
|
|||||||
separatorBuilder: (BuildContext context, int index) =>
|
separatorBuilder: (BuildContext context, int index) =>
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
itemBuilder: (BuildContext context, int index) {
|
itemBuilder: (BuildContext context, int index) {
|
||||||
final BillingInvoice invoice = state.invoiceHistory[index];
|
final Invoice invoice = state.invoiceHistory[index];
|
||||||
return _InvoiceSummaryCard(invoice: invoice);
|
return _InvoiceSummaryCard(invoice: invoice);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -72,10 +77,17 @@ class InvoiceReadyView extends StatelessWidget {
|
|||||||
|
|
||||||
class _InvoiceSummaryCard extends StatelessWidget {
|
class _InvoiceSummaryCard extends StatelessWidget {
|
||||||
const _InvoiceSummaryCard({required this.invoice});
|
const _InvoiceSummaryCard({required this.invoice});
|
||||||
final BillingInvoice invoice;
|
|
||||||
|
final Invoice invoice;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final DateFormat formatter = DateFormat('MMM d, yyyy');
|
||||||
|
final String dateLabel = invoice.dueDate != null
|
||||||
|
? formatter.format(invoice.dueDate!)
|
||||||
|
: 'N/A';
|
||||||
|
final double amountDollars = invoice.amountCents / 100.0;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -106,22 +118,26 @@ class _InvoiceSummaryCard extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'READY',
|
invoice.status.value.toUpperCase(),
|
||||||
style: UiTypography.titleUppercase4b.copyWith(
|
style: UiTypography.titleUppercase4b.copyWith(
|
||||||
color: UiColors.success,
|
color: UiColors.success,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(invoice.date, style: UiTypography.footnote2r.textTertiary),
|
Text(dateLabel, style: UiTypography.footnote2r.textTertiary),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(invoice.title, style: UiTypography.title2b.textPrimary),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
Text(
|
||||||
invoice.locationAddress,
|
invoice.invoiceNumber,
|
||||||
style: UiTypography.body2r.textSecondary,
|
style: UiTypography.title2b.textPrimary,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (invoice.vendorName != null)
|
||||||
|
Text(
|
||||||
|
invoice.vendorName!,
|
||||||
|
style: UiTypography.body2r.textSecondary,
|
||||||
|
),
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
@@ -134,7 +150,7 @@ class _InvoiceSummaryCard extends StatelessWidget {
|
|||||||
style: UiTypography.titleUppercase4m.textSecondary,
|
style: UiTypography.titleUppercase4m.textSecondary,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
'\$${amountDollars.toStringAsFixed(2)}',
|
||||||
style: UiTypography.title2b.primary,
|
style: UiTypography.title2b.primary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import 'package:flutter_bloc/flutter_bloc.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 '../blocs/billing_bloc.dart';
|
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||||
import '../blocs/billing_state.dart';
|
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||||
import '../widgets/invoices_list_skeleton.dart';
|
import 'package:billing/src/presentation/widgets/invoices_list_skeleton.dart';
|
||||||
import '../widgets/pending_invoices_section.dart';
|
import 'package:billing/src/presentation/widgets/pending_invoices_section.dart';
|
||||||
|
|
||||||
|
/// Page listing all invoices awaiting client approval.
|
||||||
class PendingInvoicesPage extends StatelessWidget {
|
class PendingInvoicesPage extends StatelessWidget {
|
||||||
|
/// Creates a [PendingInvoicesPage].
|
||||||
const PendingInvoicesPage({super.key});
|
const PendingInvoicesPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -44,7 +46,7 @@ class PendingInvoicesPage extends StatelessWidget {
|
|||||||
UiConstants.space5,
|
UiConstants.space5,
|
||||||
UiConstants.space5,
|
UiConstants.space5,
|
||||||
UiConstants.space5,
|
UiConstants.space5,
|
||||||
100, // Bottom padding for scroll clearance
|
100,
|
||||||
),
|
),
|
||||||
itemCount: state.pendingInvoices.length,
|
itemCount: state.pendingInvoices.length,
|
||||||
itemBuilder: (BuildContext context, int index) {
|
itemBuilder: (BuildContext context, int index) {
|
||||||
@@ -87,6 +89,3 @@ class PendingInvoicesPage extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// We need to export the card widget from the section file if we want to reuse it,
|
|
||||||
// or move it to its own file. I'll move it to a shared file or just make it public in the section file.
|
|
||||||
|
|||||||
@@ -6,23 +6,26 @@ import 'package:flutter/material.dart';
|
|||||||
class BillingHeader extends StatelessWidget {
|
class BillingHeader extends StatelessWidget {
|
||||||
/// Creates a [BillingHeader].
|
/// Creates a [BillingHeader].
|
||||||
const BillingHeader({
|
const BillingHeader({
|
||||||
required this.currentBill,
|
required this.currentBillCents,
|
||||||
required this.savings,
|
required this.savingsCents,
|
||||||
required this.onBack,
|
required this.onBack,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The amount of the current bill.
|
/// The amount of the current bill in cents.
|
||||||
final double currentBill;
|
final int currentBillCents;
|
||||||
|
|
||||||
/// The amount saved in the current period.
|
/// The savings amount in cents.
|
||||||
final double savings;
|
final int savingsCents;
|
||||||
|
|
||||||
/// Callback when the back button is pressed.
|
/// Callback when the back button is pressed.
|
||||||
final VoidCallback onBack;
|
final VoidCallback onBack;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final double billDollars = currentBillCents / 100.0;
|
||||||
|
final double savingsDollars = savingsCents / 100.0;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
UiConstants.space5,
|
UiConstants.space5,
|
||||||
@@ -54,10 +57,9 @@ class BillingHeader extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space1),
|
const SizedBox(height: UiConstants.space1),
|
||||||
Text(
|
Text(
|
||||||
'\$${currentBill.toStringAsFixed(2)}',
|
'\$${billDollars.toStringAsFixed(2)}',
|
||||||
style: UiTypography.display1b.copyWith(color: UiColors.white),
|
style: UiTypography.display1b.copyWith(color: UiColors.white),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: UiConstants.space2),
|
const SizedBox(height: UiConstants.space2),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@@ -79,7 +81,7 @@ class BillingHeader extends StatelessWidget {
|
|||||||
const SizedBox(width: UiConstants.space1),
|
const SizedBox(width: UiConstants.space1),
|
||||||
Text(
|
Text(
|
||||||
t.client_billing.saved_amount(
|
t.client_billing.saved_amount(
|
||||||
amount: savings.toStringAsFixed(0),
|
amount: savingsDollars.toStringAsFixed(0),
|
||||||
),
|
),
|
||||||
style: UiTypography.footnote2b.copyWith(
|
style: UiTypography.footnote2b.copyWith(
|
||||||
color: UiColors.foreground,
|
color: UiColors.foreground,
|
||||||
|
|||||||
@@ -5,87 +5,91 @@ import 'package:flutter_bloc/flutter_bloc.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 '../../blocs/shift_completion_review/shift_completion_review_bloc.dart';
|
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||||
import '../../blocs/shift_completion_review/shift_completion_review_event.dart';
|
import 'package:billing/src/presentation/blocs/billing_event.dart';
|
||||||
import '../../blocs/shift_completion_review/shift_completion_review_state.dart';
|
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart';
|
||||||
import '../../blocs/billing_bloc.dart';
|
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_event.dart';
|
||||||
import '../../blocs/billing_event.dart';
|
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_state.dart';
|
||||||
|
|
||||||
|
/// Action buttons (approve / flag) at the bottom of the review page.
|
||||||
class CompletionReviewActions extends StatelessWidget {
|
class CompletionReviewActions extends StatelessWidget {
|
||||||
|
/// Creates a [CompletionReviewActions].
|
||||||
const CompletionReviewActions({required this.invoiceId, super.key});
|
const CompletionReviewActions({required this.invoiceId, super.key});
|
||||||
|
|
||||||
|
/// The invoice ID to act upon.
|
||||||
final String invoiceId;
|
final String invoiceId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider<ShiftCompletionReviewBloc>.value(
|
return BlocProvider<ShiftCompletionReviewBloc>(
|
||||||
value: Modular.get<ShiftCompletionReviewBloc>(),
|
create: (_) => Modular.get<ShiftCompletionReviewBloc>(),
|
||||||
child:
|
child:
|
||||||
BlocConsumer<ShiftCompletionReviewBloc, ShiftCompletionReviewState>(
|
BlocConsumer<ShiftCompletionReviewBloc, ShiftCompletionReviewState>(
|
||||||
listener: (BuildContext context, ShiftCompletionReviewState state) {
|
listener: (BuildContext context, ShiftCompletionReviewState state) {
|
||||||
if (state.status == ShiftCompletionReviewStatus.success) {
|
if (state.status == ShiftCompletionReviewStatus.success) {
|
||||||
final String message = state.message == 'approved'
|
final String message = state.message == 'approved'
|
||||||
? t.client_billing.approved_success
|
? t.client_billing.approved_success
|
||||||
: t.client_billing.flagged_success;
|
: t.client_billing.flagged_success;
|
||||||
final UiSnackbarType type = state.message == 'approved'
|
final UiSnackbarType type = state.message == 'approved'
|
||||||
? UiSnackbarType.success
|
? UiSnackbarType.success
|
||||||
: UiSnackbarType.warning;
|
: UiSnackbarType.warning;
|
||||||
|
|
||||||
UiSnackbar.show(context, message: message, type: type);
|
UiSnackbar.show(context, message: message, type: type);
|
||||||
Modular.get<BillingBloc>().add(const BillingLoadStarted());
|
Modular.get<BillingBloc>().add(const BillingLoadStarted());
|
||||||
Modular.to.toAwaitingApproval();
|
Modular.to.toAwaitingApproval();
|
||||||
} else if (state.status == ShiftCompletionReviewStatus.failure) {
|
} else if (state.status == ShiftCompletionReviewStatus.failure) {
|
||||||
UiSnackbar.show(
|
UiSnackbar.show(
|
||||||
context,
|
context,
|
||||||
message: state.errorMessage ?? t.errors.generic.unknown,
|
message: state.errorMessage ?? t.errors.generic.unknown,
|
||||||
type: UiSnackbarType.error,
|
type: UiSnackbarType.error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
builder: (BuildContext context, ShiftCompletionReviewState state) {
|
builder: (BuildContext context, ShiftCompletionReviewState state) {
|
||||||
final bool isLoading =
|
final bool isLoading =
|
||||||
state.status == ShiftCompletionReviewStatus.loading;
|
state.status == ShiftCompletionReviewStatus.loading;
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
spacing: UiConstants.space2,
|
spacing: UiConstants.space2,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Expanded(
|
Expanded(
|
||||||
child: UiButton.secondary(
|
child: UiButton.secondary(
|
||||||
text: t.client_billing.actions.flag_review,
|
text: t.client_billing.actions.flag_review,
|
||||||
leadingIcon: UiIcons.warning,
|
leadingIcon: UiIcons.warning,
|
||||||
onPressed: isLoading
|
onPressed: isLoading
|
||||||
? null
|
? null
|
||||||
: () => _showFlagDialog(context, state),
|
: () => _showFlagDialog(context, state),
|
||||||
size: UiButtonSize.large,
|
size: UiButtonSize.large,
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: UiColors.destructive,
|
foregroundColor: UiColors.destructive,
|
||||||
side: BorderSide.none,
|
side: BorderSide.none,
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
),
|
||||||
child: UiButton.primary(
|
),
|
||||||
text: t.client_billing.actions.approve_pay,
|
Expanded(
|
||||||
leadingIcon: isLoading ? null : UiIcons.checkCircle,
|
child: UiButton.primary(
|
||||||
isLoading: isLoading,
|
text: t.client_billing.actions.approve_pay,
|
||||||
onPressed: isLoading
|
leadingIcon: isLoading ? null : UiIcons.checkCircle,
|
||||||
? null
|
isLoading: isLoading,
|
||||||
: () {
|
onPressed: isLoading
|
||||||
BlocProvider.of<ShiftCompletionReviewBloc>(
|
? null
|
||||||
context,
|
: () {
|
||||||
).add(ShiftCompletionReviewApproved(invoiceId));
|
BlocProvider.of<ShiftCompletionReviewBloc>(
|
||||||
},
|
context,
|
||||||
size: UiButtonSize.large,
|
).add(ShiftCompletionReviewApproved(invoiceId));
|
||||||
),
|
},
|
||||||
),
|
size: UiButtonSize.large,
|
||||||
],
|
),
|
||||||
);
|
),
|
||||||
},
|
],
|
||||||
),
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showFlagDialog(BuildContext context, ShiftCompletionReviewState state) {
|
void _showFlagDialog(
|
||||||
|
BuildContext context, ShiftCompletionReviewState state) {
|
||||||
final TextEditingController controller = TextEditingController();
|
final TextEditingController controller = TextEditingController();
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -2,15 +2,18 @@ import 'package:core_localization/core_localization.dart';
|
|||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../models/billing_invoice_model.dart';
|
/// Displays the total invoice amount on the review page.
|
||||||
|
|
||||||
class CompletionReviewAmount extends StatelessWidget {
|
class CompletionReviewAmount extends StatelessWidget {
|
||||||
const CompletionReviewAmount({required this.invoice, super.key});
|
/// Creates a [CompletionReviewAmount].
|
||||||
|
const CompletionReviewAmount({required this.amountCents, super.key});
|
||||||
|
|
||||||
final BillingInvoice invoice;
|
/// The invoice total in cents.
|
||||||
|
final int amountCents;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final double amountDollars = amountCents / 100.0;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(UiConstants.space6),
|
padding: const EdgeInsets.all(UiConstants.space6),
|
||||||
@@ -27,13 +30,9 @@ class CompletionReviewAmount extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space1),
|
const SizedBox(height: UiConstants.space1),
|
||||||
Text(
|
Text(
|
||||||
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
'\$${amountDollars.toStringAsFixed(2)}',
|
||||||
style: UiTypography.headline1b.textPrimary.copyWith(fontSize: 40),
|
style: UiTypography.headline1b.textPrimary.copyWith(fontSize: 40),
|
||||||
),
|
),
|
||||||
Text(
|
|
||||||
'${invoice.totalHours.toStringAsFixed(1)} ${t.client_billing.hours_suffix} • \$${(invoice.totalAmount / (invoice.totalHours > 0.1 ? invoice.totalHours : 1)).toStringAsFixed(2)}${t.client_billing.avg_rate_suffix}',
|
|
||||||
style: UiTypography.footnote2b.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../models/billing_invoice_model.dart';
|
/// Displays invoice metadata (date, vendor) on the review page.
|
||||||
|
|
||||||
class CompletionReviewInfo extends StatelessWidget {
|
class CompletionReviewInfo extends StatelessWidget {
|
||||||
const CompletionReviewInfo({required this.invoice, super.key});
|
/// Creates a [CompletionReviewInfo].
|
||||||
|
const CompletionReviewInfo({
|
||||||
|
required this.dateLabel,
|
||||||
|
this.vendorName,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
final BillingInvoice invoice;
|
/// Formatted date string.
|
||||||
|
final String dateLabel;
|
||||||
|
|
||||||
|
/// Vendor name, if available.
|
||||||
|
final String? vendorName;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -14,12 +22,9 @@ class CompletionReviewInfo extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
spacing: UiConstants.space1,
|
spacing: UiConstants.space1,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
_buildInfoRow(UiIcons.calendar, invoice.date),
|
_buildInfoRow(UiIcons.calendar, dateLabel),
|
||||||
_buildInfoRow(
|
if (vendorName != null)
|
||||||
UiIcons.clock,
|
_buildInfoRow(UiIcons.building, vendorName!),
|
||||||
'${invoice.startTime ?? "--"} - ${invoice.endTime ?? "--"}',
|
|
||||||
),
|
|
||||||
_buildInfoRow(UiIcons.mapPin, invoice.locationAddress),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,126 +1,18 @@
|
|||||||
import 'package:core_localization/core_localization.dart';
|
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../models/billing_invoice_model.dart';
|
/// Card showing a single worker's details in the completion review.
|
||||||
|
///
|
||||||
|
/// Currently unused -- the V2 Invoice entity does not include per-worker
|
||||||
|
/// breakdown data. This widget is retained as a placeholder for when the
|
||||||
|
/// backend adds worker-level invoice detail endpoints.
|
||||||
class CompletionReviewWorkerCard extends StatelessWidget {
|
class CompletionReviewWorkerCard extends StatelessWidget {
|
||||||
const CompletionReviewWorkerCard({required this.worker, super.key});
|
/// Creates a [CompletionReviewWorkerCard].
|
||||||
|
const CompletionReviewWorkerCard({super.key});
|
||||||
final BillingWorkerRecord worker;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
// Placeholder until V2 API provides worker-level invoice data.
|
||||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
return const SizedBox.shrink();
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: UiConstants.radiusLg,
|
|
||||||
border: Border.all(color: UiColors.border.withValues(alpha: 0.5)),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: <Widget>[
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
CircleAvatar(
|
|
||||||
radius: 20,
|
|
||||||
backgroundColor: UiColors.bgSecondary,
|
|
||||||
backgroundImage: worker.workerAvatarUrl != null
|
|
||||||
? NetworkImage(worker.workerAvatarUrl!)
|
|
||||||
: null,
|
|
||||||
child: worker.workerAvatarUrl == null
|
|
||||||
? const Icon(
|
|
||||||
UiIcons.user,
|
|
||||||
size: 20,
|
|
||||||
color: UiColors.iconSecondary,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
const SizedBox(width: UiConstants.space3),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
Text(
|
|
||||||
worker.workerName,
|
|
||||||
style: UiTypography.body1b.textPrimary,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
worker.roleName,
|
|
||||||
style: UiTypography.footnote2r.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: <Widget>[
|
|
||||||
Text(
|
|
||||||
'\$${worker.totalAmount.toStringAsFixed(2)}',
|
|
||||||
style: UiTypography.body1b.textPrimary,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'${worker.hours}h x \$${worker.rate.toStringAsFixed(2)}/hr',
|
|
||||||
style: UiTypography.footnote2r.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space4),
|
|
||||||
Row(
|
|
||||||
children: <Widget>[
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10,
|
|
||||||
vertical: 4,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: UiConstants.radiusMd,
|
|
||||||
border: Border.all(color: UiColors.border),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'${worker.startTime} - ${worker.endTime}',
|
|
||||||
style: UiTypography.footnote2b.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: UiConstants.space2),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10,
|
|
||||||
vertical: 4,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: UiConstants.radiusMd,
|
|
||||||
border: Border.all(color: UiColors.border),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: <Widget>[
|
|
||||||
const Icon(
|
|
||||||
UiIcons.coffee,
|
|
||||||
size: 12,
|
|
||||||
color: UiColors.iconSecondary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
'${worker.breakMinutes} ${t.client_billing.workers_tab.min_break}',
|
|
||||||
style: UiTypography.footnote2r.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
UiIconButton.secondary(icon: UiIcons.edit, onTap: () {}),
|
|
||||||
const SizedBox(width: UiConstants.space2),
|
|
||||||
UiIconButton.secondary(icon: UiIcons.warning, onTap: () {}),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import 'package:core_localization/core_localization.dart';
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../models/billing_invoice_model.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
/// Section showing the history of paid invoices.
|
/// Section showing the history of paid invoices.
|
||||||
class InvoiceHistorySection extends StatelessWidget {
|
class InvoiceHistorySection extends StatelessWidget {
|
||||||
@@ -9,7 +10,7 @@ class InvoiceHistorySection extends StatelessWidget {
|
|||||||
const InvoiceHistorySection({required this.invoices, super.key});
|
const InvoiceHistorySection({required this.invoices, super.key});
|
||||||
|
|
||||||
/// The list of historical invoices.
|
/// The list of historical invoices.
|
||||||
final List<BillingInvoice> invoices;
|
final List<Invoice> invoices;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -36,10 +37,10 @@ class InvoiceHistorySection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: invoices.asMap().entries.map((
|
children: invoices.asMap().entries.map((
|
||||||
MapEntry<int, BillingInvoice> entry,
|
MapEntry<int, Invoice> entry,
|
||||||
) {
|
) {
|
||||||
final int index = entry.key;
|
final int index = entry.key;
|
||||||
final BillingInvoice invoice = entry.value;
|
final Invoice invoice = entry.value;
|
||||||
return Column(
|
return Column(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
if (index > 0)
|
if (index > 0)
|
||||||
@@ -58,10 +59,18 @@ class InvoiceHistorySection extends StatelessWidget {
|
|||||||
class _InvoiceItem extends StatelessWidget {
|
class _InvoiceItem extends StatelessWidget {
|
||||||
const _InvoiceItem({required this.invoice});
|
const _InvoiceItem({required this.invoice});
|
||||||
|
|
||||||
final BillingInvoice invoice;
|
final Invoice invoice;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final DateFormat formatter = DateFormat('MMM d, yyyy');
|
||||||
|
final String dateLabel = invoice.paymentDate != null
|
||||||
|
? formatter.format(invoice.paymentDate!)
|
||||||
|
: invoice.dueDate != null
|
||||||
|
? formatter.format(invoice.dueDate!)
|
||||||
|
: 'N/A';
|
||||||
|
final double amountDollars = invoice.amountCents / 100.0;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: UiConstants.space4,
|
horizontal: UiConstants.space4,
|
||||||
@@ -86,11 +95,11 @@ class _InvoiceItem extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(invoice.title, style: UiTypography.body1r.textPrimary),
|
|
||||||
Text(
|
Text(
|
||||||
invoice.date,
|
invoice.invoiceNumber,
|
||||||
style: UiTypography.footnote2r.textSecondary,
|
style: UiTypography.body1r.textPrimary,
|
||||||
),
|
),
|
||||||
|
Text(dateLabel, style: UiTypography.footnote2r.textSecondary),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -98,7 +107,7 @@ class _InvoiceItem extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text(
|
||||||
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
'\$${amountDollars.toStringAsFixed(2)}',
|
||||||
style: UiTypography.body1b.textPrimary.copyWith(fontSize: 15),
|
style: UiTypography.body1b.textPrimary.copyWith(fontSize: 15),
|
||||||
),
|
),
|
||||||
_StatusBadge(status: invoice.status),
|
_StatusBadge(status: invoice.status),
|
||||||
@@ -113,11 +122,11 @@ class _InvoiceItem extends StatelessWidget {
|
|||||||
class _StatusBadge extends StatelessWidget {
|
class _StatusBadge extends StatelessWidget {
|
||||||
const _StatusBadge({required this.status});
|
const _StatusBadge({required this.status});
|
||||||
|
|
||||||
final String status;
|
final InvoiceStatus status;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final bool isPaid = status.toUpperCase() == 'PAID';
|
final bool isPaid = status == InvoiceStatus.paid;
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: UiConstants.space1 + 2,
|
horizontal: UiConstants.space1 + 2,
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import 'package:design_system/design_system.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../blocs/billing_bloc.dart';
|
|
||||||
import '../blocs/billing_state.dart';
|
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||||
|
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||||
|
|
||||||
/// Card showing the current payment method.
|
/// Card showing the current payment method.
|
||||||
class PaymentMethodCard extends StatelessWidget {
|
class PaymentMethodCard extends StatelessWidget {
|
||||||
@@ -15,8 +16,8 @@ class PaymentMethodCard extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<BillingBloc, BillingState>(
|
return BlocBuilder<BillingBloc, BillingState>(
|
||||||
builder: (BuildContext context, BillingState state) {
|
builder: (BuildContext context, BillingState state) {
|
||||||
final List<BusinessBankAccount> accounts = state.bankAccounts;
|
final List<BillingAccount> accounts = state.bankAccounts;
|
||||||
final BusinessBankAccount? account =
|
final BillingAccount? account =
|
||||||
accounts.isNotEmpty ? accounts.first : null;
|
accounts.isNotEmpty ? accounts.first : null;
|
||||||
|
|
||||||
if (account == null) {
|
if (account == null) {
|
||||||
@@ -24,11 +25,10 @@ class PaymentMethodCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final String bankLabel =
|
final String bankLabel =
|
||||||
account.bankName.isNotEmpty == true ? account.bankName : '----';
|
account.bankName.isNotEmpty ? account.bankName : '----';
|
||||||
final String last4 =
|
final String last4 =
|
||||||
account.last4.isNotEmpty == true ? account.last4 : '----';
|
account.last4?.isNotEmpty == true ? account.last4! : '----';
|
||||||
final bool isPrimary = account.isPrimary;
|
final bool isPrimary = account.isPrimary;
|
||||||
final String expiryLabel = _formatExpiry(account.expiryTime);
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
padding: const EdgeInsets.all(UiConstants.space4),
|
||||||
@@ -87,11 +87,11 @@ class PaymentMethodCard extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text(
|
||||||
'•••• $last4',
|
'\u2022\u2022\u2022\u2022 $last4',
|
||||||
style: UiTypography.body2b.textPrimary,
|
style: UiTypography.body2b.textPrimary,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
t.client_billing.expires(date: expiryLabel),
|
account.accountType.name.toUpperCase(),
|
||||||
style: UiTypography.footnote2r.textSecondary,
|
style: UiTypography.footnote2r.textSecondary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -121,13 +121,4 @@ class PaymentMethodCard extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatExpiry(DateTime? expiryTime) {
|
|
||||||
if (expiryTime == null) {
|
|
||||||
return 'N/A';
|
|
||||||
}
|
|
||||||
final String month = expiryTime.month.toString().padLeft(2, '0');
|
|
||||||
final String year = (expiryTime.year % 100).toString().padLeft(2, '0');
|
|
||||||
return '$month/$year';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import 'package:core_localization/core_localization.dart';
|
|||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.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:intl/intl.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../models/billing_invoice_model.dart';
|
|
||||||
|
|
||||||
/// Section showing a banner for invoices awaiting approval.
|
/// Section showing a banner for invoices awaiting approval.
|
||||||
class PendingInvoicesSection extends StatelessWidget {
|
class PendingInvoicesSection extends StatelessWidget {
|
||||||
@@ -12,7 +12,7 @@ class PendingInvoicesSection extends StatelessWidget {
|
|||||||
const PendingInvoicesSection({required this.invoices, super.key});
|
const PendingInvoicesSection({required this.invoices, super.key});
|
||||||
|
|
||||||
/// The list of pending invoices.
|
/// The list of pending invoices.
|
||||||
final List<BillingInvoice> invoices;
|
final List<Invoice> invoices;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -93,10 +93,17 @@ class PendingInvoiceCard extends StatelessWidget {
|
|||||||
/// Creates a [PendingInvoiceCard].
|
/// Creates a [PendingInvoiceCard].
|
||||||
const PendingInvoiceCard({required this.invoice, super.key});
|
const PendingInvoiceCard({required this.invoice, super.key});
|
||||||
|
|
||||||
final BillingInvoice invoice;
|
/// The invoice to display.
|
||||||
|
final Invoice invoice;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final DateFormat formatter = DateFormat('EEEE, MMMM d');
|
||||||
|
final String dateLabel = invoice.dueDate != null
|
||||||
|
? formatter.format(invoice.dueDate!)
|
||||||
|
: 'N/A';
|
||||||
|
final double amountDollars = invoice.amountCents / 100.0;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: UiColors.white,
|
color: UiColors.white,
|
||||||
@@ -108,42 +115,33 @@ class PendingInvoiceCard extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(invoice.title, style: UiTypography.headline4b.textPrimary),
|
Text(
|
||||||
|
invoice.invoiceNumber,
|
||||||
|
style: UiTypography.headline4b.textPrimary,
|
||||||
|
),
|
||||||
const SizedBox(height: UiConstants.space3),
|
const SizedBox(height: UiConstants.space3),
|
||||||
Row(
|
if (invoice.vendorName != null) ...<Widget>[
|
||||||
children: <Widget>[
|
Row(
|
||||||
const Icon(
|
children: <Widget>[
|
||||||
UiIcons.mapPin,
|
const Icon(
|
||||||
size: 16,
|
UiIcons.building,
|
||||||
color: UiColors.iconSecondary,
|
size: 16,
|
||||||
),
|
color: UiColors.iconSecondary,
|
||||||
const SizedBox(width: UiConstants.space2),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
invoice.locationAddress,
|
|
||||||
style: UiTypography.footnote2r.textSecondary,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: UiConstants.space2),
|
||||||
],
|
Expanded(
|
||||||
),
|
child: Text(
|
||||||
const SizedBox(height: UiConstants.space2),
|
invoice.vendorName!,
|
||||||
Row(
|
style: UiTypography.footnote2r.textSecondary,
|
||||||
children: <Widget>[
|
maxLines: 1,
|
||||||
Text(
|
overflow: TextOverflow.ellipsis,
|
||||||
invoice.clientName,
|
),
|
||||||
style: UiTypography.footnote2r.textSecondary,
|
),
|
||||||
),
|
],
|
||||||
const SizedBox(width: UiConstants.space2),
|
),
|
||||||
Text('•', style: UiTypography.footnote2r.textInactive),
|
const SizedBox(height: UiConstants.space2),
|
||||||
const SizedBox(width: UiConstants.space2),
|
],
|
||||||
Text(
|
Text(dateLabel, style: UiTypography.footnote2r.textSecondary),
|
||||||
invoice.date,
|
|
||||||
style: UiTypography.footnote2r.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space3),
|
const SizedBox(height: UiConstants.space3),
|
||||||
Row(
|
Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
@@ -157,7 +155,7 @@ class PendingInvoiceCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space2),
|
const SizedBox(width: UiConstants.space2),
|
||||||
Text(
|
Text(
|
||||||
t.client_billing.pending_badge.toUpperCase(),
|
invoice.status.value.toUpperCase(),
|
||||||
style: UiTypography.titleUppercase4b.copyWith(
|
style: UiTypography.titleUppercase4b.copyWith(
|
||||||
color: UiColors.textWarning,
|
color: UiColors.textWarning,
|
||||||
),
|
),
|
||||||
@@ -168,40 +166,10 @@ class PendingInvoiceCard extends StatelessWidget {
|
|||||||
const Divider(height: 1, color: UiColors.border),
|
const Divider(height: 1, color: UiColors.border),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: UiConstants.space4),
|
padding: const EdgeInsets.symmetric(vertical: UiConstants.space4),
|
||||||
child: Row(
|
child: _buildStatItem(
|
||||||
children: <Widget>[
|
UiIcons.dollar,
|
||||||
Expanded(
|
'\$${amountDollars.toStringAsFixed(2)}',
|
||||||
child: _buildStatItem(
|
t.client_billing.stats.total,
|
||||||
UiIcons.dollar,
|
|
||||||
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
|
||||||
t.client_billing.stats.total,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
width: 1,
|
|
||||||
height: 32,
|
|
||||||
color: UiColors.border.withValues(alpha: 0.3),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: _buildStatItem(
|
|
||||||
UiIcons.users,
|
|
||||||
'${invoice.workersCount}',
|
|
||||||
t.client_billing.stats.workers,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
width: 1,
|
|
||||||
height: 32,
|
|
||||||
color: UiColors.border.withValues(alpha: 0.3),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: _buildStatItem(
|
|
||||||
UiIcons.clock,
|
|
||||||
invoice.totalHours.toStringAsFixed(1),
|
|
||||||
t.client_billing.stats.hrs,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1, color: UiColors.border),
|
const Divider(height: 1, color: UiColors.border),
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import 'package:design_system/design_system.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../blocs/billing_bloc.dart';
|
|
||||||
import '../blocs/billing_state.dart';
|
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||||
import '../blocs/billing_event.dart';
|
import 'package:billing/src/presentation/blocs/billing_event.dart';
|
||||||
import '../models/spending_breakdown_model.dart';
|
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||||
|
|
||||||
/// Card showing the spending breakdown for the current period.
|
/// Card showing the spending breakdown for the current period.
|
||||||
class SpendingBreakdownCard extends StatefulWidget {
|
class SpendingBreakdownCard extends StatefulWidget {
|
||||||
@@ -37,10 +37,7 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<BillingBloc, BillingState>(
|
return BlocBuilder<BillingBloc, BillingState>(
|
||||||
builder: (BuildContext context, BillingState state) {
|
builder: (BuildContext context, BillingState state) {
|
||||||
final double total = state.spendingBreakdown.fold(
|
final double totalDollars = state.spendTotalCents / 100.0;
|
||||||
0.0,
|
|
||||||
(double sum, SpendingBreakdownItem item) => sum + item.amount,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
padding: const EdgeInsets.all(UiConstants.space4),
|
||||||
@@ -97,11 +94,12 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
|||||||
),
|
),
|
||||||
dividerColor: UiColors.transparent,
|
dividerColor: UiColors.transparent,
|
||||||
onTap: (int index) {
|
onTap: (int index) {
|
||||||
final BillingPeriod period =
|
final BillingPeriodTab tab = index == 0
|
||||||
index == 0 ? BillingPeriod.week : BillingPeriod.month;
|
? BillingPeriodTab.week
|
||||||
ReadContext(context).read<BillingBloc>().add(
|
: BillingPeriodTab.month;
|
||||||
BillingPeriodChanged(period),
|
ReadContext(context)
|
||||||
);
|
.read<BillingBloc>()
|
||||||
|
.add(BillingPeriodChanged(tab));
|
||||||
},
|
},
|
||||||
tabs: <Widget>[
|
tabs: <Widget>[
|
||||||
Tab(text: t.client_billing.week),
|
Tab(text: t.client_billing.week),
|
||||||
@@ -112,8 +110,8 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
...state.spendingBreakdown.map(
|
...state.spendBreakdown.map(
|
||||||
(SpendingBreakdownItem item) => _buildBreakdownRow(item),
|
(SpendItem item) => _buildBreakdownRow(item),
|
||||||
),
|
),
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: UiConstants.space2),
|
padding: EdgeInsets.symmetric(vertical: UiConstants.space2),
|
||||||
@@ -127,7 +125,7 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
|||||||
style: UiTypography.body2b.textPrimary,
|
style: UiTypography.body2b.textPrimary,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'\$${total.toStringAsFixed(2)}',
|
'\$${totalDollars.toStringAsFixed(2)}',
|
||||||
style: UiTypography.body2b.textPrimary,
|
style: UiTypography.body2b.textPrimary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -139,7 +137,8 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBreakdownRow(SpendingBreakdownItem item) {
|
Widget _buildBreakdownRow(SpendItem item) {
|
||||||
|
final double amountDollars = item.amountCents / 100.0;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: UiConstants.space2),
|
padding: const EdgeInsets.only(bottom: UiConstants.space2),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -151,14 +150,14 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(item.category, style: UiTypography.body2r.textPrimary),
|
Text(item.category, style: UiTypography.body2r.textPrimary),
|
||||||
Text(
|
Text(
|
||||||
t.client_billing.hours(count: item.hours),
|
'${item.percentage.toStringAsFixed(1)}%',
|
||||||
style: UiTypography.footnote2r.textSecondary,
|
style: UiTypography.footnote2r.textSecondary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'\$${item.amount.toStringAsFixed(2)}',
|
'\$${amountDollars.toStringAsFixed(2)}',
|
||||||
style: UiTypography.body2m.textPrimary,
|
style: UiTypography.body2m.textPrimary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -25,12 +25,10 @@ dependencies:
|
|||||||
path: ../../../domain
|
path: ../../../domain
|
||||||
krow_core:
|
krow_core:
|
||||||
path: ../../../core
|
path: ../../../core
|
||||||
krow_data_connect:
|
|
||||||
path: ../../../data_connect
|
|
||||||
|
|
||||||
# UI
|
# UI
|
||||||
intl: ^0.20.0
|
intl: ^0.20.0
|
||||||
firebase_data_connect: ^0.2.2+1
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
@@ -1,26 +1,35 @@
|
|||||||
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';
|
||||||
import 'data/repositories_impl/coverage_repository_impl.dart';
|
|
||||||
import 'domain/repositories/coverage_repository.dart';
|
import 'package:client_coverage/src/data/repositories_impl/coverage_repository_impl.dart';
|
||||||
import 'domain/usecases/get_coverage_stats_usecase.dart';
|
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||||
import 'domain/usecases/get_shifts_for_date_usecase.dart';
|
import 'package:client_coverage/src/domain/usecases/cancel_late_worker_usecase.dart';
|
||||||
import 'presentation/blocs/coverage_bloc.dart';
|
import 'package:client_coverage/src/domain/usecases/get_coverage_stats_usecase.dart';
|
||||||
import 'presentation/pages/coverage_page.dart';
|
import 'package:client_coverage/src/domain/usecases/get_shifts_for_date_usecase.dart';
|
||||||
|
import 'package:client_coverage/src/domain/usecases/submit_worker_review_usecase.dart';
|
||||||
|
import 'package:client_coverage/src/presentation/blocs/coverage_bloc.dart';
|
||||||
|
import 'package:client_coverage/src/presentation/pages/coverage_page.dart';
|
||||||
|
|
||||||
/// Modular module for the coverage feature.
|
/// Modular module for the coverage feature.
|
||||||
|
///
|
||||||
|
/// Uses the V2 REST API via [BaseApiService] for all backend access.
|
||||||
class CoverageModule extends Module {
|
class CoverageModule extends Module {
|
||||||
@override
|
@override
|
||||||
List<Module> get imports => <Module>[DataConnectModule()];
|
List<Module> get imports => <Module>[CoreModule()];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
// Repositories
|
// Repositories
|
||||||
i.addLazySingleton<CoverageRepository>(CoverageRepositoryImpl.new);
|
i.addLazySingleton<CoverageRepository>(
|
||||||
|
() => CoverageRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||||
|
);
|
||||||
|
|
||||||
// Use Cases
|
// Use Cases
|
||||||
i.addLazySingleton(GetShiftsForDateUseCase.new);
|
i.addLazySingleton(GetShiftsForDateUseCase.new);
|
||||||
i.addLazySingleton(GetCoverageStatsUseCase.new);
|
i.addLazySingleton(GetCoverageStatsUseCase.new);
|
||||||
|
i.addLazySingleton(SubmitWorkerReviewUseCase.new);
|
||||||
|
i.addLazySingleton(CancelLateWorkerUseCase.new);
|
||||||
|
|
||||||
// BLoCs
|
// BLoCs
|
||||||
i.addLazySingleton<CoverageBloc>(CoverageBloc.new);
|
i.addLazySingleton<CoverageBloc>(CoverageBloc.new);
|
||||||
@@ -28,7 +37,9 @@ class CoverageModule extends Module {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void routes(RouteManager r) {
|
void routes(RouteManager r) {
|
||||||
r.child(ClientPaths.childRoute(ClientPaths.coverage, ClientPaths.coverage),
|
r.child(
|
||||||
child: (_) => const CoveragePage());
|
ClientPaths.childRoute(ClientPaths.coverage, ClientPaths.coverage),
|
||||||
|
child: (_) => const CoveragePage(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user