diff --git a/.claude/agent-memory/mobile-builder/MEMORY.md b/.claude/agent-memory/mobile-builder/MEMORY.md index 77531b1b..1a9b605f 100644 --- a/.claude/agent-memory/mobile-builder/MEMORY.md +++ b/.claude/agent-memory/mobile-builder/MEMORY.md @@ -52,4 +52,175 @@ - BenefitsOverviewPage also has CircularProgressIndicator (not shimmer-ified yet) - ShiftDetailsPage has a dialog-level spinner in the "applying" dialog -- this is intentional, not a page loading state - Hub details/edit pages use CircularProgressIndicator as action overlays (save/delete) -- keep as-is, not initial load -- Client home page has no loading spinner; it renders with default empty dashboard data +- Client home page uses shimmer skeleton during loading (ClientHomePageSkeleton + ClientHomeHeaderSkeleton) + +## V2 API Migration Patterns +- `BaseApiService` is registered in `CoreModule` as a lazy singleton (injected as `i.get()`) +- `BaseApiService` 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`, `List`, `List` +- 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`, `List`, `List`, `List`, `List` +- 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 with `bucket`/`amountCents`), `breakdown` (List with `category`/`amountCents`/`percentage`) +- Coverage: `CoverageReport` uses `averageCoveragePercentage`, `filledWorkers`, `neededWorkers`, `chart` (List with `day`/`needed`/`filled`/`coveragePercentage`) +- Forecast: `ForecastReport` uses `forecastSpendCents`, `averageWeeklySpendCents`, `totalWorkerHours`, `weeks` (List 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 with `staffId`/`staffName`/`incidentCount`/`riskStatus`/`incidents`) +- Module injects `BaseApiService` via `i.get()` -- 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) +- 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` 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()` -- 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 diff --git a/.claude/agent-memory/mobile-builder/project_v2_profile_migration.md b/.claude/agent-memory/mobile-builder/project_v2_profile_migration.md new file mode 100644 index 00000000..580887e0 --- /dev/null +++ b/.claude/agent-memory/mobile-builder/project_v2_profile_migration.md @@ -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()`) +- Modules import `CoreModule()` instead of `DataConnectModule()` diff --git a/.claude/agent-memory/mobile-qa-analyst/MEMORY.md b/.claude/agent-memory/mobile-qa-analyst/MEMORY.md index 9bfe7a71..1f07441f 100644 --- a/.claude/agent-memory/mobile-qa-analyst/MEMORY.md +++ b/.claude/agent-memory/mobile-qa-analyst/MEMORY.md @@ -2,3 +2,5 @@ ## Project Context - [project_clock_in_feature_issues.md](project_clock_in_feature_issues.md) — Critical bugs in staff clock_in feature: BLoC lifecycle leak, stale geofence override, dead lunch break data, non-functional date selector +- [project_client_v2_migration_issues.md](project_client_v2_migration_issues.md) — Critical bugs in client app V2 migration: reports BLoCs missing BlocErrorHandler, firebase_auth in features, no executeProtected, hardcoded strings, double sign-in +- [project_v2_migration_qa_findings.md](project_v2_migration_qa_findings.md) — Critical bugs in staff app V2 migration: cold-start session logout, geofence bypass, auth navigation race, token expiry inversion, shifts response shape mismatch diff --git a/.claude/agent-memory/mobile-qa-analyst/project_client_v2_migration_issues.md b/.claude/agent-memory/mobile-qa-analyst/project_client_v2_migration_issues.md new file mode 100644 index 00000000..69ad49a1 --- /dev/null +++ b/.claude/agent-memory/mobile-qa-analyst/project_client_v2_migration_issues.md @@ -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. diff --git a/.claude/agent-memory/mobile-qa-analyst/project_v2_migration_qa_findings.md b/.claude/agent-memory/mobile-qa-analyst/project_v2_migration_qa_findings.md new file mode 100644 index 00000000..1579df68 --- /dev/null +++ b/.claude/agent-memory/mobile-qa-analyst/project_v2_migration_qa_findings.md @@ -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` 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. diff --git a/.claude/agents/mobile-architecture-reviewer.md b/.claude/agents/mobile-architecture-reviewer.md index c0c7b2a4..d2e3ae99 100644 --- a/.claude/agents/mobile-architecture-reviewer.md +++ b/.claude/agents/mobile-architecture-reviewer.md @@ -26,6 +26,7 @@ and load any additional skills as needed for specific review challenges. - Ensuring business logic lives in use cases (not BLoCs/widgets) - Flagging design system violations (hardcoded colors, TextStyle, spacing, icons) - Validating BLoC pattern usage (SessionHandlerMixin, BlocErrorHandler, singleton registration) +- **Verifying every feature module that uses `BaseApiService` (or any CoreModule binding) declares `List get imports => [CoreModule()];`** — missing this causes `UnregisteredInstance` runtime crashes - Ensuring safe navigation extensions are used (no direct Navigator usage) - Verifying test coverage for business logic - Checking documentation on public APIs @@ -205,6 +206,7 @@ Produce a structured report in this exact format: |------|--------|---------| | Design System | ✅/❌ | [details] | | Architecture Boundaries | ✅/❌ | [details] | +| DI / CoreModule Imports | ✅/❌ | [Every module using BaseApiService must import CoreModule] | | State Management | ✅/❌ | [details] | | Navigation | ✅/❌ | [details] | | Testing Coverage | ✅/❌ | [estimated %] | diff --git a/.claude/agents/mobile-builder.md b/.claude/agents/mobile-builder.md index 55504099..18f2fe1c 100644 --- a/.claude/agents/mobile-builder.md +++ b/.claude/agents/mobile-builder.md @@ -43,10 +43,11 @@ If any of these files are missing or unreadable, notify the user before proceedi - Import icon libraries directly — use `UiIcons` - Use `Navigator.push` directly — use Modular safe extensions - Navigate without home fallback -- Call DataConnect directly from BLoCs — go through repository +- Call API directly from BLoCs — go through repository - Skip tests for business logic ### ALWAYS: +- **Add `CoreModule` import to every feature module that uses `BaseApiService` or any other `CoreModule` binding** (e.g., `FileUploadService`, `DeviceFileUploadService`, `CameraService`). Without this, the DI container throws `UnregisteredInstance` at runtime. Add: `@override List get imports => [CoreModule()];` - **Use `package:` imports everywhere inside `lib/`** for consistency and robustness. Use relative imports only in `test/` and `bin/` directories. Example: `import 'package:staff_clock_in/src/presentation/bloc/clock_in/clock_in_bloc.dart';` not `import '../bloc/clock_in/clock_in_bloc.dart';` - Place reusable utility functions (math, geo, formatting, etc.) in `apps/mobile/packages/core/lib/src/utils/` and export from `core.dart` — never keep them as private methods in feature packages - Use feature-first packaging: `domain/`, `data/`, `presentation/` @@ -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: ### 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` - 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 @@ -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/` - **Feature-level domain layer is optional** — only add `domain/` when the feature has use cases, validators, or feature-specific interfaces - **Simple features** (read-only, no business logic) = just `data/` + `presentation/` -- Do NOT import from `packages/data_connect/` — it is deprecated +- Do NOT import from `packages/data_connect/` — deleted ### Status & Type Enums All status/type fields from the V2 API must use Dart enums, NOT raw strings. Parse at the `fromJson` boundary with a safe fallback: @@ -169,7 +170,7 @@ Follow these steps in order for every feature implementation: - Create barrel file exporting the domain public API ### 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()` - Map errors to domain `Failure` types - 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: - Existing feature patterns and conventions in the codebase - Session store usage patterns and available stores -- DataConnect query/mutation names and their locations +- V2 API endpoint patterns and response shapes - Design token values and component patterns actually in use - Module registration patterns and route conventions - Recurring issues found during `melos analyze` diff --git a/.claude/agents/mobile-qa-analyst.md b/.claude/agents/mobile-qa-analyst.md index f21ca49b..304ff279 100644 --- a/.claude/agents/mobile-qa-analyst.md +++ b/.claude/agents/mobile-qa-analyst.md @@ -22,8 +22,8 @@ You are working within a Flutter monorepo (where features are organized into pac - **State Management**: Flutter BLoC/Cubit. BLoCs registered with `i.add()` (transient), never `i.addSingleton()`. `BlocProvider.value()` for shared BLoCs. - **DI & Routing**: Flutter Modular. Safe navigation via `safeNavigate()`, `safePush()`, `popSafe()`. Never `Navigator.push()` directly (except when popping a dialog). - **Error Handling**: `BlocErrorHandler` mixin with `_safeEmit()` to prevent StateError on disposed BLoCs. -- **Backend**: Firebase Data Connect through `data_connect` package Connectors. `_service.run(() => connector.().execute())` for auth/token management. -- **Session Management**: `SessionHandlerMixin` + `SessionListener` widget. +- **Backend**: V2 REST API via `ApiService` with `V2ApiEndpoints`. Domain entities have `fromJson`/`toJson`. Status fields use typed enums from `krow_domain`. Money values are `int` in cents. +- **Session Management**: `V2SessionService` + `SessionHandlerMixin` + `SessionListener` widget. Session stores (`StaffSessionStore`, `ClientSessionStore`) in `core`. - **Localization**: Slang (`t.section.key`), not `context.strings`. - **Design System**: Tokens from `UiColors`, `UiTypography`, `UiConstants`. No hardcoded values. @@ -55,7 +55,7 @@ Detect potential bugs including: - **API integration issues**: Missing error handling, incorrect data mapping, async issues - **Performance concerns**: Inefficient algorithms, unnecessary rebuilds, memory problems - **Security vulnerabilities**: Hardcoded credentials, insecure data storage, authentication gaps -- **Architecture violations**: Features importing other features, business logic in BLoCs/widgets, Firebase packages outside `data_connect` +- **Architecture violations**: Features importing other features, business logic in BLoCs/widgets, Firebase packages outside `core`, direct Dio usage instead of `ApiService` - **Data persistence issues**: Cache invalidation, concurrent access ## Analysis Methodology @@ -64,7 +64,7 @@ Detect potential bugs including: 1. Map the feature's architecture and key screens 2. Identify critical user flows and navigation paths 3. Review state management implementation (BLoC states, events, transitions) -4. Understand data models and API contracts via Data Connect connectors +4. Understand data models and API contracts via V2 API endpoints 5. Document assumptions and expected behaviors ### Phase 2: Use Case Extraction @@ -108,7 +108,7 @@ Analyze code for: - Missing error handling in `.then()` chains - Mounted checks missing in async callbacks - Race conditions in concurrent requests -- Missing `_service.run()` wrapper for Data Connect calls +- Missing `ApiErrorHandler.executeProtected()` wrapper for API calls ### Background Tasks & WorkManager When reviewing code that uses WorkManager or background task scheduling, check these edge cases: @@ -140,7 +140,9 @@ When reviewing code that uses WorkManager or background task scheduling, check t ### Architecture Rules - Features importing other features directly - Business logic in BLoCs or widgets instead of Use Cases -- Firebase packages used outside `data_connect` package +- Firebase packages (`firebase_auth`) used outside `core` package +- Direct Dio/HTTP usage instead of `ApiService` with `V2ApiEndpoints` +- Importing deleted `krow_data_connect` package - `context.read()` instead of `ReadContext(context).read()` ## Output Format diff --git a/.claude/skills/krow-mobile-architecture/SKILL.md b/.claude/skills/krow-mobile-architecture/SKILL.md index 2ba4d4cf..ca3251d3 100644 --- a/.claude/skills/krow-mobile-architecture/SKILL.md +++ b/.claude/skills/krow-mobile-architecture/SKILL.md @@ -1,6 +1,6 @@ --- name: krow-mobile-architecture -description: KROW mobile app Clean Architecture implementation including package structure, dependency rules, feature isolation, BLoC lifecycle management, session handling, and Data Connect connectors pattern. Use this when architecting new mobile features, debugging state management issues, preventing prop drilling, managing BLoC disposal, implementing session stores, or setting up connector repositories. Essential for maintaining architectural integrity across staff and client apps. +description: KROW mobile app Clean Architecture implementation including package structure, dependency rules, feature isolation, BLoC lifecycle management, session handling, and V2 REST API integration. Use this when architecting new mobile features, debugging state management issues, preventing prop drilling, managing BLoC disposal, implementing session stores, or setting up API repository patterns. Essential for maintaining architectural integrity across staff and client apps. --- # KROW Mobile Architecture @@ -13,7 +13,7 @@ This skill defines the authoritative mobile architecture for the KROW platform. - Debugging state management or BLoC lifecycle issues - Preventing prop drilling in UI code - Managing session state and authentication -- Implementing Data Connect connector repositories +- Implementing V2 API repository patterns - Setting up feature modules and dependency injection - Understanding package boundaries and dependencies - Refactoring legacy code to Clean Architecture @@ -46,13 +46,14 @@ KROW follows **Clean Architecture** in a **Melos Monorepo**. Dependencies flow * │ both depend on ┌─────────────────▼───────────────────────────────────────┐ │ Services (Interface Adapters) │ -│ • data_connect: Backend integration, session mgmt │ -│ • core: Extensions, base classes, utilities │ +│ • core: API service, session management, device │ +│ services, utilities, extensions, base classes │ └─────────────────┬───────────────────────────────────────┘ - │ both depend on + │ depends on ┌─────────────────▼───────────────────────────────────────┐ │ Domain (Stable Core) │ -│ • Entities (immutable data models) │ +│ • Entities (data models with fromJson/toJson) │ +│ • Enums (shared enumerations) │ │ • Failures (domain-specific errors) │ │ • Pure Dart only, zero Flutter dependencies │ └─────────────────────────────────────────────────────────┘ @@ -69,9 +70,9 @@ KROW follows **Clean Architecture** in a **Melos Monorepo**. Dependencies flow * **Responsibilities:** - Initialize Flutter Modular - Assemble features into navigation tree -- Inject concrete implementations (from `data_connect`) into features +- Inject concrete implementations into features - Configure environment-specific settings (dev/stage/prod) -- Initialize session management +- Initialize session management via `V2SessionService` **Structure:** ``` @@ -119,21 +120,22 @@ features/staff/profile/ **Key Principles:** - **Presentation:** UI Pages and Widgets, BLoCs/Cubits for state - **Application:** Use Cases (business logic orchestration) -- **Data:** Repository implementations (backend integration) +- **Data:** Repository implementations using `ApiService` with `V2ApiEndpoints` - **Pages as StatelessWidget:** Move state to BLoCs for better performance and testability +- **Feature-level domain is optional:** Only needed when the feature has business logic (use cases, validators). Simple features can have just `data/` + `presentation/`. **RESTRICTION:** Features MUST NOT import other features. Communication happens via: - Shared domain entities - Session stores (`StaffSessionStore`, `ClientSessionStore`) - Navigation via Modular -- Data Connect connector repositories ### 2.3 Domain (`apps/mobile/packages/domain`) **Role:** The stable, pure heart of the system **Responsibilities:** -- Define **Entities** (immutable data models using Data Classes or Freezed) +- Define **Entities** (data models with `fromJson`/`toJson` for V2 API serialization) +- Define **Enums** (shared enumerations in `entities/enums/`) - Define **Failures** (domain-specific error types) **Structure:** @@ -144,11 +146,17 @@ domain/ │ ├── entities/ │ │ ├── user.dart │ │ ├── staff.dart -│ │ └── shift.dart -│ └── failures/ -│ ├── failure.dart # Base class -│ ├── auth_failure.dart -│ └── network_failure.dart +│ │ ├── shift.dart +│ │ └── enums/ +│ │ ├── staff_status.dart +│ │ └── order_type.dart +│ ├── failures/ +│ │ ├── failure.dart # Base class +│ │ ├── auth_failure.dart +│ │ └── network_failure.dart +│ └── core/ +│ └── services/api_services/ +│ └── base_api_service.dart └── pubspec.yaml ``` @@ -161,68 +169,120 @@ class Staff extends Equatable { final String name; final String email; final StaffStatus status; - + const Staff({ required this.id, required this.name, required this.email, required this.status, }); - + + factory Staff.fromJson(Map 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 toJson() => { + 'id': id, + 'name': name, + 'email': email, + 'status': status.name, + }; + @override List get props => [id, name, email, status]; } ``` -**RESTRICTION:** +**RESTRICTION:** - NO Flutter dependencies (no `import 'package:flutter/material.dart'`) -- NO `json_annotation` or serialization code - Only `equatable` for value equality - Pure Dart only +- `fromJson`/`toJson` live directly on entities (no separate DTOs or adapters) -### 2.4 Data Connect (`apps/mobile/packages/data_connect`) +### 2.4 Core (`apps/mobile/packages/core`) -**Role:** Interface Adapter for Backend Access +**Role:** Cross-cutting concerns, API infrastructure, session management, device services, and utilities **Responsibilities:** -- Centralized connector repositories (see Data Connect Connectors Pattern section) -- Implement Firebase Data Connect service layer -- Map Domain Entities ↔ Data Connect generated code -- Handle Firebase exceptions → domain failures -- Provide `DataConnectService` with session management +- `ApiService` — HTTP client wrapper around Dio with consistent response/error handling +- `V2ApiEndpoints` — All V2 REST API endpoint constants +- `DioClient` — Pre-configured Dio with `AuthInterceptor` and `IdempotencyInterceptor` +- `AuthInterceptor` — Automatically attaches Firebase Auth ID token to requests +- `IdempotencyInterceptor` — Adds `Idempotency-Key` header to POST/PUT/DELETE requests +- `ApiErrorHandler` mixin — Maps API errors to domain failures +- `SessionHandlerMixin` — Handles auth state, token refresh, role validation +- `V2SessionService` — Manages session lifecycle, replaces legacy DataConnectService +- Session stores (`StaffSessionStore`, `ClientSessionStore`) +- Device services (camera, gallery, location, notifications, storage, etc.) +- Extension methods (`NavigationExtensions`, `ListExtensions`, etc.) +- Base classes (`UseCase`, `Failure`, `BlocErrorHandler`) +- Logger configuration +- `AppConfig` — Environment-specific configuration (API base URLs, keys) **Structure:** ``` -data_connect/ +core/ ├── lib/ -│ ├── src/ -│ │ ├── services/ -│ │ │ ├── data_connect_service.dart # Core service -│ │ │ └── mixins/ -│ │ │ └── session_handler_mixin.dart -│ │ ├── connectors/ # Connector pattern (see below) -│ │ │ ├── staff/ -│ │ │ │ ├── domain/ -│ │ │ │ │ ├── repositories/ -│ │ │ │ │ │ └── staff_connector_repository.dart -│ │ │ │ │ └── usecases/ -│ │ │ │ │ └── get_profile_completion_usecase.dart -│ │ │ │ └── data/ -│ │ │ │ └── repositories/ -│ │ │ │ └── staff_connector_repository_impl.dart -│ │ │ ├── order/ -│ │ │ └── shifts/ -│ │ └── session/ -│ │ ├── staff_session_store.dart -│ │ └── client_session_store.dart -│ └── krow_data_connect.dart # Exports +│ ├── core.dart # Barrel exports +│ └── src/ +│ ├── config/ +│ │ ├── app_config.dart # Env-specific config (V2_API_BASE_URL, etc.) +│ │ └── app_environment.dart +│ ├── services/ +│ │ ├── api_service/ +│ │ │ ├── api_service.dart # ApiService (get/post/put/patch/delete) +│ │ │ ├── dio_client.dart # Pre-configured Dio +│ │ │ ├── inspectors/ +│ │ │ │ ├── auth_interceptor.dart +│ │ │ │ └── idempotency_interceptor.dart +│ │ │ ├── mixins/ +│ │ │ │ ├── api_error_handler.dart +│ │ │ │ └── session_handler_mixin.dart +│ │ │ └── core_api_services/ +│ │ │ ├── v2_api_endpoints.dart +│ │ │ ├── core_api_endpoints.dart +│ │ │ ├── file_upload/ +│ │ │ ├── signed_url/ +│ │ │ ├── llm/ +│ │ │ ├── verification/ +│ │ │ └── rapid_order/ +│ │ ├── session/ +│ │ │ ├── v2_session_service.dart +│ │ │ ├── staff_session_store.dart +│ │ │ └── client_session_store.dart +│ │ └── device/ +│ │ ├── camera/ +│ │ ├── gallery/ +│ │ ├── location/ +│ │ ├── notification/ +│ │ ├── storage/ +│ │ └── background_task/ +│ ├── presentation/ +│ │ ├── mixins/ +│ │ │ └── bloc_error_handler.dart +│ │ └── observers/ +│ │ └── core_bloc_observer.dart +│ ├── routing/ +│ │ └── routing.dart +│ ├── domain/ +│ │ ├── arguments/ +│ │ └── usecases/ +│ └── utils/ +│ ├── date_time_utils.dart +│ ├── geo_utils.dart +│ └── time_utils.dart └── pubspec.yaml ``` **RESTRICTION:** - NO feature-specific logic -- Connectors are domain-neutral and reusable -- All queries follow Clean Architecture (domain interfaces → data implementations) +- Core services are domain-neutral and reusable +- All V2 API access goes through `ApiService` — never use raw Dio directly in features ### 2.5 Design System (`apps/mobile/packages/design_system`) @@ -274,13 +334,13 @@ design_system/ **Feature Integration:** ```dart -// ✅ CORRECT: Access via Slang's global `t` accessor +// CORRECT: Access via Slang's global `t` accessor import 'package:core_localization/core_localization.dart'; Text(t.client_create_order.review.invalid_arguments) Text(t.errors.order.creation_failed) -// ❌ FORBIDDEN: Hardcoded user-facing strings +// FORBIDDEN: Hardcoded user-facing strings Text('Invalid review arguments') // Must use localized key Text('Order created!') // Must use localized key ``` @@ -313,62 +373,86 @@ BlocProvider( ) ``` -### 2.7 Core (`apps/mobile/packages/core`) - -**Role:** Cross-cutting concerns - -**Responsibilities:** -- Extension methods (NavigationExtensions, ListExtensions, etc.) -- Base classes (UseCase, Failure, BlocErrorHandler) -- Logger configuration -- Result types for functional error handling - ## 3. Dependency Direction Rules 1. **Domain Independence:** `domain` knows NOTHING about outer layers - Defines *what* needs to be done, not *how* - Pure Dart, zero Flutter dependencies - Stable contracts that rarely change + - Entities include `fromJson`/`toJson` for practical V2 API serialization 2. **UI Agnosticism:** Features depend on `design_system` for UI and `domain` for logic - - Features do NOT know about Firebase or backend details + - Features do NOT know about HTTP/Dio details - Backend changes don't affect feature implementation -3. **Data Isolation:** `data_connect` depends on `domain` to know interfaces - - Implements domain repository interfaces - - Maps backend models to domain entities +3. **Data Isolation:** Feature `data/` layer depends on `core` for API access and `domain` for entities + - RepoImpl uses `ApiService` with `V2ApiEndpoints` + - Maps JSON responses to domain entities via `Entity.fromJson()` - Does NOT know about UI **Dependency Flow:** ``` Apps → Features → Design System → Core Localization - → Data Connect → Domain - → Core + → Core → Domain ``` -## 4. Data Connect Service & Session Management +## 4. V2 API Service & Session Management -### 4.1 Session Handler Mixin +### 4.1 ApiService -**Location:** `apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart` +**Location:** `apps/mobile/packages/core/lib/src/services/api_service/api_service.dart` **Responsibilities:** -- Automatic token refresh (triggered when <5 minutes to expiry) -- Firebase auth state listening -- Role-based access validation -- Session state stream emissions -- 3-attempt retry with exponential backoff (1s → 2s → 4s) +- Wraps Dio HTTP methods (GET, POST, PUT, PATCH, DELETE) +- Consistent response parsing via `ApiResponse` +- Consistent error handling (maps `DioException` to `ApiResponse` with V2 error envelope) + +**Key Usage:** +```dart +final ApiService apiService; + +// GET request +final response = await apiService.get( + V2ApiEndpoints.staffDashboard, + params: {'date': '2026-01-15'}, +); + +// POST request +final response = await apiService.post( + V2ApiEndpoints.staffClockIn, + data: {'shiftId': shiftId, 'latitude': lat, 'longitude': lng}, +); +``` + +### 4.2 DioClient & Interceptors + +**Location:** `apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart` + +**Pre-configured with:** +- `AuthInterceptor` — Automatically attaches Firebase Auth ID token as `Bearer` token +- `IdempotencyInterceptor` — Adds `Idempotency-Key` (UUID v4) to POST/PUT/DELETE requests +- `LogInterceptor` — Logs request/response bodies for debugging + +### 4.3 V2SessionService + +**Location:** `apps/mobile/packages/core/lib/src/services/session/v2_session_service.dart` + +**Responsibilities:** +- Manages session lifecycle (initialize, refresh, invalidate) +- Fetches session data from V2 API on auth state change +- Populates session stores with user/role data +- Provides session state stream for `SessionListener` **Key Method:** ```dart // Call once on app startup -DataConnectService.instance.initializeAuthListener( +V2SessionService.instance.initializeAuthListener( allowedRoles: ['STAFF', 'BOTH'], // or ['CLIENT', 'BUSINESS', 'BOTH'] ); ``` -### 4.2 Session Listener Widget +### 4.4 Session Listener Widget **Location:** `apps/mobile/apps//lib/src/widgets/session_listener.dart` @@ -381,13 +465,13 @@ DataConnectService.instance.initializeAuthListener( ```dart // main.dart runApp( - SessionListener( // ← Critical wrapper + SessionListener( // Critical wrapper child: ModularApp(module: AppModule(), child: AppWidget()), ), ); ``` -### 4.3 Repository Pattern with Data Connect +### 4.5 Repository Pattern with V2 API **Step 1:** Define interface in feature domain: ```dart @@ -397,32 +481,33 @@ abstract interface class ProfileRepositoryInterface { } ``` -**Step 2:** Implement using `DataConnectService.run()`: +**Step 2:** Implement using `ApiService` with `V2ApiEndpoints`: ```dart // features/staff/profile/lib/src/data/repositories_impl/ class ProfileRepositoryImpl implements ProfileRepositoryInterface { - final DataConnectService _service = DataConnectService.instance; - + final ApiService _apiService; + + ProfileRepositoryImpl({required ApiService apiService}) + : _apiService = apiService; + @override Future getProfile(String id) async { - return await _service.run(() async { - final response = await _service.connector - .getStaffById(id: id) - .execute(); - return _mapToStaff(response.data.staff); - }); + final response = await _apiService.get( + V2ApiEndpoints.staffSession, + params: {'staffId': id}, + ); + return Staff.fromJson(response.data as Map); } } ``` -**Benefits of `_service.run()`:** -- ✅ Auto validates user is authenticated -- ✅ Refreshes token if <5 min to expiry -- ✅ Executes the query -- ✅ 3-attempt retry with exponential backoff -- ✅ Maps exceptions to domain failures +**Benefits of `ApiService` + interceptors:** +- AuthInterceptor auto-attaches Firebase Auth token +- IdempotencyInterceptor prevents duplicate writes +- Consistent error handling via `ApiResponse` +- No manual token management in features -### 4.4 Session Store Pattern +### 4.6 Session Store Pattern After successful auth, populate session stores: @@ -451,9 +536,10 @@ ClientSessionStore.instance.setSession( ```dart final session = StaffSessionStore.instance.session; if (session?.staff == null) { - final staff = await getStaffById(session!.user.uid); + final response = await apiService.get(V2ApiEndpoints.staffSession); + final staff = Staff.fromJson(response.data as Map); StaffSessionStore.instance.setSession( - session.copyWith(staff: staff), + session!.copyWith(staff: staff), ); } ``` @@ -463,12 +549,12 @@ if (session?.staff == null) { ### Zero Direct Imports ```dart -// ❌ FORBIDDEN +// FORBIDDEN import 'package:staff_profile/staff_profile.dart'; // in another feature -// ✅ ALLOWED +// ALLOWED import 'package:krow_domain/krow_domain.dart'; // shared domain -import 'package:krow_core/krow_core.dart'; // shared utilities +import 'package:krow_core/krow_core.dart'; // shared utilities + API import 'package:design_system/design_system.dart'; // shared UI ``` @@ -485,7 +571,7 @@ extension NavigationExtensions on IModularNavigator { await navigate('/home'); // Fallback } } - + /// Safely push with fallback to home Future safePush(String route) async { try { @@ -495,7 +581,7 @@ extension NavigationExtensions on IModularNavigator { return null; } } - + /// Safely pop with guard against empty stack void popSafe() { if (canPop()) { @@ -512,23 +598,23 @@ extension NavigationExtensions on IModularNavigator { // apps/mobile/apps/staff/lib/src/navigation/staff_navigator.dart extension StaffNavigator on IModularNavigator { Future toStaffHome() => safeNavigate(StaffPaths.home); - - Future toShiftDetails(String shiftId) => + + Future toShiftDetails(String shiftId) => safePush('${StaffPaths.shifts}/$shiftId'); - + Future toProfileEdit() => safePush(StaffPaths.profileEdit); } ``` **Usage in Features:** ```dart -// ✅ CORRECT +// CORRECT Modular.to.toStaffHome(); Modular.to.toShiftDetails(shiftId: '123'); Modular.to.popSafe(); -// ❌ AVOID -Modular.to.navigate('/home'); // No safety +// AVOID +Modular.to.navigate('/profile'); // No safety Navigator.push(...); // No Modular integration ``` @@ -536,9 +622,9 @@ Navigator.push(...); // No Modular integration Features don't share state directly. Use: -1. **Domain Repositories:** Centralized data sources +1. **Domain Repositories:** Centralized data sources via `ApiService` 2. **Session Stores:** `StaffSessionStore`, `ClientSessionStore` for app-wide context -3. **Event Streams:** If needed, via `DataConnectService` streams +3. **Event Streams:** If needed, via `V2SessionService` streams 4. **Navigation Arguments:** Pass IDs, not full objects ## 6. App-Specific Session Management @@ -549,11 +635,11 @@ Features don't share state directly. Use: // main.dart void main() async { WidgetsFlutterBinding.ensureInitialized(); - - DataConnectService.instance.initializeAuthListener( + + V2SessionService.instance.initializeAuthListener( allowedRoles: ['STAFF', 'BOTH'], ); - + runApp( SessionListener( child: ModularApp(module: StaffAppModule(), child: StaffApp()), @@ -564,11 +650,11 @@ void main() async { **Session Store:** `StaffSessionStore` - Fields: `user`, `staff`, `ownerId` -- Lazy load: `getStaffById()` if staff is null +- Lazy load: fetch from `V2ApiEndpoints.staffSession` if staff is null **Navigation:** -- Authenticated → `Modular.to.toStaffHome()` -- Unauthenticated → `Modular.to.toInitialPage()` +- Authenticated -> `Modular.to.toStaffHome()` +- Unauthenticated -> `Modular.to.toInitialPage()` ### Client App @@ -576,11 +662,11 @@ void main() async { // main.dart void main() async { WidgetsFlutterBinding.ensureInitialized(); - - DataConnectService.instance.initializeAuthListener( + + V2SessionService.instance.initializeAuthListener( allowedRoles: ['CLIENT', 'BUSINESS', 'BOTH'], ); - + runApp( SessionListener( child: ModularApp(module: ClientAppModule(), child: ClientApp()), @@ -591,137 +677,138 @@ void main() async { **Session Store:** `ClientSessionStore` - Fields: `user`, `business` -- Lazy load: `getBusinessById()` if business is null +- Lazy load: fetch from `V2ApiEndpoints.clientSession` if business is null **Navigation:** -- Authenticated → `Modular.to.toClientHome()` -- Unauthenticated → `Modular.to.toInitialPage()` +- Authenticated -> `Modular.to.toClientHome()` +- Unauthenticated -> `Modular.to.toInitialPage()` -## 7. Data Connect Connectors Pattern +## 7. V2 API Repository Pattern -**Problem:** Without connectors, each feature duplicates backend queries. +**Problem:** Without a consistent pattern, each feature handles HTTP differently. -**Solution:** Centralize all backend queries in `data_connect/connectors/`. +**Solution:** Feature RepoImpl uses `ApiService` with `V2ApiEndpoints`, returning domain entities via `Entity.fromJson()`. ### Structure -Mirror backend connector structure: +Repository implementations live in the feature package: ``` -data_connect/lib/src/connectors/ -├── staff/ +features/staff/profile/ +├── lib/src/ │ ├── domain/ -│ │ ├── repositories/ -│ │ │ └── staff_connector_repository.dart # Interface -│ │ └── usecases/ -│ │ └── get_profile_completion_usecase.dart -│ └── data/ -│ └── repositories/ -│ └── staff_connector_repository_impl.dart # Implementation -├── order/ -├── shifts/ -└── user/ +│ │ └── repositories/ +│ │ └── profile_repository_interface.dart # Interface +│ ├── data/ +│ │ └── repositories_impl/ +│ │ └── profile_repository_impl.dart # Implementation +│ └── presentation/ +│ └── blocs/ +│ └── profile_cubit.dart ``` -**Maps to backend:** -``` -backend/dataconnect/connector/ -├── staff/ -├── order/ -├── shifts/ -└── user/ -``` +### Repository Interface -### Clean Architecture in Connectors - -**Domain Interface:** ```dart -// staff_connector_repository.dart -abstract interface class StaffConnectorRepository { - Future getProfileCompletion(); - Future getStaffById(String id); +// profile_repository_interface.dart +abstract interface class ProfileRepositoryInterface { + Future getProfile(); + Future updatePersonalInfo(Map data); + Future> getProfileSections(); } ``` -**Use Case:** -```dart -// get_profile_completion_usecase.dart -class GetProfileCompletionUseCase { - final StaffConnectorRepository _repository; - - GetProfileCompletionUseCase({required StaffConnectorRepository repository}) - : _repository = repository; - - Future call() => _repository.getProfileCompletion(); -} -``` +### Repository Implementation -**Data Implementation:** ```dart -// staff_connector_repository_impl.dart -class StaffConnectorRepositoryImpl implements StaffConnectorRepository { - final DataConnectService _service; - +// profile_repository_impl.dart +class ProfileRepositoryImpl implements ProfileRepositoryInterface { + final ApiService _apiService; + + ProfileRepositoryImpl({required ApiService apiService}) + : _apiService = apiService; + @override - Future getProfileCompletion() async { - return _service.run(() async { - final staffId = await _service.getStaffId(); - final response = await _service.connector - .getStaffProfileCompletion(id: staffId) - .execute(); - - return _isProfileComplete(response); - }); + Future getProfile() async { + final response = await _apiService.get(V2ApiEndpoints.staffSession); + final data = response.data as Map; + return Staff.fromJson(data['staff'] as Map); + } + + @override + Future updatePersonalInfo(Map data) async { + await _apiService.put( + V2ApiEndpoints.staffPersonalInfo, + data: data, + ); + } + + @override + Future> getProfileSections() async { + final response = await _apiService.get(V2ApiEndpoints.staffProfileSections); + final list = response.data['sections'] as List; + return list + .map((e) => ProfileSection.fromJson(e as Map)) + .toList(); } } ``` -### Feature Integration +### Feature Module Integration -**Step 1:** Feature registers connector repository: ```dart -// staff_main_module.dart -class StaffMainModule extends Module { +// profile_module.dart +class ProfileModule extends Module { @override void binds(Injector i) { - i.addLazySingleton( - StaffConnectorRepositoryImpl.new, + i.addLazySingleton( + () => ProfileRepositoryImpl(apiService: i.get()), ); - + i.addLazySingleton( - () => GetProfileCompletionUseCase( - repository: i.get(), + () => GetProfileUseCase( + repository: i.get(), ), ); - + i.addLazySingleton( - () => StaffMainCubit( - getProfileCompletionUsecase: i.get(), + () => ProfileCubit( + getProfileUseCase: i.get(), ), ); } } ``` -**Step 2:** BLoC uses it: +### BLoC Usage + ```dart -class StaffMainCubit extends Cubit { - final GetProfileCompletionUseCase _getProfileCompletionUsecase; - - Future loadProfileCompletion() async { - final isComplete = await _getProfileCompletionUsecase(); - emit(state.copyWith(isProfileComplete: isComplete)); +class ProfileCubit extends Cubit with BlocErrorHandler { + final GetProfileUseCase _getProfileUseCase; + + Future loadProfile() async { + emit(state.copyWith(status: ProfileStatus.loading)); + + await handleError( + emit: emit, + action: () async { + final profile = await _getProfileUseCase(); + emit(state.copyWith(status: ProfileStatus.loaded, profile: profile)); + }, + onError: (errorKey) => state.copyWith(status: ProfileStatus.error), + ); } } ``` ### Benefits -✅ **No Duplication** - Query implemented once, used by many features -✅ **Single Source of Truth** - Backend change → update one place -✅ **Reusability** - Any feature can use any connector -✅ **Testability** - Mock connector repo to test features -✅ **Scalability** - Easy to add connectors as backend grows +- **No Duplication** — Endpoint constants defined once in `V2ApiEndpoints` +- **Consistent Auth** — `AuthInterceptor` handles token attachment automatically +- **Idempotent Writes** — `IdempotencyInterceptor` prevents duplicate mutations +- **Domain Purity** — Entities use `fromJson`/`toJson` directly, no mapping layers +- **Testability** — Mock `ApiService` to test RepoImpl in isolation +- **Scalability** — Add new endpoints to `V2ApiEndpoints`, implement in feature RepoImpl ## 8. Avoiding Prop Drilling: Direct BLoC Access @@ -730,23 +817,23 @@ class StaffMainCubit extends Cubit { Passing data through intermediate widgets creates maintenance burden: ```dart -// ❌ BAD: Prop drilling +// BAD: Prop drilling ProfilePage(status: status) - → ProfileHeader(status: status) - → ProfileLevelBadge(status: status) // Only widget that needs it + -> ProfileHeader(status: status) + -> ProfileLevelBadge(status: status) // Only widget that needs it ``` ### The Solution: BlocBuilder in Leaf Widgets ```dart -// ✅ GOOD: Direct BLoC access +// GOOD: Direct BLoC access class ProfileLevelBadge extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { if (state.profile == null) return const SizedBox.shrink(); - + final level = _mapStatusToLevel(state.profile!.status); return LevelBadgeUI(level: level); }, @@ -765,9 +852,9 @@ class ProfileLevelBadge extends StatelessWidget { **Decision Tree:** ``` Does this widget need data? -├─ YES, leaf widget → Use BlocBuilder -├─ YES, container → Use BlocBuilder in child -└─ NO → Don't add prop +├─ YES, leaf widget -> Use BlocBuilder +├─ YES, container -> Use BlocBuilder in child +└─ NO -> Don't add prop ``` ## 9. BLoC Lifecycle & State Emission Safety @@ -780,7 +867,7 @@ StateError: Cannot emit new states after calling close ``` **Root Causes:** -1. Transient BLoCs created with `BlocProvider(create:)` → disposed prematurely +1. Transient BLoCs created with `BlocProvider(create:)` -> disposed prematurely 2. Multiple BlocProviders disposing same singleton 3. User navigates away during async operation @@ -789,26 +876,26 @@ StateError: Cannot emit new states after calling close #### Step 1: Register as Singleton ```dart -// ✅ GOOD: Singleton registration +// GOOD: Singleton registration i.addLazySingleton( () => ProfileCubit(useCase1, useCase2), ); -// ❌ BAD: Creates new instance each time +// BAD: Creates new instance each time i.add(ProfileCubit.new); ``` #### Step 2: Use BlocProvider.value() ```dart -// ✅ GOOD: Reuse singleton +// GOOD: Reuse singleton final cubit = Modular.get(); BlocProvider.value( value: cubit, child: MyWidget(), ) -// ❌ BAD: Creates duplicate +// BAD: Creates duplicate BlocProvider( create: (_) => Modular.get(), child: MyWidget(), @@ -839,13 +926,13 @@ mixin BlocErrorHandler on Cubit { class ProfileCubit extends Cubit with BlocErrorHandler { Future loadProfile() async { emit(state.copyWith(status: ProfileStatus.loading)); - + await handleError( emit: emit, action: () async { final profile = await getProfile(); emit(state.copyWith(status: ProfileStatus.loaded, profile: profile)); - // ✅ Safe even if BLoC disposed + // Safe even if BLoC disposed }, onError: (errorKey) => state.copyWith(status: ProfileStatus.error), ); @@ -863,43 +950,48 @@ class ProfileCubit extends Cubit with BlocErrorHandler((event, emit) { - if (event.email.isEmpty) { // ← Use case responsibility + if (event.email.isEmpty) { // Use case responsibility emit(AuthError('Email required')); } }); ``` -❌ **Direct Data Connect in features** +- **Direct HTTP/Dio in features (use ApiService)** ```dart -final response = await FirebaseDataConnect.instance.query(); // ← Use repository +final response = await Dio().get('https://api.example.com/staff'); // Use ApiService ``` -❌ **Global state variables** +- **Importing krow_data_connect (deprecated package)** ```dart -User? currentUser; // ← Use SessionStore +import 'package:krow_data_connect/krow_data_connect.dart'; // Use krow_core instead ``` -❌ **Direct Navigator.push** +- **Global state variables** ```dart -Navigator.push(context, MaterialPageRoute(...)); // ← Use Modular +User? currentUser; // Use SessionStore ``` -❌ **Hardcoded navigation** +- **Direct Navigator.push** ```dart -Modular.to.navigate('/profile'); // ← Use safe extensions +Navigator.push(context, MaterialPageRoute(...)); // Use Modular ``` -❌ **Hardcoded user-facing strings** +- **Hardcoded navigation** ```dart -Text('Order created successfully!'); // ← Use t.section.key from core_localization +Modular.to.navigate('/profile'); // Use safe extensions +``` + +- **Hardcoded user-facing strings** +```dart +Text('Order created successfully!'); // Use t.section.key from core_localization ``` ## Summary @@ -907,17 +999,20 @@ Text('Order created successfully!'); // ← Use t.section.key from core_localiz The architecture enforces: - **Clean Architecture** with strict layer boundaries - **Feature Isolation** via zero cross-feature imports -- **Session Management** via DataConnectService and SessionListener -- **Connector Pattern** for reusable backend queries +- **V2 REST API** integration via `ApiService`, `V2ApiEndpoints`, and interceptors +- **Session Management** via `V2SessionService`, session stores, and `SessionListener` +- **Repository Pattern** with feature-local RepoImpl using `ApiService` - **BLoC Lifecycle** safety with singletons and safe emit - **Navigation Safety** with typed navigators and fallbacks When implementing features: 1. Follow package structure strictly -2. Use connector repositories for backend access -3. Register BLoCs as singletons with `.value()` -4. Use safe navigation extensions -5. Avoid prop drilling with direct BLoC access -6. Keep domain pure and stable +2. Use `ApiService` with `V2ApiEndpoints` for all backend access +3. Domain entities use `fromJson`/`toJson` for V2 API serialization +4. RepoImpl lives in the feature `data/` layer, not a shared package +5. Register BLoCs as singletons with `.value()` +6. Use safe navigation extensions +7. Avoid prop drilling with direct BLoC access +8. Keep domain pure and stable Architecture is not negotiable. When in doubt, refer to existing well-structured features or ask for clarification. diff --git a/.claude/skills/krow-mobile-development-rules/SKILL.md b/.claude/skills/krow-mobile-development-rules/SKILL.md index 4f4adc0f..83df3f8d 100644 --- a/.claude/skills/krow-mobile-development-rules/SKILL.md +++ b/.claude/skills/krow-mobile-development-rules/SKILL.md @@ -1,6 +1,6 @@ --- name: krow-mobile-development-rules -description: Enforce KROW mobile app development standards including file structure, naming conventions, logic placement boundaries, localization, Data Connect integration, and prototype migration rules. Use this skill whenever working on KROW Flutter mobile features, creating new packages, implementing BLoCs, integrating with backend, or migrating from prototypes. Critical for maintaining clean architecture and preventing architectural degradation. +description: Enforce KROW mobile app development standards including file structure, naming conventions, logic placement boundaries, localization, V2 REST API integration, and prototype migration rules. Use this skill whenever working on KROW Flutter mobile features, creating new packages, implementing BLoCs, integrating with backend, or migrating from prototypes. Critical for maintaining clean architecture and preventing architectural degradation. --- # KROW Mobile Development Rules @@ -11,7 +11,7 @@ These rules are **NON-NEGOTIABLE** enforcement guidelines for the KROW mobile ap - Creating new mobile features or packages - Implementing BLoCs, Use Cases, or Repositories -- Integrating with Firebase Data Connect backend +- Integrating with V2 REST API backend - Migrating code from prototypes - Reviewing mobile code for compliance - Setting up new feature modules @@ -186,15 +186,17 @@ class _LoginPageState extends State { ```dart // profile_repository_impl.dart class ProfileRepositoryImpl implements ProfileRepositoryInterface { + ProfileRepositoryImpl({required BaseApiService apiService}) + : _apiService = apiService; + final BaseApiService _apiService; + @override Future getProfile(String id) async { - final response = await dataConnect.getStaffById(id: id).execute(); - // Data transformation happens here - return Staff( - id: response.data.staff.id, - name: response.data.staff.name, - // Map Data Connect model to Domain entity + final ApiResponse response = await _apiService.get( + V2ApiEndpoints.staffProfile(id), ); + // Data transformation happens here + return Staff.fromJson(response.data as Map); } } ``` @@ -252,19 +254,19 @@ Modular.to.pop(); // ← Can crash if stack is empty **PATTERN:** All navigation MUST have fallback to Home page. Safe extensions automatically handle this. -### Session Management → DataConnectService + SessionHandlerMixin +### Session Management → V2SessionService + SessionHandlerMixin **✅ CORRECT:** ```dart // In main.dart: void main() async { WidgetsFlutterBinding.ensureInitialized(); - + // Initialize session listener (pick allowed roles for app) - DataConnectService.instance.initializeAuthListener( + V2SessionService.instance.initializeAuthListener( allowedRoles: ['STAFF', 'BOTH'], // for staff app ); - + runApp( SessionListener( // Wraps entire app child: ModularApp(module: AppModule(), child: AppWidget()), @@ -274,28 +276,24 @@ void main() async { // In repository: class ProfileRepositoryImpl implements ProfileRepositoryInterface { - final DataConnectService _service = DataConnectService.instance; - + ProfileRepositoryImpl({required BaseApiService apiService}) + : _apiService = apiService; + final BaseApiService _apiService; + @override Future getProfile(String id) async { - // _service.run() handles: - // - Auth validation - // - Token refresh (if <5 min to expiry) - // - Error handling with 3 retries - return await _service.run(() async { - final response = await _service.connector - .getStaffById(id: id) - .execute(); - return _mapToStaff(response.data.staff); - }); + final ApiResponse response = await _apiService.get( + V2ApiEndpoints.staffProfile(id), + ); + return Staff.fromJson(response.data as Map); } } ``` **PATTERN:** - **SessionListener** widget wraps app and shows dialogs for session errors -- **SessionHandlerMixin** in `DataConnectService` provides automatic token refresh -- **3-attempt retry logic** with exponential backoff (1s → 2s → 4s) +- **V2SessionService** provides automatic token refresh and auth management +- **ApiService** handles HTTP requests with automatic auth headers - **Role validation** configurable per app ## 4. Localization Integration (core_localization) @@ -372,7 +370,7 @@ class AppModule extends Module { @override List get imports => [ LocalizationModule(), // ← Required - DataConnectModule(), + CoreModule(), ]; } @@ -387,44 +385,51 @@ runApp( ); ``` -## 5. Data Connect Integration +## 5. V2 API Integration -All backend access goes through `DataConnectService`. +All backend access goes through `ApiService` with `V2ApiEndpoints`. ### Repository Pattern -**Step 1:** Define interface in feature domain: +**Step 1:** Define interface in feature domain (optional — feature-level domain layer is optional if entities from `krow_domain` suffice): ```dart -// domain/repositories/profile_repository_interface.dart -abstract interface class ProfileRepositoryInterface { - Future getProfile(String id); - Future updateProfile(Staff profile); +// domain/repositories/shifts_repository_interface.dart +abstract interface class ShiftsRepositoryInterface { + Future> getAssignedShifts(); + Future getShiftById(String id); } ``` -**Step 2:** Implement using `DataConnectService.run()`: +**Step 2:** Implement using `ApiService` + `V2ApiEndpoints`: ```dart -// data/repositories_impl/profile_repository_impl.dart -class ProfileRepositoryImpl implements ProfileRepositoryInterface { - final DataConnectService _service = DataConnectService.instance; - +// data/repositories_impl/shifts_repository_impl.dart +class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { + ShiftsRepositoryImpl({required BaseApiService apiService}) + : _apiService = apiService; + final BaseApiService _apiService; + @override - Future getProfile(String id) async { - return await _service.run(() async { - final response = await _service.connector - .getStaffById(id: id) - .execute(); - return _mapToStaff(response.data.staff); - }); + Future> getAssignedShifts() async { + final ApiResponse response = await _apiService.get(V2ApiEndpoints.staffShiftsAssigned); + final List items = response.data['items'] as List; + return items.map((dynamic json) => AssignedShift.fromJson(json as Map)).toList(); + } + + @override + Future getShiftById(String id) async { + final ApiResponse response = await _apiService.get(V2ApiEndpoints.staffShift(id)); + return AssignedShift.fromJson(response.data as Map); } } ``` -**Benefits of `_service.run()`:** -- ✅ Automatic auth validation -- ✅ Token refresh if needed -- ✅ 3-attempt retry with exponential backoff -- ✅ Consistent error handling +### Key Conventions + +- **Domain entities** have `fromJson` / `toJson` factory methods for serialization +- **Status fields** use enums from `krow_domain` (e.g., `ShiftStatus`, `OrderStatus`) +- **Money** is represented in cents as `int` (never `double`) +- **Timestamps** are `DateTime` objects (parsed from ISO 8601 strings) +- **Feature-level domain layer** is optional when `krow_domain` entities cover the need ### Session Store Pattern @@ -448,7 +453,7 @@ ClientSessionStore.instance.setSession( ); ``` -**Lazy Loading:** If session is null, fetch via `getStaffById()` or `getBusinessById()` and update store. +**Lazy Loading:** If session is null, fetch via the appropriate `ApiService.get()` endpoint and update store. ## 6. Prototype Migration Rules @@ -462,7 +467,7 @@ When migrating from `prototypes/`: ### ❌ MUST REJECT & REFACTOR - `GetX`, `Provider`, or `MVC` patterns - Any state management not using BLoC -- Direct HTTP calls (must use Data Connect) +- Direct HTTP calls (must use ApiService with V2ApiEndpoints) - Hardcoded colors/typography (must use design system) - Global state variables - Navigation without Modular @@ -491,13 +496,12 @@ If requirements are unclear: ### DO NOT - Add 3rd party packages without checking `apps/mobile/packages/core` first -- Add `firebase_auth` or `firebase_data_connect` to Feature packages (they belong in `data_connect` only) -- Use `addSingleton` for BLoCs (always use `add` method in Modular) +- Add `firebase_auth` or `firebase_data_connect` to Feature packages (they belong in `core` only) ### DO -- Use `DataConnectService.instance` for backend operations +- Use `ApiService` with `V2ApiEndpoints` for backend operations - Use Flutter Modular for dependency injection -- Register BLoCs with `i.addSingleton(() => CubitType(...))` +- Register BLoCs with `i.add(() => CubitType(...))` (transient) - Register Use Cases as factories or singletons as needed ## 9. Error Handling Pattern @@ -516,15 +520,12 @@ class InvalidCredentialsFailure extends AuthFailure { ### Repository Error Mapping ```dart -// Map Data Connect exceptions to Domain failures +// Map API errors to Domain failures using ApiErrorHandler try { - final response = await dataConnect.query(); - return Right(response); -} on DataConnectException catch (e) { - if (e.message.contains('unauthorized')) { - return Left(InvalidCredentialsFailure()); - } - return Left(ServerFailure(e.message)); + final response = await _apiService.get(V2ApiEndpoints.staffProfile(id)); + return Right(Staff.fromJson(response.data as Map)); +} catch (e) { + return Left(ApiErrorHandler.mapToFailure(e)); } ``` @@ -579,7 +580,7 @@ testWidgets('shows loading indicator when logging in', (tester) async { ``` ### Integration Tests -- Test full feature flows end-to-end with Data Connect +- Test full feature flows end-to-end with V2 API - Use dependency injection to swap implementations if needed ## 11. Clean Code Principles @@ -635,12 +636,12 @@ Before merging any mobile feature code: - [ ] Zero analyzer warnings ### Integration -- [ ] Data Connect queries via `_service.run()` +- [ ] V2 API calls via `ApiService` + `V2ApiEndpoints` - [ ] Error handling with domain failures - [ ] Proper dependency injection in modules ## Summary -The key principle: **Clean Architecture with zero tolerance for violations.** Business logic in Use Cases, state in BLoCs, data access in Repositories, UI in Widgets. Features are isolated, backend is centralized, localization is mandatory, and design system is immutable. +The key principle: **Clean Architecture with zero tolerance for violations.** Business logic in Use Cases, state in BLoCs, data access in Repositories (via `ApiService` + `V2ApiEndpoints`), UI in Widgets. Features are isolated, backend access is centralized through the V2 REST API layer, localization is mandatory, and design system is immutable. When in doubt, refer to existing features following these patterns or ask for clarification. It's better to ask than to introduce architectural debt. diff --git a/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index bab9899d..2b40a2eb 100644 --- a/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -20,11 +20,6 @@ public final class GeneratedPluginRegistrant { } catch (Exception e) { Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e); } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.appcheck.FlutterFirebaseAppCheckPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin firebase_app_check, io.flutter.plugins.firebase.appcheck.FlutterFirebaseAppCheckPlugin", e); - } try { flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.auth.FlutterFirebaseAuthPlugin()); } catch (Exception e) { diff --git a/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m b/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m index adab234d..0285454c 100644 --- a/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m +++ b/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m @@ -12,12 +12,6 @@ @import file_picker; #endif -#if __has_include() -#import -#else -@import firebase_app_check; -#endif - #if __has_include() #import #else @@ -82,7 +76,6 @@ + (void)registerWithRegistry:(NSObject*)registry { [FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]]; - [FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]]; [FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]]; [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; [FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]]; diff --git a/apps/mobile/apps/client/lib/main.dart b/apps/mobile/apps/client/lib/main.dart index f696a7c3..6d3c2ac4 100644 --- a/apps/mobile/apps/client/lib/main.dart +++ b/apps/mobile/apps/client/lib/main.dart @@ -14,7 +14,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; import 'firebase_options.dart'; import 'src/widgets/session_listener.dart'; @@ -31,8 +30,9 @@ void main() async { logStateChanges: false, // Set to true for verbose debugging ); - // Initialize session listener for Firebase Auth state changes - DataConnectService.instance.initializeAuthListener( + // Initialize V2 session listener for Firebase Auth state changes. + // Role validation calls GET /auth/session via the V2 API. + V2SessionService.instance.initializeAuthListener( allowedRoles: [ 'CLIENT', 'BUSINESS', diff --git a/apps/mobile/apps/client/lib/src/widgets/session_listener.dart b/apps/mobile/apps/client/lib/src/widgets/session_listener.dart index 707d5cf7..5d5f124d 100644 --- a/apps/mobile/apps/client/lib/src/widgets/session_listener.dart +++ b/apps/mobile/apps/client/lib/src/widgets/session_listener.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; /// A widget that listens to session state changes and handles global reactions. /// @@ -32,7 +31,7 @@ class _SessionListenerState extends State { } void _setupSessionListener() { - _sessionSubscription = DataConnectService.instance.onSessionStateChanged + _sessionSubscription = V2SessionService.instance.onSessionStateChanged .listen((SessionState state) { _handleSessionChange(state); }); @@ -134,7 +133,7 @@ class _SessionListenerState extends State { ), TextButton( onPressed: () { - Modular.to.popSafe();; + Modular.to.popSafe(); _proceedToLogin(); }, child: const Text('Log Out'), @@ -147,8 +146,9 @@ class _SessionListenerState extends State { /// Navigate to login screen and clear navigation stack. void _proceedToLogin() { - // Clear service caches on sign-out - DataConnectService.instance.handleSignOut(); + // Clear session stores on sign-out + V2SessionService.instance.handleSignOut(); + ClientSessionStore.instance.clear(); // Navigate to authentication Modular.to.toClientGetStartedPage(); diff --git a/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift index 288fbc2c..812f995c 100644 --- a/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,7 +7,6 @@ import Foundation import file_picker import file_selector_macos -import firebase_app_check import firebase_auth import firebase_core import flutter_local_notifications @@ -20,7 +19,6 @@ import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) - FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) diff --git a/apps/mobile/apps/client/pubspec.yaml b/apps/mobile/apps/client/pubspec.yaml index 677133d7..0949ea04 100644 --- a/apps/mobile/apps/client/pubspec.yaml +++ b/apps/mobile/apps/client/pubspec.yaml @@ -41,7 +41,6 @@ dependencies: flutter_localizations: sdk: flutter firebase_core: ^4.4.0 - krow_data_connect: ^0.0.1 dev_dependencies: flutter_test: diff --git a/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index 3d38f5de..2b40a2eb 100644 --- a/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -20,11 +20,6 @@ public final class GeneratedPluginRegistrant { } catch (Exception e) { Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e); } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.appcheck.FlutterFirebaseAppCheckPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin firebase_app_check, io.flutter.plugins.firebase.appcheck.FlutterFirebaseAppCheckPlugin", e); - } try { flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.auth.FlutterFirebaseAuthPlugin()); } catch (Exception e) { @@ -50,11 +45,6 @@ public final class GeneratedPluginRegistrant { } catch (Exception e) { Log.e(TAG, "Error registering plugin geolocator_android, com.baseflow.geolocator.GeolocatorPlugin", e); } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.googlemaps.GoogleMapsPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin google_maps_flutter_android, io.flutter.plugins.googlemaps.GoogleMapsPlugin", e); - } try { flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin()); } catch (Exception e) { diff --git a/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m b/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m index ea9cd0c4..0285454c 100644 --- a/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m +++ b/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m @@ -12,12 +12,6 @@ @import file_picker; #endif -#if __has_include() -#import -#else -@import firebase_app_check; -#endif - #if __has_include() #import #else @@ -42,12 +36,6 @@ @import geolocator_apple; #endif -#if __has_include() -#import -#else -@import google_maps_flutter_ios; -#endif - #if __has_include() #import #else @@ -88,12 +76,10 @@ + (void)registerWithRegistry:(NSObject*)registry { [FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]]; - [FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]]; [FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]]; [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; [FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]]; [GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]]; - [FLTGoogleMapsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTGoogleMapsPlugin"]]; [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]]; [RecordIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"RecordIosPlugin"]]; diff --git a/apps/mobile/apps/staff/lib/main.dart b/apps/mobile/apps/staff/lib/main.dart index 34a7321e..66fc30c8 100644 --- a/apps/mobile/apps/staff/lib/main.dart +++ b/apps/mobile/apps/staff/lib/main.dart @@ -6,7 +6,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krowwithus_staff/firebase_options.dart'; import 'package:staff_authentication/staff_authentication.dart' as staff_authentication; @@ -29,8 +28,9 @@ void main() async { logStateChanges: false, // Set to true for verbose debugging ); - // Initialize session listener for Firebase Auth state changes - DataConnectService.instance.initializeAuthListener( + // Initialize V2 session listener for Firebase Auth state changes. + // Role validation calls GET /auth/session via the V2 API. + V2SessionService.instance.initializeAuthListener( allowedRoles: [ 'STAFF', 'BOTH', diff --git a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart index 47d9fdd0..f5385ed9 100644 --- a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart +++ b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; /// A widget that listens to session state changes and handles global reactions. /// @@ -32,7 +31,7 @@ class _SessionListenerState extends State { } void _setupSessionListener() { - _sessionSubscription = DataConnectService.instance.onSessionStateChanged + _sessionSubscription = V2SessionService.instance.onSessionStateChanged .listen((SessionState state) { _handleSessionChange(state); }); @@ -65,6 +64,19 @@ class _SessionListenerState extends State { _sessionExpiredDialogShown = false; debugPrint('[SessionListener] Authenticated: ${state.userId}'); + // Don't auto-navigate while the auth flow is active — the auth + // BLoC handles its own navigation (e.g. profile-setup for new users). + final String currentPath = Modular.to.path; + if (currentPath.contains('/phone-verification') || + currentPath.contains('/profile-setup') || + currentPath.contains('/get-started')) { + debugPrint( + '[SessionListener] Skipping home navigation — auth flow active ' + '(path: $currentPath)', + ); + break; + } + // Navigate to the main app Modular.to.toStaffHome(); break; @@ -104,7 +116,7 @@ class _SessionListenerState extends State { actions: [ TextButton( onPressed: () { - Modular.to.popSafe();; + Modular.to.popSafe(); _proceedToLogin(); }, child: const Text('Log In'), @@ -134,7 +146,7 @@ class _SessionListenerState extends State { ), TextButton( onPressed: () { - Modular.to.popSafe();; + Modular.to.popSafe(); _proceedToLogin(); }, child: const Text('Log Out'), @@ -147,8 +159,9 @@ class _SessionListenerState extends State { /// Navigate to login screen and clear navigation stack. void _proceedToLogin() { - // Clear service caches on sign-out - DataConnectService.instance.handleSignOut(); + // Clear session stores on sign-out + V2SessionService.instance.handleSignOut(); + StaffSessionStore.instance.clear(); // Navigate to authentication Modular.to.toGetStartedPage(); diff --git a/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift index 288fbc2c..812f995c 100644 --- a/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,7 +7,6 @@ import Foundation import file_picker import file_selector_macos -import firebase_app_check import firebase_auth import firebase_core import flutter_local_notifications @@ -20,7 +19,6 @@ import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) - FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) diff --git a/apps/mobile/apps/staff/pubspec.yaml b/apps/mobile/apps/staff/pubspec.yaml index dd289c30..de4181dc 100644 --- a/apps/mobile/apps/staff/pubspec.yaml +++ b/apps/mobile/apps/staff/pubspec.yaml @@ -28,8 +28,6 @@ dependencies: path: ../../packages/features/staff/staff_main krow_core: path: ../../packages/core - krow_data_connect: - path: ../../packages/data_connect cupertino_icons: ^1.0.8 flutter_modular: ^6.3.0 firebase_core: ^4.4.0 diff --git a/apps/mobile/packages/core/lib/core.dart b/apps/mobile/packages/core/lib/core.dart index 33e4e5ac..0956107f 100644 --- a/apps/mobile/packages/core/lib/core.dart +++ b/apps/mobile/packages/core/lib/core.dart @@ -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_response.dart'; +// Session Management +export 'src/services/session/client_session_store.dart'; +export 'src/services/session/staff_session_store.dart'; +export 'src/services/session/v2_session_service.dart'; + // Device Services export 'src/services/device/camera/camera_service.dart'; export 'src/services/device/gallery/gallery_service.dart'; diff --git a/apps/mobile/packages/core/lib/src/core_module.dart b/apps/mobile/packages/core/lib/src/core_module.dart index 1d2c07ea..a1b90277 100644 --- a/apps/mobile/packages/core/lib/src/core_module.dart +++ b/apps/mobile/packages/core/lib/src/core_module.dart @@ -18,6 +18,15 @@ class CoreModule extends Module { // 2. Register the base API service i.addLazySingleton(() => ApiService(i.get())); + // 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(() { + final V2SessionService service = V2SessionService.instance; + service.setApiService(i.get()); + return service; + }); + // 3. Register Core API Services (Orchestrators) i.addLazySingleton( () => FileUploadService(i.get()), diff --git a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart index 42420650..686ea53c 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart @@ -98,6 +98,13 @@ extension StaffNavigator on IModularNavigator { safeNavigate(StaffPaths.shiftDetails(shift.id), arguments: shift); } + /// Navigates to shift details by ID only (no pre-fetched [Shift] object). + /// + /// Used when only the shift ID is available (e.g. from dashboard list items). + void toShiftDetailsById(String shiftId) { + safeNavigate(StaffPaths.shiftDetails(shiftId)); + } + void toPersonalInfo() { safePush(StaffPaths.onboardingPersonalInfo); } @@ -118,7 +125,7 @@ extension StaffNavigator on IModularNavigator { safeNavigate(StaffPaths.attire); } - void toAttireCapture({required AttireItem item, String? initialPhotoUrl}) { + void toAttireCapture({required AttireChecklist item, String? initialPhotoUrl}) { safeNavigate( StaffPaths.attireCapture, arguments: { @@ -132,7 +139,7 @@ extension StaffNavigator on IModularNavigator { safeNavigate(StaffPaths.documents); } - void toDocumentUpload({required StaffDocument document, String? initialUrl}) { + void toDocumentUpload({required ProfileDocument document, String? initialUrl}) { safeNavigate( StaffPaths.documentUpload, arguments: { diff --git a/apps/mobile/packages/core/lib/src/services/api_service/mixins/session_handler_mixin.dart b/apps/mobile/packages/core/lib/src/services/api_service/mixins/session_handler_mixin.dart index 7c91cde1..6c37d595 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/mixins/session_handler_mixin.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/mixins/session_handler_mixin.dart @@ -158,7 +158,7 @@ mixin SessionHandlerMixin { final Duration timeUntilExpiry = expiryTime.difference(now); if (timeUntilExpiry <= _refreshThreshold) { - await user.getIdTokenResult(); + await user.getIdTokenResult(true); } _lastTokenRefreshTime = now; @@ -212,9 +212,9 @@ mixin SessionHandlerMixin { final firebase_auth.IdTokenResult idToken = await user.getIdTokenResult(); if (idToken.expirationTime != null && - DateTime.now().difference(idToken.expirationTime!) < + idToken.expirationTime!.difference(DateTime.now()) < const Duration(minutes: 5)) { - await user.getIdTokenResult(); + await user.getIdTokenResult(true); } _emitSessionState(SessionState.authenticated(userId: user.uid)); diff --git a/apps/mobile/packages/core/lib/src/services/session/client_session_store.dart b/apps/mobile/packages/core/lib/src/services/session/client_session_store.dart new file mode 100644 index 00000000..51e52f66 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/session/client_session_store.dart @@ -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; + } +} diff --git a/apps/mobile/packages/core/lib/src/services/session/staff_session_store.dart b/apps/mobile/packages/core/lib/src/services/session/staff_session_store.dart new file mode 100644 index 00000000..99d424e1 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/session/staff_session_store.dart @@ -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; + } +} diff --git a/apps/mobile/packages/core/lib/src/services/session/v2_session_service.dart b/apps/mobile/packages/core/lib/src/services/session/v2_session_service.dart new file mode 100644 index 00000000..b126d74b --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/session/v2_session_service.dart @@ -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 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.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) { + final Map data = + response.data as Map; + 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 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(); + } + } +} diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index f4693848..1b6e1532 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -785,6 +785,9 @@ "personal_info": { "title": "Personal Info", "change_photo_hint": "Tap to change photo", + "choose_photo_source": "Choose Photo Source", + "photo_upload_success": "Profile photo updated", + "photo_upload_failed": "Failed to upload photo. Please try again.", "full_name_label": "Full Name", "email_label": "Email", "phone_label": "Phone Number", diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 006e3dec..dc509e86 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -780,6 +780,9 @@ "personal_info": { "title": "Informaci\u00f3n Personal", "change_photo_hint": "Toca para cambiar foto", + "choose_photo_source": "Elegir fuente de foto", + "photo_upload_success": "Foto de perfil actualizada", + "photo_upload_failed": "Error al subir la foto. Por favor, int\u00e9ntalo de nuevo.", "full_name_label": "Nombre Completo", "email_label": "Correo Electr\u00f3nico", "phone_label": "N\u00famero de Tel\u00e9fono", diff --git a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart b/apps/mobile/packages/data_connect/lib/krow_data_connect.dart deleted file mode 100644 index 7f6f5187..00000000 --- a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart +++ /dev/null @@ -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'; diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/billing/data/repositories/billing_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/billing/data/repositories/billing_connector_repository_impl.dart deleted file mode 100644 index 63cda77d..00000000 --- a/apps/mobile/packages/data_connect/lib/src/connectors/billing/data/repositories/billing_connector_repository_impl.dart +++ /dev/null @@ -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> 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 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( - 0.0, - (double sum, Invoice item) => sum + item.totalAmount, - ); - }); - } - - @override - Future> 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> 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> 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 - shiftRoles = result.data.shiftRoles; - if (shiftRoles.isEmpty) return []; - - final Map summary = {}; - 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 approveInvoice({required String id}) async { - return _service.run(() async { - await _service.connector - .updateInvoice(id: id) - .status(dc.InvoiceStatus.APPROVED) - .execute(); - }); - } - - @override - Future 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 workers = []; - - // Try to get workers from denormalized 'roles' field first - final List rolesData = invoice.roles is List - ? invoice.roles - : []; - if (rolesData.isNotEmpty) { - workers = rolesData.map((dynamic r) { - final Map role = r as Map; - - // 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 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 roles) { - return roles.fold(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, - ); - } -} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/billing/domain/repositories/billing_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/billing/domain/repositories/billing_connector_repository.dart deleted file mode 100644 index 4d4b0464..00000000 --- a/apps/mobile/packages/data_connect/lib/src/connectors/billing/domain/repositories/billing_connector_repository.dart +++ /dev/null @@ -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> getBankAccounts({required String businessId}); - - /// Fetches the current bill amount for the period. - Future getCurrentBillAmount({required String businessId}); - - /// Fetches historically paid invoices. - Future> getInvoiceHistory({required String businessId}); - - /// Fetches pending invoices (Open or Disputed). - Future> getPendingInvoices({required String businessId}); - - /// Fetches the breakdown of spending. - Future> getSpendingBreakdown({ - required String businessId, - required BillingPeriod period, - }); - - /// Approves an invoice. - Future approveInvoice({required String id}); - - /// Disputes an invoice. - Future disputeInvoice({required String id, required String reason}); -} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/coverage/data/repositories/coverage_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/coverage/data/repositories/coverage_connector_repository_impl.dart deleted file mode 100644 index d4fbea5c..00000000 --- a/apps/mobile/packages/data_connect/lib/src/connectors/coverage/data/repositories/coverage_connector_repository_impl.dart +++ /dev/null @@ -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> 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 shiftRolesResult = await _service.connector - .listShiftRolesByBusinessAndDateRange( - businessId: businessId, - start: _service.toTimestamp(start), - end: _service.toTimestamp(end), - ) - .execute(); - - final QueryResult 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 _mapCoverageShifts( - List shiftRoles, - List applications, - DateTime date, - ) { - if (shiftRoles.isEmpty && applications.isEmpty) return []; - - final Map groups = {}; - - 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: [], - ); - } - - 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: [], - ); - } - - 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 workers; -} - diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/coverage/domain/repositories/coverage_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/coverage/domain/repositories/coverage_connector_repository.dart deleted file mode 100644 index abb993c1..00000000 --- a/apps/mobile/packages/data_connect/lib/src/connectors/coverage/domain/repositories/coverage_connector_repository.dart +++ /dev/null @@ -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> getShiftsForDate({ - required String businessId, - required DateTime date, - }); -} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart deleted file mode 100644 index c48ac0a4..00000000 --- a/apps/mobile/packages/data_connect/lib/src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart +++ /dev/null @@ -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> getHubs({required String businessId}) async { - return _service.run(() async { - final String teamId = await _getOrCreateTeamId(businessId); - final QueryResult response = await _service.connector - .getTeamHubsByTeamId(teamId: teamId) - .execute(); - - final QueryResult< - dc.ListTeamHudDepartmentsData, - dc.ListTeamHudDepartmentsVariables - > - deptsResult = await _service.connector.listTeamHudDepartments().execute(); - final Map hubToDept = - {}; - 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 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 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 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 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 deleteHub({required String businessId, required String id}) async { - return _service.run(() async { - final QueryResult 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 _getOrCreateTeamId(String businessId) async { - final QueryResult 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 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', - { - '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 payload = json.decode(response.body) as Map; - if (payload['status'] != 'OK') return null; - - final Map? result = payload['result'] as Map?; - final List? components = result?['address_components'] as List?; - if (components == null || components.isEmpty) return null; - - String? streetNumber, route, city, state, country, zipCode; - - for (var entry in components) { - final Map component = entry as Map; - final List types = component['types'] as List? ?? []; - 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 = [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; -} - diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/hubs/domain/repositories/hubs_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/hubs/domain/repositories/hubs_connector_repository.dart deleted file mode 100644 index 42a83265..00000000 --- a/apps/mobile/packages/data_connect/lib/src/connectors/hubs/domain/repositories/hubs_connector_repository.dart +++ /dev/null @@ -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> getHubs({required String businessId}); - - /// Creates a new hub. - Future 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 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 deleteHub({required String businessId, required String id}); -} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/reports/data/repositories/reports_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/reports/data/repositories/reports_connector_repository_impl.dart deleted file mode 100644 index c4a04aac..00000000 --- a/apps/mobile/packages/data_connect/lib/src/connectors/reports/data/repositories/reports_connector_repository_impl.dart +++ /dev/null @@ -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 getDailyOpsReport({ - String? businessId, - required DateTime date, - }) async { - return _service.run(() async { - final String id = businessId ?? await _service.getBusinessId(); - final QueryResult response = await _service.connector - .listShiftsForDailyOpsByBusiness( - businessId: id, - date: _service.toTimestamp(date), - ) - .execute(); - - final List shifts = response.data.shifts; - - final int scheduledShifts = shifts.length; - int workersConfirmed = 0; - int inProgressShifts = 0; - int completedShifts = 0; - - final List dailyOpsShifts = []; - - 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 getSpendReport({ - String? businessId, - required DateTime startDate, - required DateTime endDate, - }) async { - return _service.run(() async { - final String id = businessId ?? await _service.getBusinessId(); - final QueryResult response = await _service.connector - .listInvoicesForSpendByBusiness( - businessId: id, - startDate: _service.toTimestamp(startDate), - endDate: _service.toTimestamp(endDate), - ) - .execute(); - - final List invoices = response.data.invoices; - - double totalSpend = 0.0; - int paidInvoices = 0; - int pendingInvoices = 0; - int overdueInvoices = 0; - - final List spendInvoices = []; - final Map dailyAggregates = {}; - final Map industryAggregates = {}; - - 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 completeDailyAggregates = {}; - 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 chartData = completeDailyAggregates.entries - .map((MapEntry e) => SpendChartPoint(date: e.key, amount: e.value)) - .toList() - ..sort((SpendChartPoint a, SpendChartPoint b) => a.date.compareTo(b.date)); - - final List industryBreakdown = industryAggregates.entries - .map((MapEntry 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 getCoverageReport({ - String? businessId, - required DateTime startDate, - required DateTime endDate, - }) async { - return _service.run(() async { - final String id = businessId ?? await _service.getBusinessId(); - final QueryResult response = await _service.connector - .listShiftsForCoverage( - businessId: id, - startDate: _service.toTimestamp(startDate), - endDate: _service.toTimestamp(endDate), - ) - .execute(); - - final List shifts = response.data.shifts; - - int totalNeeded = 0; - int totalFilled = 0; - final Map dailyStats = {}; - - 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 dailyCoverage = dailyStats.entries.map((MapEntry 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 getForecastReport({ - String? businessId, - required DateTime startDate, - required DateTime endDate, - }) async { - return _service.run(() async { - final String id = businessId ?? await _service.getBusinessId(); - final QueryResult response = await _service.connector - .listShiftsForForecastByBusiness( - businessId: id, - startDate: _service.toTimestamp(startDate), - endDate: _service.toTimestamp(endDate), - ) - .execute(); - - final List shifts = response.data.shifts; - - double projectedSpend = 0.0; - int projectedWorkers = 0; - double totalHours = 0.0; - final Map dailyStats = {}; - - // Weekly stats: index -> (cost, count, hours) - final Map weeklyStats = { - 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 chartData = dailyStats.entries.map((MapEntry 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 weeklyBreakdown = []; - 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 getPerformanceReport({ - String? businessId, - required DateTime startDate, - required DateTime endDate, - }) async { - return _service.run(() async { - final String id = businessId ?? await _service.getBusinessId(); - final QueryResult response = await _service.connector - .listShiftsForPerformanceByBusiness( - businessId: id, - startDate: _service.toTimestamp(startDate), - endDate: _service.toTimestamp(endDate), - ) - .execute(); - - final List 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(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 getNoShowReport({ - String? businessId, - required DateTime startDate, - required DateTime endDate, - }) async { - return _service.run(() async { - final String id = businessId ?? await _service.getBusinessId(); - - final QueryResult shiftsResponse = await _service.connector - .listShiftsForNoShowRangeByBusiness( - businessId: id, - startDate: _service.toTimestamp(startDate), - endDate: _service.toTimestamp(endDate), - ) - .execute(); - - final List shiftIds = shiftsResponse.data.shifts.map((dc.ListShiftsForNoShowRangeByBusinessShifts s) => s.id).toList(); - if (shiftIds.isEmpty) { - return const NoShowReport(totalNoShows: 0, noShowRate: 0, flaggedWorkers: []); - } - - final QueryResult appsResponse = await _service.connector - .listApplicationsForNoShowRange(shiftIds: shiftIds) - .execute(); - - final List apps = appsResponse.data.applications; - final List noShowApps = apps.where((dc.ListApplicationsForNoShowRangeApplications a) => (a.status.stringValue) == 'NO_SHOW').toList(); - final List 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: [], - ); - } - - final QueryResult staffResponse = await _service.connector - .listStaffForNoShowReport(staffIds: noShowStaffIds) - .execute(); - - final List staffList = staffResponse.data.staffs; - - final List 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 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 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 perfResponse = await _service.connector - .listShiftsForPerformanceByBusiness( - businessId: id, - startDate: _service.toTimestamp(startDate), - endDate: _service.toTimestamp(endDate), - ) - .execute(); - - final QueryResult invoicesResponse = await _service.connector - .listInvoicesForSpendByBusiness( - businessId: id, - startDate: _service.toTimestamp(startDate), - endDate: _service.toTimestamp(endDate), - ) - .execute(); - - final List forecastShifts = shiftsResponse.data.shifts; - final List perfShifts = perfResponse.data.shifts; - final List 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 shiftIds = forecastShifts.map((dc.ListShiftsForForecastByBusinessShifts s) => s.id).toList(); - double noShowRate = 0; - if (shiftIds.isNotEmpty) { - final QueryResult appsResponse = await _service.connector - .listApplicationsForNoShowRange(shiftIds: shiftIds) - .execute(); - final List apps = appsResponse.data.applications; - final List 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, - ); - }); - } -} - diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/reports/domain/repositories/reports_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/reports/domain/repositories/reports_connector_repository.dart deleted file mode 100644 index 14c44db9..00000000 --- a/apps/mobile/packages/data_connect/lib/src/connectors/reports/domain/repositories/reports_connector_repository.dart +++ /dev/null @@ -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 getDailyOpsReport({ - String? businessId, - required DateTime date, - }); - - /// Fetches the spend report for a specific business and date range. - Future getSpendReport({ - String? businessId, - required DateTime startDate, - required DateTime endDate, - }); - - /// Fetches the coverage report for a specific business and date range. - Future getCoverageReport({ - String? businessId, - required DateTime startDate, - required DateTime endDate, - }); - - /// Fetches the forecast report for a specific business and date range. - Future getForecastReport({ - String? businessId, - required DateTime startDate, - required DateTime endDate, - }); - - /// Fetches the performance report for a specific business and date range. - Future getPerformanceReport({ - String? businessId, - required DateTime startDate, - required DateTime endDate, - }); - - /// Fetches the no-show report for a specific business and date range. - Future getNoShowReport({ - String? businessId, - required DateTime startDate, - required DateTime endDate, - }); - - /// Fetches a summary of all reports for a specific business and date range. - Future getReportsSummary({ - String? businessId, - required DateTime startDate, - required DateTime endDate, - }); -} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart deleted file mode 100644 index 4f6e1ed9..00000000 --- a/apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart +++ /dev/null @@ -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> 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> 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 []; - - final QueryResult< - dc.ListShiftRolesByVendorIdData, - dc.ListShiftRolesByVendorIdVariables - > - response = await _service.connector - .listShiftRolesByVendorId(vendorId: vendorId) - .execute(); - - final List 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 appliedShiftIds = myAppsResponse.data.applications - .map((dc.GetApplicationsByStaffIdApplications a) => a.shiftId) - .toSet(); - - final List mappedShifts = []; - 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? 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> 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 []; - }); - } - - @override - Future getShiftDetails({ - required String shiftId, - required String staffId, - String? roleId, - }) async { - return _service.run(() async { - if (roleId != null && roleId.isNotEmpty) { - final QueryResult - 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 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 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 - 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 orderTypeEnum = - initialShift.order.orderType; - final bool isMultiDay = - orderTypeEnum is dc.Known && - (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 - 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 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 _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 acceptShift({required String shiftId, required String staffId}) { - return _updateApplicationStatus( - shiftId, - staffId, - dc.ApplicationStatus.CONFIRMED, - ); - } - - @override - Future declineShift({ - required String shiftId, - required String staffId, - }) { - return _updateApplicationStatus( - shiftId, - staffId, - dc.ApplicationStatus.REJECTED, - ); - } - - @override - Future> getCancelledShifts({required String staffId}) async { - return _service.run(() async { - // Logic would go here to fetch by REJECTED status if needed - return []; - }); - } - - @override - Future> 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 shifts = []; - 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 _mapApplicationsToShifts(List 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 _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? _generateSchedules({ - required String orderType, - required DateTime? startDate, - required DateTime? endDate, - required List? recurringDays, - required List? permanentDays, - required String startTime, - required String endTime, - }) { - if (orderType != 'RECURRING' && orderType != 'PERMANENT') return null; - if (startDate == null || endDate == null) return null; - - final List? daysToInclude = orderType == 'RECURRING' - ? recurringDays - : permanentDays; - if (daysToInclude == null || daysToInclude.isEmpty) return null; - - final List schedules = []; - final Set 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, - }); -} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/shifts/domain/repositories/shifts_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/shifts/domain/repositories/shifts_connector_repository.dart deleted file mode 100644 index bb8b50af..00000000 --- a/apps/mobile/packages/data_connect/lib/src/connectors/shifts/domain/repositories/shifts_connector_repository.dart +++ /dev/null @@ -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> getMyShifts({ - required String staffId, - required DateTime start, - required DateTime end, - }); - - /// Retrieves available shifts. - Future> getAvailableShifts({ - required String staffId, - String? query, - String? type, - }); - - /// Retrieves pending shift assignments for the current staff member. - Future> getPendingAssignments({required String staffId}); - - /// Retrieves detailed information for a specific shift. - Future getShiftDetails({ - required String shiftId, - required String staffId, - String? roleId, - }); - - /// Applies for a specific open shift. - Future applyForShift({ - required String shiftId, - required String staffId, - bool isInstantBook = false, - String? roleId, - }); - - /// Accepts a pending shift assignment. - Future acceptShift({ - required String shiftId, - required String staffId, - }); - - /// Declines a pending shift assignment. - Future declineShift({ - required String shiftId, - required String staffId, - }); - - /// Retrieves cancelled shifts for the current staff member. - Future> getCancelledShifts({required String staffId}); - - /// Retrieves historical (completed) shifts for the current staff member. - Future> getHistoryShifts({required String staffId}); -} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart deleted file mode 100644 index 770f1d68..00000000 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart +++ /dev/null @@ -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 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 - emergencyContacts = response.data.emergencyContacts; - return _isProfileComplete(staff, emergencyContacts); - }); - } - - @override - Future 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 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 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 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 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).value; - return status == dc.TaxFormStatus.SUBMITTED; - }, - ); - }); - } - - @override - Future getAttireOptionsCompletion() async { - return _service.run(() async { - final String staffId = await _service.getStaffId(); - - final List> results = - await Future.wait>( - >>[ - _service.connector.listAttireOptions().execute(), - _service.connector.getStaffAttire(staffId: staffId).execute(), - ], - ); - - final QueryResult optionsRes = - results[0] as QueryResult; - final QueryResult - staffAttireRes = - results[1] - as QueryResult; - - final List attireOptions = - optionsRes.data.attireOptions; - final List staffAttire = - staffAttireRes.data.staffAttires; - - // Get only mandatory attire options - final List 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) - .value; - return status == dc.AttireVerificationStatus.APPROVED; - }, - ); - }); - } - - @override - Future 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 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).value; - return status == dc.DocumentStatus.VERIFIED; - }, - ); - }); - } - - @override - Future 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 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).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? skills = staff.skills; - final List? 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 emergencyContacts, - ) { - if (staff == null) return false; - - final List? skills = staff.skills; - final List? 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 getStaffProfile() async { - return _service.run(() async { - final String staffId = await _service.getStaffId(); - - final QueryResult - 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> 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> getAttireOptions() async { - return _service.run(() async { - final String staffId = await _service.getStaffId(); - - final List> results = - await Future.wait>( - >>[ - _service.connector.listAttireOptions().execute(), - _service.connector.getStaffAttire(staffId: staffId).execute(), - ], - ); - - final QueryResult optionsRes = - results[0] as QueryResult; - final QueryResult - staffAttireRes = - results[1] - as QueryResult; - - final List 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 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 status, - ) { - if (status is dc.Unknown) { - return domain.AttireVerificationStatus.error; - } - final String name = - (status as dc.Known).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 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 signOut() async { - try { - await _service.signOut(); - } catch (e) { - throw Exception('Error signing out: ${e.toString()}'); - } - } - - @override - Future> getStaffDocuments() async { - return _service.run(() async { - final String staffId = await _service.getStaffId(); - - final List> results = - await Future.wait>( - >>[ - _service.connector.listDocuments().execute(), - _service.connector - .listStaffDocumentsByStaffId(staffId: staffId) - .execute(), - ], - ); - - final QueryResult documentsRes = - results[0] as QueryResult; - final QueryResult< - dc.ListStaffDocumentsByStaffIdData, - dc.ListStaffDocumentsByStaffIdVariables - > - staffDocsRes = - results[1] - as QueryResult< - dc.ListStaffDocumentsByStaffIdData, - dc.ListStaffDocumentsByStaffIdVariables - >; - - final List 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 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 status, - ) { - if (status is dc.Unknown) { - return domain.DocumentStatus.pending; - } - final dc.DocumentStatus value = - (status as dc.Known).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 status, - ) { - if (status is dc.Unknown) { - return domain.DocumentVerificationStatus.error; - } - final String name = (status as dc.Known).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> 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 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 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 status, - ) { - if (status is dc.Unknown) return domain.StaffCertificateStatus.notStarted; - final dc.CertificateStatus value = - (status as dc.Known).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 type, - ) { - if (type is dc.Unknown) return domain.ComplianceType.other; - final dc.ComplianceType value = (type as dc.Known).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? status, - ) { - if (status == null || status is dc.Unknown) return null; - final dc.ValidationStatus value = - (status as dc.Known).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; - } - } -} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart deleted file mode 100644 index f3ba6ac6..00000000 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart +++ /dev/null @@ -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 getProfileCompletion(); - - /// Fetches personal information completion status. - /// - /// Returns true if personal info (name, email, phone, locations) is complete. - Future getPersonalInfoCompletion(); - - /// Fetches emergency contacts completion status. - /// - /// Returns true if at least one emergency contact exists. - Future getEmergencyContactsCompletion(); - - /// Fetches experience completion status. - /// - /// Returns true if staff has industries or skills defined. - Future getExperienceCompletion(); - - /// Fetches tax forms completion status. - /// - /// Returns true if at least one tax form exists. - Future getTaxFormsCompletion(); - - /// Fetches attire options completion status. - /// - /// Returns true if all mandatory attire options are verified. - Future getAttireOptionsCompletion(); - - /// Fetches documents completion status. - /// - /// Returns true if all mandatory documents are verified. - Future getStaffDocumentsCompletion(); - - /// Fetches certificates completion status. - /// - /// Returns true if all certificates are validated. - Future 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 getStaffProfile(); - - /// Fetches the benefits for the current authenticated user. - /// - /// Returns a list of [Benefit] entities. - Future> getBenefits(); - - /// Fetches the attire options for the current authenticated user. - /// - /// Returns a list of [AttireItem] entities. - Future> getAttireOptions(); - - /// Upserts staff attire photo information. - Future 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 signOut(); - - /// Saves the staff profile information. - Future saveStaffProfile({ - String? firstName, - String? lastName, - String? bio, - String? profilePictureUrl, - }); - - /// Fetches the staff documents for the current authenticated user. - Future> getStaffDocuments(); - - /// Upserts staff document information. - Future upsertStaffDocument({ - required String documentId, - required String documentUrl, - DocumentStatus? status, - String? verificationId, - }); - - /// Fetches the staff certificates for the current authenticated user. - Future> getStaffCertificates(); - - /// Upserts staff certificate information. - Future 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 deleteStaffCertificate({ - required ComplianceType certificationType, - }); -} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_attire_options_completion_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_attire_options_completion_usecase.dart deleted file mode 100644 index acf51396..00000000 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_attire_options_completion_usecase.dart +++ /dev/null @@ -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 { - /// 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 call() => _repository.getAttireOptionsCompletion(); -} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_emergency_contacts_completion_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_emergency_contacts_completion_usecase.dart deleted file mode 100644 index 63c43dd4..00000000 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_emergency_contacts_completion_usecase.dart +++ /dev/null @@ -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 { - /// 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 call() => _repository.getEmergencyContactsCompletion(); -} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_experience_completion_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_experience_completion_usecase.dart deleted file mode 100644 index e744add4..00000000 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_experience_completion_usecase.dart +++ /dev/null @@ -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 { - /// 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 call() => _repository.getExperienceCompletion(); -} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_personal_info_completion_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_personal_info_completion_usecase.dart deleted file mode 100644 index a4a3f46d..00000000 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_personal_info_completion_usecase.dart +++ /dev/null @@ -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 { - /// 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 call() => _repository.getPersonalInfoCompletion(); -} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart deleted file mode 100644 index f079eb23..00000000 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart +++ /dev/null @@ -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 { - /// 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 call() => _repository.getProfileCompletion(); -} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_staff_certificates_completion_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_staff_certificates_completion_usecase.dart deleted file mode 100644 index a77238c7..00000000 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_staff_certificates_completion_usecase.dart +++ /dev/null @@ -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 { - /// 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 call() => _repository.getStaffCertificatesCompletion(); -} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_staff_documents_completion_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_staff_documents_completion_usecase.dart deleted file mode 100644 index 4bbe85db..00000000 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_staff_documents_completion_usecase.dart +++ /dev/null @@ -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 { - /// 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 call() => _repository.getStaffDocumentsCompletion(); -} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_staff_profile_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_staff_profile_usecase.dart deleted file mode 100644 index 3889bd49..00000000 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_staff_profile_usecase.dart +++ /dev/null @@ -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 { - /// 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 call([void params]) => _repository.getStaffProfile(); -} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_tax_forms_completion_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_tax_forms_completion_usecase.dart deleted file mode 100644 index 9a8fda29..00000000 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_tax_forms_completion_usecase.dart +++ /dev/null @@ -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 { - /// 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 call() => _repository.getTaxFormsCompletion(); -} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/sign_out_staff_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/sign_out_staff_usecase.dart deleted file mode 100644 index 4331006c..00000000 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/sign_out_staff_usecase.dart +++ /dev/null @@ -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 { - /// 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 call() => _repository.signOut(); -} diff --git a/apps/mobile/packages/data_connect/lib/src/data_connect_module.dart b/apps/mobile/packages/data_connect/lib/src/data_connect_module.dart deleted file mode 100644 index 53b9428f..00000000 --- a/apps/mobile/packages/data_connect/lib/src/data_connect_module.dart +++ /dev/null @@ -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.instance); - - // Repositories - i.addLazySingleton( - ReportsConnectorRepositoryImpl.new, - ); - i.addLazySingleton( - ShiftsConnectorRepositoryImpl.new, - ); - i.addLazySingleton( - HubsConnectorRepositoryImpl.new, - ); - i.addLazySingleton( - BillingConnectorRepositoryImpl.new, - ); - i.addLazySingleton( - CoverageConnectorRepositoryImpl.new, - ); - } -} diff --git a/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart b/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart deleted file mode 100644 index 4465a7cb..00000000 --- a/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart +++ /dev/null @@ -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 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 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 _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 run( - Future Function() operation, { - bool requiresAuthentication = true, - }) async { - if (requiresAuthentication) { - await ensureSessionValid(); - } - return executeProtected(operation); - } - - /// Implementation for SessionHandlerMixin. - @override - Future 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 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(); - } -} diff --git a/apps/mobile/packages/data_connect/lib/src/services/mixins/data_error_handler.dart b/apps/mobile/packages/data_connect/lib/src/services/mixins/data_error_handler.dart deleted file mode 100644 index 49a5cbea..00000000 --- a/apps/mobile/packages/data_connect/lib/src/services/mixins/data_error_handler.dart +++ /dev/null @@ -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 executeProtected( - Future 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()); - } - } -} diff --git a/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart b/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart deleted file mode 100644 index 0ce10c6a..00000000 --- a/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart +++ /dev/null @@ -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 _sessionStateController = - StreamController.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 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 _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? _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 _allowedRoles = []; - - /// Initialize the auth state listener (call once on app startup). - void initializeAuthListener({List allowedRoles = const []}) { - _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 validateUserRole( - String userId, - List 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 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 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.delayed(backoffDuration); - } - } - } - - /// Handle user sign-in event. - Future _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 disposeSessionHandler() async { - await _authStateSubscription?.cancel(); - await _sessionStateController.close(); - } -} diff --git a/apps/mobile/packages/data_connect/lib/src/session/client_session_store.dart b/apps/mobile/packages/data_connect/lib/src/session/client_session_store.dart deleted file mode 100644 index fbab38fe..00000000 --- a/apps/mobile/packages/data_connect/lib/src/session/client_session_store.dart +++ /dev/null @@ -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._(); -} diff --git a/apps/mobile/packages/data_connect/lib/src/session/staff_session_store.dart b/apps/mobile/packages/data_connect/lib/src/session/staff_session_store.dart deleted file mode 100644 index 02333a0c..00000000 --- a/apps/mobile/packages/data_connect/lib/src/session/staff_session_store.dart +++ /dev/null @@ -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._(); -} diff --git a/apps/mobile/packages/data_connect/pubspec.yaml b/apps/mobile/packages/data_connect/pubspec.yaml deleted file mode 100644 index 374204e5..00000000 --- a/apps/mobile/packages/data_connect/pubspec.yaml +++ /dev/null @@ -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 diff --git a/apps/mobile/packages/domain/lib/src/entities/availability/time_slot.dart b/apps/mobile/packages/domain/lib/src/entities/availability/time_slot.dart index 8bfcf812..6df43b55 100644 --- a/apps/mobile/packages/domain/lib/src/entities/availability/time_slot.dart +++ b/apps/mobile/packages/domain/lib/src/entities/availability/time_slot.dart @@ -13,10 +13,16 @@ class TimeSlot extends Equatable { }); /// 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 json) { return TimeSlot( - startTime: json['startTime'] as String? ?? '00:00', - endTime: json['endTime'] as String? ?? '00:00', + startTime: json['start'] as String? ?? + 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. final String endTime; - /// Serialises to JSON. + /// Serialises to JSON matching the V2 API contract. Map toJson() { return { - 'startTime': startTime, - 'endTime': endTime, + 'start': startTime, + 'end': endTime, }; } diff --git a/apps/mobile/packages/domain/lib/src/entities/home/staff_dashboard.dart b/apps/mobile/packages/domain/lib/src/entities/home/staff_dashboard.dart index c299a3ac..3e69aad3 100644 --- a/apps/mobile/packages/domain/lib/src/entities/home/staff_dashboard.dart +++ b/apps/mobile/packages/domain/lib/src/entities/home/staff_dashboard.dart @@ -1,6 +1,9 @@ import 'package:equatable/equatable.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. /// @@ -9,9 +12,9 @@ class StaffDashboard extends Equatable { /// Creates a [StaffDashboard] instance. const StaffDashboard({ required this.staffName, - this.todaysShifts = const >[], - this.tomorrowsShifts = const >[], - this.recommendedShifts = const >[], + this.todaysShifts = const [], + this.tomorrowsShifts = const [], + this.recommendedShifts = const [], this.benefits = const [], }); @@ -25,10 +28,19 @@ class StaffDashboard extends Equatable { : const []; return StaffDashboard( - staffName: json['staffName'] as String, - todaysShifts: _castShiftList(json['todaysShifts']), - tomorrowsShifts: _castShiftList(json['tomorrowsShifts']), - recommendedShifts: _castShiftList(json['recommendedShifts']), + staffName: json['staffName'] as String? ?? '', + todaysShifts: _parseList( + json['todaysShifts'], + TodayShift.fromJson, + ), + tomorrowsShifts: _parseList( + json['tomorrowsShifts'], + AssignedShift.fromJson, + ), + recommendedShifts: _parseList( + json['recommendedShifts'], + OpenShift.fromJson, + ), benefits: benefitsList, ); } @@ -37,13 +49,13 @@ class StaffDashboard extends Equatable { final String staffName; /// Shifts assigned for today. - final List> todaysShifts; + final List todaysShifts; /// Shifts assigned for tomorrow. - final List> tomorrowsShifts; + final List tomorrowsShifts; /// Recommended open shifts. - final List> recommendedShifts; + final List recommendedShifts; /// Active benefits. final List benefits; @@ -52,21 +64,27 @@ class StaffDashboard extends Equatable { Map toJson() { return { 'staffName': staffName, - 'todaysShifts': todaysShifts, - 'tomorrowsShifts': tomorrowsShifts, - 'recommendedShifts': recommendedShifts, + 'todaysShifts': + todaysShifts.map((TodayShift s) => s.toJson()).toList(), + 'tomorrowsShifts': + tomorrowsShifts.map((AssignedShift s) => s.toJson()).toList(), + 'recommendedShifts': + recommendedShifts.map((OpenShift s) => s.toJson()).toList(), 'benefits': benefits.map((Benefit b) => b.toJson()).toList(), }; } - static List> _castShiftList(dynamic raw) { + /// Safely parses a JSON list into a typed [List]. + static List _parseList( + dynamic raw, + T Function(Map) fromJson, + ) { if (raw is List) { return raw - .map((dynamic e) => - Map.from(e as Map)) + .map((dynamic e) => fromJson(e as Map)) .toList(); } - return const >[]; + return []; } @override diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/staff_personal_info.dart b/apps/mobile/packages/domain/lib/src/entities/profile/staff_personal_info.dart index e394e27d..0ea6beb4 100644 --- a/apps/mobile/packages/domain/lib/src/entities/profile/staff_personal_info.dart +++ b/apps/mobile/packages/domain/lib/src/entities/profile/staff_personal_info.dart @@ -17,6 +17,7 @@ class StaffPersonalInfo extends Equatable { this.skills = const [], this.email, this.phone, + this.photoUrl, }); /// Deserialises a [StaffPersonalInfo] from the V2 API JSON response. @@ -32,6 +33,7 @@ class StaffPersonalInfo extends Equatable { skills: _parseStringList(json['skills']), email: json['email'] as String?, phone: json['phone'] as String?, + photoUrl: json['photoUrl'] as String?, ); } @@ -65,6 +67,9 @@ class StaffPersonalInfo extends Equatable { /// Contact phone number. final String? phone; + /// URL of the staff member's profile photo. + final String? photoUrl; + /// Serialises this [StaffPersonalInfo] to a JSON map. Map toJson() { return { @@ -78,6 +83,7 @@ class StaffPersonalInfo extends Equatable { 'skills': skills, 'email': email, 'phone': phone, + 'photoUrl': photoUrl, }; } @@ -93,6 +99,7 @@ class StaffPersonalInfo extends Equatable { skills, email, phone, + photoUrl, ]; /// Parses a dynamic value into a list of strings. diff --git a/apps/mobile/packages/domain/lib/src/entities/users/staff.dart b/apps/mobile/packages/domain/lib/src/entities/users/staff.dart index f0245dea..d46849a0 100644 --- a/apps/mobile/packages/domain/lib/src/entities/users/staff.dart +++ b/apps/mobile/packages/domain/lib/src/entities/users/staff.dart @@ -1,31 +1,6 @@ import 'package:equatable/equatable.dart'; -/// Lifecycle status of a staff account in V2. -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, -} +import 'package:krow_domain/krow_domain.dart' show OnboardingStatus, StaffStatus; /// Represents a worker profile in the KROW platform. /// @@ -63,9 +38,9 @@ class Staff extends Equatable { fullName: json['fullName'] as String, email: json['email'] 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?, - onboardingStatus: _parseOnboardingStatus(json['onboardingStatus'] as String?), + onboardingStatus: OnboardingStatus.fromJson(json['onboardingStatus'] as String?), averageRating: _parseDouble(json['averageRating']), ratingCount: (json['ratingCount'] as num?)?.toInt() ?? 0, metadata: (json['metadata'] as Map?) ?? const {}, @@ -137,9 +112,9 @@ class Staff extends Equatable { 'fullName': fullName, 'email': email, 'phone': phone, - 'status': status.name.toUpperCase(), + 'status': status.toJson(), 'primaryRole': primaryRole, - 'onboardingStatus': onboardingStatus.name.toUpperCase(), + 'onboardingStatus': onboardingStatus.toJson(), 'averageRating': averageRating, 'ratingCount': ratingCount, 'metadata': metadata, @@ -172,36 +147,6 @@ class Staff extends Equatable { 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. static double _parseDouble(Object? value) { if (value is num) return value.toDouble(); diff --git a/apps/mobile/packages/features/client/authentication/lib/client_authentication.dart b/apps/mobile/packages/features/client/authentication/lib/client_authentication.dart index 5e4eec82..9a7a5670 100644 --- a/apps/mobile/packages/features/client/authentication/lib/client_authentication.dart +++ b/apps/mobile/packages/features/client/authentication/lib/client_authentication.dart @@ -2,7 +2,8 @@ library; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_domain/krow_domain.dart'; + import 'src/data/repositories_impl/auth_repository_impl.dart'; import 'src/domain/repositories/auth_repository_interface.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'; /// 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 { @override - List get imports => [DataConnectModule()]; + List get imports => [CoreModule()]; @override void binds(Injector i) { // Repositories - i.addLazySingleton(AuthRepositoryImpl.new); + i.addLazySingleton( + () => AuthRepositoryImpl(apiService: i.get()), + ); // UseCases i.addLazySingleton( diff --git a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index 3361b69d..433a30d1 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -1,68 +1,96 @@ import 'dart:developer' as developer; import 'package:firebase_auth/firebase_auth.dart' as firebase; -import 'package:firebase_data_connect/firebase_data_connect.dart'; -import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart' show + AccountExistsException, + ApiResponse, + AppException, + BaseApiService, + ClientSession, InvalidCredentialsException, + NetworkException, + PasswordMismatchException, SignInFailedException, SignUpFailedException, - WeakPasswordException, - AccountExistsException, - UserNotFoundException, UnauthorizedAppException, - PasswordMismatchException, - NetworkException; -import 'package:krow_domain/krow_domain.dart' as domain; + User, + UserStatus, + 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 -/// identity management and KROW's Data Connect SDK for storing user profile data. +/// Uses Firebase Auth client-side for sign-in (to maintain local auth state for +/// 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 { - /// Creates an [AuthRepositoryImpl] with the real dependencies. - AuthRepositoryImpl({dc.DataConnectService? service}) - : _service = service ?? dc.DataConnectService.instance; + /// Creates an [AuthRepositoryImpl] with the given [BaseApiService]. + AuthRepositoryImpl({required BaseApiService apiService}) + : _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 - Future signInWithEmail({ + Future signInWithEmail({ required String email, required String password, }) async { try { - final firebase.UserCredential credential = await _service.auth - .signInWithEmailAndPassword(email: email, password: password); + // Step 1: Call V2 sign-in endpoint — server handles Firebase Auth + // via Identity Toolkit and returns a full auth envelope. + final ApiResponse response = await _apiService.post( + V2ApiEndpoints.clientSignIn, + data: { + 'email': email, + 'password': password, + }, + ); + + final Map body = + response.data as Map; + + // 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; if (firebaseUser == null) { throw const SignInFailedException( - technicalMessage: 'No Firebase user received after sign-in', + technicalMessage: 'Local Firebase sign-in failed after V2 sign-in', ); } - return _getUserProfile( - firebaseUserId: firebaseUser.uid, - fallbackEmail: firebaseUser.email ?? email, - requireBusinessRole: true, - ); - } 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 { + // Step 3: Populate session store from the V2 auth envelope directly + // (no need for a separate GET /auth/session call). + return _populateStoreFromAuthEnvelope(body, firebaseUser, email); + } on AppException { rethrow; } catch (e) { throw SignInFailedException(technicalMessage: 'Unexpected error: $e'); @@ -70,50 +98,57 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { } @override - Future signUpWithEmail({ + Future signUpWithEmail({ required String companyName, required String email, required String password, }) async { - firebase.User? firebaseUser; - String? createdBusinessId; - try { - // Step 1: Try to create Firebase Auth user - final firebase.UserCredential credential = await _service.auth - .createUserWithEmailAndPassword(email: email, password: password); + // Step 1: Call V2 sign-up endpoint which handles everything server-side: + // - Creates Firebase Auth account via Identity Toolkit + // - Creates user, tenant, business, memberships in one transaction + // - Returns full auth envelope with session tokens + final ApiResponse response = await _apiService.post( + V2ApiEndpoints.clientSignUp, + data: { + 'companyName': companyName, + 'email': email, + 'password': password, + }, + ); - firebaseUser = credential.user; + // Check for V2 error responses. + final Map body = response.data as Map; + 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) { 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 - // token before we fire any mutations. Without this, there is a race - // 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, - ); + // Step 3: Populate store from the sign-up response envelope. + return _populateStoreFromAuthEnvelope(body, firebaseUser, email); } on firebase.FirebaseAuthException catch (e) { - if (e.code == 'weak-password') { - throw WeakPasswordException(technicalMessage: 'Firebase: ${e.message}'); - } else if (e.code == 'email-already-in-use') { - // Email exists in Firebase Auth - try to sign in and complete registration - return await _handleExistingFirebaseAccount( - email: email, - password: password, - companyName: companyName, + if (e.code == 'email-already-in-use') { + throw AccountExistsException( + technicalMessage: 'Firebase: ${e.message}', ); + } else if (e.code == 'weak-password') { + throw WeakPasswordException(technicalMessage: 'Firebase: ${e.message}'); } else if (e.code == 'network-request-failed') { throw NetworkException(technicalMessage: 'Firebase: ${e.message}'); } else { @@ -121,304 +156,103 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { technicalMessage: 'Firebase auth error: ${e.message}', ); } - } on domain.AppException { - // Rollback for our known exceptions - await _rollbackSignUp( - firebaseUser: firebaseUser, - businessId: createdBusinessId, - ); + } on AppException { rethrow; } catch (e) { - // Rollback: Clean up any partially created resources - await _rollbackSignUp( - firebaseUser: firebaseUser, - businessId: createdBusinessId, - ); 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 _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 _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 _checkBusinessUserExists(String firebaseUserId) async { - final QueryResult 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 _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 - 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 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 _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 - Future signOut() async { - try { - await _service.signOut(); - } catch (e) { - throw Exception('Error signing out: ${e.toString()}'); - } - } - - @override - Future signInWithSocial({required String provider}) { + Future signInWithSocial({required String provider}) { throw UnimplementedError( 'Social authentication with $provider is not yet implemented.', ); } - Future _getUserProfile({ - required String firebaseUserId, - required String? fallbackEmail, - bool requireBusinessRole = false, - }) async { - final QueryResult response = - await _service.run( - () => _service.connector.getUserById(id: firebaseUserId).execute(), - ); - 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', + @override + Future signOut() async { + try { + // Step 1: Call V2 sign-out endpoint for server-side token revocation. + await _apiService.post(V2ApiEndpoints.clientSignOut); + } catch (e) { + developer.log( + 'V2 sign-out request failed: $e', + name: 'AuthRepository', ); + // Continue with local sign-out even if server-side fails. } - final String? email = user.email ?? fallbackEmail; - if (email == null || email.isEmpty) { - throw UserNotFoundException( - technicalMessage: 'User email missing for UID $firebaseUserId', - ); + try { + // Step 2: Sign out from local Firebase Auth. + await _auth.signOut(); + } catch (e) { + throw Exception('Error signing out locally: $e'); } - final domain.User domainUser = domain.User( - id: user.id, + // Step 3: Clear the client session store. + ClientSessionStore.instance.clear(); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /// Populates the session store from a V2 auth envelope response and + /// returns a domain [User]. + User _populateStoreFromAuthEnvelope( + Map envelope, + firebase.User firebaseUser, + String fallbackEmail, + ) { + final Map? userJson = + envelope['user'] as Map?; + final Map? businessJson = + envelope['business'] as Map?; + + 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, - role: user.role.stringValue, + displayName: userJson?['displayName'] as String?, + phone: userJson?['phone'] as String?, + status: _parseUserStatus(userJson?['status'] as String?), ); + } - final QueryResult< - dc.GetBusinessesByUserIdData, - dc.GetBusinessesByUserIdVariables - > - businessResponse = await _service.run( - () => _service.connector - .getBusinessesByUserId(userId: firebaseUserId) - .execute(), - ); - final dc.GetBusinessesByUserIdBusinesses? business = - businessResponse.data.businesses.isNotEmpty - ? businessResponse.data.businesses.first - : null; + /// Maps a V2 error code to the appropriate domain exception for sign-up. + Never _throwSignUpError(String errorCode, String message) { + switch (errorCode) { + case 'AUTH_PROVIDER_ERROR' when message.contains('EMAIL_EXISTS'): + throw AccountExistsException(technicalMessage: message); + case 'AUTH_PROVIDER_ERROR' when message.contains('WEAK_PASSWORD'): + throw WeakPasswordException(technicalMessage: message); + case 'FORBIDDEN': + throw PasswordMismatchException(technicalMessage: message); + default: + throw SignUpFailedException(technicalMessage: '$errorCode: $message'); + } + } - dc.ClientSessionStore.instance.setSession( - dc.ClientSession( - business: business == null - ? null - : dc.ClientBusinessSession( - id: business.id, - businessName: business.businessName, - email: business.email, - city: business.city, - contactName: business.contactName, - companyLogoUrl: business.companyLogoUrl, - ), - ), - ); - - return domainUser; + /// Parses a status string from the API into a [UserStatus]. + static UserStatus _parseUserStatus(String? value) { + switch (value?.toUpperCase()) { + case 'ACTIVE': + return UserStatus.active; + case 'INVITED': + return UserStatus.invited; + case 'DISABLED': + return UserStatus.disabled; + default: + return UserStatus.active; + } } } diff --git a/apps/mobile/packages/features/client/authentication/pubspec.yaml b/apps/mobile/packages/features/client/authentication/pubspec.yaml index 0cc085d8..4db9ded0 100644 --- a/apps/mobile/packages/features/client/authentication/pubspec.yaml +++ b/apps/mobile/packages/features/client/authentication/pubspec.yaml @@ -14,17 +14,13 @@ dependencies: flutter_bloc: ^8.1.0 flutter_modular: ^6.3.0 equatable: ^2.0.5 - firebase_core: ^4.2.1 - firebase_auth: ^6.1.2 # Updated for compatibility - firebase_data_connect: ^0.2.2+1 - + firebase_auth: ^6.1.2 + # Architecture Packages design_system: path: ../../../design_system core_localization: path: ../../../core_localization - krow_data_connect: - path: ../../../data_connect krow_domain: path: ../../../domain krow_core: @@ -35,7 +31,6 @@ dev_dependencies: sdk: flutter bloc_test: ^9.1.0 mocktail: ^1.0.0 - build_runner: ^2.4.15 flutter: uses-material-design: true diff --git a/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart b/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart index b2bf37d8..02a4bb6c 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart @@ -1,30 +1,37 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; -import 'data/repositories_impl/billing_repository_impl.dart'; -import 'domain/repositories/billing_repository.dart'; -import 'domain/usecases/get_bank_accounts.dart'; -import 'domain/usecases/get_current_bill_amount.dart'; -import 'domain/usecases/get_invoice_history.dart'; -import 'domain/usecases/get_pending_invoices.dart'; -import 'domain/usecases/get_savings_amount.dart'; -import 'domain/usecases/get_spending_breakdown.dart'; -import 'domain/usecases/approve_invoice.dart'; -import 'domain/usecases/dispute_invoice.dart'; -import 'presentation/blocs/billing_bloc.dart'; -import 'presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart'; -import 'presentation/models/billing_invoice_model.dart'; -import 'presentation/pages/billing_page.dart'; -import 'presentation/pages/completion_review_page.dart'; -import 'presentation/pages/invoice_ready_page.dart'; -import 'presentation/pages/pending_invoices_page.dart'; +import 'package:billing/src/data/repositories_impl/billing_repository_impl.dart'; +import 'package:billing/src/domain/repositories/billing_repository.dart'; +import 'package:billing/src/domain/usecases/approve_invoice.dart'; +import 'package:billing/src/domain/usecases/dispute_invoice.dart'; +import 'package:billing/src/domain/usecases/get_bank_accounts.dart'; +import 'package:billing/src/domain/usecases/get_current_bill_amount.dart'; +import 'package:billing/src/domain/usecases/get_invoice_history.dart'; +import 'package:billing/src/domain/usecases/get_pending_invoices.dart'; +import 'package:billing/src/domain/usecases/get_savings_amount.dart'; +import 'package:billing/src/domain/usecases/get_spending_breakdown.dart'; +import 'package:billing/src/presentation/blocs/billing_bloc.dart'; +import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart'; +import 'package:billing/src/presentation/pages/billing_page.dart'; +import 'package:billing/src/presentation/pages/completion_review_page.dart'; +import 'package:billing/src/presentation/pages/invoice_ready_page.dart'; +import 'package:billing/src/presentation/pages/pending_invoices_page.dart'; /// Modular module for the billing feature. +/// +/// Uses [BaseApiService] for all backend access via V2 REST API. class BillingModule extends Module { + @override + List get imports => [CoreModule()]; + @override void binds(Injector i) { // Repositories - i.addLazySingleton(BillingRepositoryImpl.new); + i.addLazySingleton( + () => BillingRepositoryImpl(apiService: i.get()), + ); // Use Cases i.addLazySingleton(GetBankAccountsUseCase.new); @@ -32,7 +39,7 @@ class BillingModule extends Module { i.addLazySingleton(GetSavingsAmountUseCase.new); i.addLazySingleton(GetPendingInvoicesUseCase.new); i.addLazySingleton(GetInvoiceHistoryUseCase.new); - i.addLazySingleton(GetSpendingBreakdownUseCase.new); + i.addLazySingleton(GetSpendBreakdownUseCase.new); i.addLazySingleton(ApproveInvoiceUseCase.new); i.addLazySingleton(DisputeInvoiceUseCase.new); @@ -44,7 +51,7 @@ class BillingModule extends Module { getSavingsAmount: i.get(), getPendingInvoices: i.get(), getInvoiceHistory: i.get(), - getSpendingBreakdown: i.get(), + getSpendBreakdown: i.get(), ), ); i.add( @@ -62,16 +69,20 @@ class BillingModule extends Module { child: (_) => const BillingPage(), ); r.child( - ClientPaths.childRoute(ClientPaths.billing, ClientPaths.completionReview), - child: (_) => - ShiftCompletionReviewPage(invoice: r.args.data as BillingInvoice?), + ClientPaths.childRoute( + ClientPaths.billing, ClientPaths.completionReview), + child: (_) => ShiftCompletionReviewPage( + invoice: + r.args.data is Invoice ? r.args.data as Invoice : null, + ), ); r.child( ClientPaths.childRoute(ClientPaths.billing, ClientPaths.invoiceReady), child: (_) => const InvoiceReadyPage(), ); r.child( - ClientPaths.childRoute(ClientPaths.billing, ClientPaths.awaitingApproval), + ClientPaths.childRoute( + ClientPaths.billing, ClientPaths.awaitingApproval), child: (_) => const PendingInvoicesPage(), ); } diff --git a/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart b/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart index 387263ac..c8e8eea3 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart @@ -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_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_core/core.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 -/// connector repository from the data_connect package. +/// All backend calls go through [BaseApiService] with [V2ApiEndpoints]. class BillingRepositoryImpl implements BillingRepository { + /// Creates a [BillingRepositoryImpl]. + BillingRepositoryImpl({required BaseApiService apiService}) + : _apiService = apiService; - BillingRepositoryImpl({ - dc.BillingConnectorRepository? connectorRepository, - dc.DataConnectService? service, - }) : _connectorRepository = connectorRepository ?? - dc.DataConnectService.instance.getBillingRepository(), - _service = service ?? dc.DataConnectService.instance; - final dc.BillingConnectorRepository _connectorRepository; - final dc.DataConnectService _service; + /// The API service used for all HTTP requests. + final BaseApiService _apiService; @override - Future> getBankAccounts() async { - final String businessId = await _service.getBusinessId(); - return _connectorRepository.getBankAccounts(businessId: businessId); - } - - @override - Future getCurrentBillAmount() async { - final String businessId = await _service.getBusinessId(); - return _connectorRepository.getCurrentBillAmount(businessId: businessId); - } - - @override - Future> getInvoiceHistory() async { - final String businessId = await _service.getBusinessId(); - return _connectorRepository.getInvoiceHistory(businessId: businessId); + Future> getBankAccounts() async { + final ApiResponse response = + await _apiService.get(V2ApiEndpoints.clientBillingAccounts); + final List items = + (response.data as Map)['items'] as List; + return items + .map((dynamic json) => + BillingAccount.fromJson(json as Map)) + .toList(); } @override Future> getPendingInvoices() async { - final String businessId = await _service.getBusinessId(); - return _connectorRepository.getPendingInvoices(businessId: businessId); + final ApiResponse response = + await _apiService.get(V2ApiEndpoints.clientBillingInvoicesPending); + final List items = + (response.data as Map)['items'] as List; + return items + .map( + (dynamic json) => Invoice.fromJson(json as Map)) + .toList(); } @override - Future getSavingsAmount() async { - // Simulating savings calculation - return 0.0; + Future> getInvoiceHistory() async { + final ApiResponse response = + await _apiService.get(V2ApiEndpoints.clientBillingInvoicesHistory); + final List items = + (response.data as Map)['items'] as List; + return items + .map( + (dynamic json) => Invoice.fromJson(json as Map)) + .toList(); } @override - Future> getSpendingBreakdown(BillingPeriod period) async { - final String businessId = await _service.getBusinessId(); - return _connectorRepository.getSpendingBreakdown( - businessId: businessId, - period: period, + Future getCurrentBillCents() async { + final ApiResponse response = + await _apiService.get(V2ApiEndpoints.clientBillingCurrentBill); + final Map data = + response.data as Map; + return (data['currentBillCents'] as num).toInt(); + } + + @override + Future getSavingsCents() async { + final ApiResponse response = + await _apiService.get(V2ApiEndpoints.clientBillingSavings); + final Map data = + response.data as Map; + return (data['savingsCents'] as num).toInt(); + } + + @override + Future> getSpendBreakdown({ + required String startDate, + required String endDate, + }) async { + final ApiResponse response = await _apiService.get( + V2ApiEndpoints.clientBillingSpendBreakdown, + params: { + 'startDate': startDate, + 'endDate': endDate, + }, ); + final List items = + (response.data as Map)['items'] as List; + return items + .map((dynamic json) => + SpendItem.fromJson(json as Map)) + .toList(); } @override Future approveInvoice(String id) async { - return _connectorRepository.approveInvoice(id: id); + await _apiService.post(V2ApiEndpoints.clientInvoiceApprove(id)); } @override Future disputeInvoice(String id, String reason) async { - return _connectorRepository.disputeInvoice(id: id, reason: reason); + await _apiService.post( + V2ApiEndpoints.clientInvoiceDispute(id), + data: {'reason': reason}, + ); } } - diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart index 2041c0d2..4a229926 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart @@ -7,7 +7,7 @@ import 'package:krow_domain/krow_domain.dart'; /// It allows the Domain layer to remain independent of specific data sources. abstract class BillingRepository { /// Fetches bank accounts associated with the business. - Future> getBankAccounts(); + Future> getBankAccounts(); /// Fetches invoices that are pending approval or payment. Future> getPendingInvoices(); @@ -15,14 +15,17 @@ abstract class BillingRepository { /// Fetches historically paid invoices. Future> getInvoiceHistory(); - /// Fetches the current bill amount for the period. - Future getCurrentBillAmount(); + /// Fetches the current bill amount in cents for the period. + Future getCurrentBillCents(); - /// Fetches the savings amount. - Future getSavingsAmount(); + /// Fetches the savings amount in cents. + Future getSavingsCents(); - /// Fetches invoice items for spending breakdown analysis. - Future> getSpendingBreakdown(BillingPeriod period); + /// Fetches spending breakdown by category for a date range. + Future> getSpendBreakdown({ + required String startDate, + required String endDate, + }); /// Approves an invoice. Future approveInvoice(String id); diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/approve_invoice.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/approve_invoice.dart index 648c9986..7da6b1e0 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/approve_invoice.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/approve_invoice.dart @@ -1,11 +1,13 @@ 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. class ApproveInvoiceUseCase extends UseCase { /// Creates an [ApproveInvoiceUseCase]. ApproveInvoiceUseCase(this._repository); + /// The billing repository. final BillingRepository _repository; @override diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/dispute_invoice.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/dispute_invoice.dart index 7d05deb6..baac7e47 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/dispute_invoice.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/dispute_invoice.dart @@ -1,10 +1,16 @@ import 'package:krow_core/core.dart'; -import '../repositories/billing_repository.dart'; + +import 'package:billing/src/domain/repositories/billing_repository.dart'; /// Params for [DisputeInvoiceUseCase]. class DisputeInvoiceParams { + /// Creates [DisputeInvoiceParams]. const DisputeInvoiceParams({required this.id, required this.reason}); + + /// The invoice ID to dispute. final String id; + + /// The reason for the dispute. final String reason; } @@ -13,6 +19,7 @@ class DisputeInvoiceUseCase extends UseCase { /// Creates a [DisputeInvoiceUseCase]. DisputeInvoiceUseCase(this._repository); + /// The billing repository. final BillingRepository _repository; @override diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_bank_accounts.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_bank_accounts.dart index 23a52f38..39ffba24 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_bank_accounts.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_bank_accounts.dart @@ -1,14 +1,16 @@ import 'package:krow_core/core.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. -class GetBankAccountsUseCase extends NoInputUseCase> { +class GetBankAccountsUseCase extends NoInputUseCase> { /// Creates a [GetBankAccountsUseCase]. GetBankAccountsUseCase(this._repository); + /// The billing repository. final BillingRepository _repository; @override - Future> call() => _repository.getBankAccounts(); + Future> call() => _repository.getBankAccounts(); } diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_current_bill_amount.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_current_bill_amount.dart index ed684bcc..39f4737b 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_current_bill_amount.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_current_bill_amount.dart @@ -1,16 +1,17 @@ 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. -/// It delegates the data retrieval to the [BillingRepository]. -class GetCurrentBillAmountUseCase extends NoInputUseCase { +/// Delegates data retrieval to the [BillingRepository]. +class GetCurrentBillAmountUseCase extends NoInputUseCase { /// Creates a [GetCurrentBillAmountUseCase]. GetCurrentBillAmountUseCase(this._repository); + /// The billing repository. final BillingRepository _repository; @override - Future call() => _repository.getCurrentBillAmount(); + Future call() => _repository.getCurrentBillCents(); } diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_invoice_history.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_invoice_history.dart index a14fd7d3..ab84cf5d 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_invoice_history.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_invoice_history.dart @@ -1,15 +1,16 @@ import 'package:krow_core/core.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. /// -/// This use case encapsulates the logic for retrieving the list of past paid invoices. -/// It delegates the data retrieval to the [BillingRepository]. +/// Retrieves the list of past paid invoices. class GetInvoiceHistoryUseCase extends NoInputUseCase> { /// Creates a [GetInvoiceHistoryUseCase]. GetInvoiceHistoryUseCase(this._repository); + /// The billing repository. final BillingRepository _repository; @override diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_pending_invoices.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_pending_invoices.dart index 5d8b1f0a..fb8a7e9d 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_pending_invoices.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_pending_invoices.dart @@ -1,15 +1,16 @@ import 'package:krow_core/core.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. /// -/// This use case encapsulates the logic for retrieving invoices that are currently open or disputed. -/// It delegates the data retrieval to the [BillingRepository]. +/// Retrieves invoices that are currently open or disputed. class GetPendingInvoicesUseCase extends NoInputUseCase> { /// Creates a [GetPendingInvoicesUseCase]. GetPendingInvoicesUseCase(this._repository); + /// The billing repository. final BillingRepository _repository; @override diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_savings_amount.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_savings_amount.dart index 9f6b038f..baedf222 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_savings_amount.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_savings_amount.dart @@ -1,16 +1,17 @@ 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. -/// It delegates the data retrieval to the [BillingRepository]. -class GetSavingsAmountUseCase extends NoInputUseCase { +/// Delegates data retrieval to the [BillingRepository]. +class GetSavingsAmountUseCase extends NoInputUseCase { /// Creates a [GetSavingsAmountUseCase]. GetSavingsAmountUseCase(this._repository); + /// The billing repository. final BillingRepository _repository; @override - Future call() => _repository.getSavingsAmount(); + Future call() => _repository.getSavingsCents(); } diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_spending_breakdown.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_spending_breakdown.dart index 69e4c34b..0e01534a 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_spending_breakdown.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_spending_breakdown.dart @@ -1,19 +1,38 @@ import 'package:krow_core/core.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. -/// It delegates the data retrieval to the [BillingRepository]. -class GetSpendingBreakdownUseCase - extends UseCase> { - /// Creates a [GetSpendingBreakdownUseCase]. - GetSpendingBreakdownUseCase(this._repository); +/// Delegates data retrieval to the [BillingRepository]. +class GetSpendBreakdownUseCase + extends UseCase> { + /// Creates a [GetSpendBreakdownUseCase]. + GetSpendBreakdownUseCase(this._repository); + /// The billing repository. final BillingRepository _repository; @override - Future> call(BillingPeriod period) => - _repository.getSpendingBreakdown(period); + Future> call(SpendBreakdownParams input) => + _repository.getSpendBreakdown( + startDate: input.startDate, + endDate: input.endDate, + ); } diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart index e26088c2..3543571a 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart @@ -1,17 +1,17 @@ +import 'dart:developer' as developer; + import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/usecases/get_bank_accounts.dart'; -import '../../domain/usecases/get_current_bill_amount.dart'; -import '../../domain/usecases/get_invoice_history.dart'; -import '../../domain/usecases/get_pending_invoices.dart'; -import '../../domain/usecases/get_savings_amount.dart'; -import '../../domain/usecases/get_spending_breakdown.dart'; -import '../models/billing_invoice_model.dart'; -import '../models/spending_breakdown_model.dart'; -import 'billing_event.dart'; -import 'billing_state.dart'; + +import 'package:billing/src/domain/usecases/get_bank_accounts.dart'; +import 'package:billing/src/domain/usecases/get_current_bill_amount.dart'; +import 'package:billing/src/domain/usecases/get_invoice_history.dart'; +import 'package:billing/src/domain/usecases/get_pending_invoices.dart'; +import 'package:billing/src/domain/usecases/get_savings_amount.dart'; +import 'package:billing/src/domain/usecases/get_spending_breakdown.dart'; +import 'package:billing/src/presentation/blocs/billing_event.dart'; +import 'package:billing/src/presentation/blocs/billing_state.dart'; /// BLoC for managing billing state and data loading. class BillingBloc extends Bloc @@ -23,14 +23,14 @@ class BillingBloc extends Bloc required GetSavingsAmountUseCase getSavingsAmount, required GetPendingInvoicesUseCase getPendingInvoices, required GetInvoiceHistoryUseCase getInvoiceHistory, - required GetSpendingBreakdownUseCase getSpendingBreakdown, - }) : _getBankAccounts = getBankAccounts, - _getCurrentBillAmount = getCurrentBillAmount, - _getSavingsAmount = getSavingsAmount, - _getPendingInvoices = getPendingInvoices, - _getInvoiceHistory = getInvoiceHistory, - _getSpendingBreakdown = getSpendingBreakdown, - super(const BillingState()) { + required GetSpendBreakdownUseCase getSpendBreakdown, + }) : _getBankAccounts = getBankAccounts, + _getCurrentBillAmount = getCurrentBillAmount, + _getSavingsAmount = getSavingsAmount, + _getPendingInvoices = getPendingInvoices, + _getInvoiceHistory = getInvoiceHistory, + _getSpendBreakdown = getSpendBreakdown, + super(const BillingState()) { on(_onLoadStarted); on(_onPeriodChanged); } @@ -40,61 +40,60 @@ class BillingBloc extends Bloc final GetSavingsAmountUseCase _getSavingsAmount; final GetPendingInvoicesUseCase _getPendingInvoices; final GetInvoiceHistoryUseCase _getInvoiceHistory; - final GetSpendingBreakdownUseCase _getSpendingBreakdown; + final GetSpendBreakdownUseCase _getSpendBreakdown; + + /// Executes [loader] and returns null on failure, logging the error. + Future _loadSafe(Future 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 _onLoadStarted( BillingLoadStarted event, Emitter emit, ) async { emit(state.copyWith(status: BillingStatus.loading)); - await handleError( - emit: emit.call, - action: () async { - final List results = - await Future.wait(>[ - _getCurrentBillAmount.call(), - _getSavingsAmount.call(), - _getPendingInvoices.call(), - _getInvoiceHistory.call(), - _getSpendingBreakdown.call(state.period), - _getBankAccounts.call(), - ]); - final double savings = results[1] as double; - final List pendingInvoices = results[2] as List; - final List invoiceHistory = results[3] as List; - final List spendingItems = results[4] as List; - final List bankAccounts = - results[5] as List; + final SpendBreakdownParams spendParams = _dateRangeFor(state.periodTab); - // Map Domain Entities to Presentation Models - final List uiPendingInvoices = pendingInvoices - .map(_mapInvoiceToUiModel) - .toList(); - final List uiInvoiceHistory = invoiceHistory - .map(_mapInvoiceToUiModel) - .toList(); - final List uiSpendingBreakdown = - _mapSpendingItemsToUiModel(spendingItems); - final double periodTotal = uiSpendingBreakdown.fold( - 0.0, - (double sum, SpendingBreakdownItem item) => sum + item.amount, - ); + final List results = await Future.wait( + >[ + _loadSafe(() => _getCurrentBillAmount.call()), + _loadSafe(() => _getSavingsAmount.call()), + _loadSafe>(() => _getPendingInvoices.call()), + _loadSafe>(() => _getInvoiceHistory.call()), + _loadSafe>(() => _getSpendBreakdown.call(spendParams)), + _loadSafe>(() => _getBankAccounts.call()), + ], + ); - emit( - state.copyWith( - status: BillingStatus.success, - currentBill: periodTotal, - savings: savings, - pendingInvoices: uiPendingInvoices, - invoiceHistory: uiInvoiceHistory, - spendingBreakdown: uiSpendingBreakdown, - bankAccounts: bankAccounts, - ), - ); - }, - onError: (String errorKey) => - state.copyWith(status: BillingStatus.failure, errorMessage: errorKey), + final int? currentBillCents = results[0] as int?; + final int? savingsCents = results[1] as int?; + final List? pendingInvoices = results[2] as List?; + final List? invoiceHistory = results[3] as List?; + final List? spendBreakdown = results[4] as List?; + final List? bankAccounts = + results[5] as List?; + + emit( + state.copyWith( + status: BillingStatus.success, + currentBillCents: currentBillCents ?? state.currentBillCents, + savingsCents: savingsCents ?? state.savingsCents, + pendingInvoices: pendingInvoices ?? state.pendingInvoices, + invoiceHistory: invoiceHistory ?? state.invoiceHistory, + spendBreakdown: spendBreakdown ?? state.spendBreakdown, + bankAccounts: bankAccounts ?? state.bankAccounts, + ), ); } @@ -105,19 +104,15 @@ class BillingBloc extends Bloc await handleError( emit: emit.call, action: () async { - final List spendingItems = await _getSpendingBreakdown - .call(event.period); - final List uiSpendingBreakdown = - _mapSpendingItemsToUiModel(spendingItems); - final double periodTotal = uiSpendingBreakdown.fold( - 0.0, - (double sum, SpendingBreakdownItem item) => sum + item.amount, - ); + final SpendBreakdownParams params = + _dateRangeFor(event.periodTab); + final List spendBreakdown = + await _getSpendBreakdown.call(params); + emit( state.copyWith( - period: event.period, - spendingBreakdown: uiSpendingBreakdown, - currentBill: periodTotal, + periodTab: event.periodTab, + spendBreakdown: spendBreakdown, ), ); }, @@ -126,98 +121,14 @@ class BillingBloc extends Bloc ); } - BillingInvoice _mapInvoiceToUiModel(Invoice invoice) { - final DateFormat formatter = DateFormat('EEEE, MMMM d'); - final String dateLabel = invoice.issueDate == null - ? 'N/A' - : formatter.format(invoice.issueDate!); - - final List workers = invoice.workers.map(( - InvoiceWorker w, - ) { - 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 validCheckIns = invoice.workers - .where((InvoiceWorker w) => w.checkIn != null) - .map((InvoiceWorker w) => w.checkIn!) - .toList(); - final List 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, + /// Computes ISO-8601 date range for the selected period tab. + SpendBreakdownParams _dateRangeFor(BillingPeriodTab tab) { + final DateTime now = DateTime.now().toUtc(); + final int days = tab == BillingPeriodTab.week ? 7 : 30; + final DateTime start = now.subtract(Duration(days: days)); + return SpendBreakdownParams( + startDate: start.toIso8601String(), + endDate: now.toIso8601String(), ); } - - List _mapSpendingItemsToUiModel( - List items, - ) { - final Map aggregation = - {}; - - 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(); - } } diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_event.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_event.dart index 1b6996fe..3268c843 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_event.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_event.dart @@ -1,5 +1,6 @@ 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. abstract class BillingEvent extends Equatable { @@ -16,11 +17,14 @@ class BillingLoadStarted extends BillingEvent { const BillingLoadStarted(); } +/// Event triggered when the spend breakdown period tab changes. 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 - List get props => [period]; + List get props => [periodTab]; } diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_state.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_state.dart index 98d8d0fd..df5dd6a9 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_state.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_state.dart @@ -1,7 +1,5 @@ import 'package:equatable/equatable.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. enum BillingStatus { @@ -18,83 +16,104 @@ enum BillingStatus { failure, } +/// Which period the spend breakdown covers. +enum BillingPeriodTab { + /// Last 7 days. + week, + + /// Last 30 days. + month, +} + /// Represents the state of the billing feature. class BillingState extends Equatable { /// Creates a [BillingState]. const BillingState({ this.status = BillingStatus.initial, - this.currentBill = 0.0, - this.savings = 0.0, - this.pendingInvoices = const [], - this.invoiceHistory = const [], - this.spendingBreakdown = const [], - this.bankAccounts = const [], - this.period = BillingPeriod.week, + this.currentBillCents = 0, + this.savingsCents = 0, + this.pendingInvoices = const [], + this.invoiceHistory = const [], + this.spendBreakdown = const [], + this.bankAccounts = const [], + this.periodTab = BillingPeriodTab.week, this.errorMessage, }); /// The current feature status. final BillingStatus status; - /// The total amount for the current billing period. - final double currentBill; + /// The total amount for the current billing period in cents. + final int currentBillCents; - /// Total savings achieved compared to traditional agencies. - final double savings; + /// Total savings in cents. + final int savingsCents; /// Invoices awaiting client approval. - final List pendingInvoices; + final List pendingInvoices; /// History of paid invoices. - final List invoiceHistory; + final List invoiceHistory; /// Breakdown of spending by category. - final List spendingBreakdown; + final List spendBreakdown; /// Bank accounts associated with the business. - final List bankAccounts; + final List bankAccounts; - /// Selected period for the breakdown. - final BillingPeriod period; + /// Selected period tab for the breakdown. + final BillingPeriodTab periodTab; /// Error message if loading failed. 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. BillingState copyWith({ BillingStatus? status, - double? currentBill, - double? savings, - List? pendingInvoices, - List? invoiceHistory, - List? spendingBreakdown, - List? bankAccounts, - BillingPeriod? period, + int? currentBillCents, + int? savingsCents, + List? pendingInvoices, + List? invoiceHistory, + List? spendBreakdown, + List? bankAccounts, + BillingPeriodTab? periodTab, String? errorMessage, }) { return BillingState( status: status ?? this.status, - currentBill: currentBill ?? this.currentBill, - savings: savings ?? this.savings, + currentBillCents: currentBillCents ?? this.currentBillCents, + savingsCents: savingsCents ?? this.savingsCents, pendingInvoices: pendingInvoices ?? this.pendingInvoices, invoiceHistory: invoiceHistory ?? this.invoiceHistory, - spendingBreakdown: spendingBreakdown ?? this.spendingBreakdown, + spendBreakdown: spendBreakdown ?? this.spendBreakdown, bankAccounts: bankAccounts ?? this.bankAccounts, - period: period ?? this.period, + periodTab: periodTab ?? this.periodTab, errorMessage: errorMessage ?? this.errorMessage, ); } @override List get props => [ - status, - currentBill, - savings, - pendingInvoices, - invoiceHistory, - spendingBreakdown, - bankAccounts, - period, - errorMessage, - ]; + status, + currentBillCents, + savingsCents, + pendingInvoices, + invoiceHistory, + spendBreakdown, + bankAccounts, + periodTab, + errorMessage, + ]; } diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart index bbdb56f0..53b7771a 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart @@ -1,19 +1,22 @@ import 'package:flutter_bloc/flutter_bloc.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 extends Bloc with BlocErrorHandler { + /// Creates a [ShiftCompletionReviewBloc]. ShiftCompletionReviewBloc({ required ApproveInvoiceUseCase approveInvoice, required DisputeInvoiceUseCase disputeInvoice, - }) : _approveInvoice = approveInvoice, - _disputeInvoice = disputeInvoice, - super(const ShiftCompletionReviewState()) { + }) : _approveInvoice = approveInvoice, + _disputeInvoice = disputeInvoice, + super(const ShiftCompletionReviewState()) { on(_onApproved); on(_onDisputed); } diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/models/billing_invoice_model.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/models/billing_invoice_model.dart deleted file mode 100644 index 9da0a498..00000000 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/models/billing_invoice_model.dart +++ /dev/null @@ -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 [], - 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 workers; - final String? startTime; - final String? endTime; - - @override - List get props => [ - 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 get props => [ - workerName, - roleName, - totalAmount, - hours, - rate, - startTime, - endTime, - breakMinutes, - workerAvatarUrl, - ]; -} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/models/spending_breakdown_model.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/models/spending_breakdown_model.dart deleted file mode 100644 index 4fc32313..00000000 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/models/spending_breakdown_model.dart +++ /dev/null @@ -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 get props => [category, hours, amount]; -} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart index ad47a9cf..c96b5308 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart @@ -5,13 +5,13 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import '../blocs/billing_bloc.dart'; -import '../blocs/billing_event.dart'; -import '../blocs/billing_state.dart'; -import '../widgets/billing_page_skeleton.dart'; -import '../widgets/invoice_history_section.dart'; -import '../widgets/pending_invoices_section.dart'; -import '../widgets/spending_breakdown_card.dart'; +import 'package:billing/src/presentation/blocs/billing_bloc.dart'; +import 'package:billing/src/presentation/blocs/billing_event.dart'; +import 'package:billing/src/presentation/blocs/billing_state.dart'; +import 'package:billing/src/presentation/widgets/billing_page_skeleton.dart'; +import 'package:billing/src/presentation/widgets/invoice_history_section.dart'; +import 'package:billing/src/presentation/widgets/pending_invoices_section.dart'; +import 'package:billing/src/presentation/widgets/spending_breakdown_card.dart'; /// 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. /// -/// This widget displays the billing dashboard content based on the current -/// state of the [BillingBloc]. +/// Displays the billing dashboard content based on the current [BillingState]. class BillingView extends StatefulWidget { /// Creates a [BillingView]. const BillingView({super.key}); @@ -125,7 +124,7 @@ class _BillingViewState extends State { ), const SizedBox(height: UiConstants.space1), Text( - '\$${state.currentBill.toStringAsFixed(2)}', + '\$${state.currentBillDollars.toStringAsFixed(2)}', style: UiTypography.displayM.copyWith( color: UiColors.white, fontSize: 40, @@ -152,7 +151,8 @@ class _BillingViewState extends State { const SizedBox(width: UiConstants.space2), Text( t.client_billing.saved_amount( - amount: state.savings.toStringAsFixed(0), + amount: state.savingsDollars + .toStringAsFixed(0), ), style: UiTypography.footnote2b.copyWith( color: UiColors.accentForeground, @@ -221,7 +221,6 @@ class _BillingViewState extends State { if (state.pendingInvoices.isNotEmpty) ...[ PendingInvoicesSection(invoices: state.pendingInvoices), ], - // const PaymentMethodCard(), const SpendingBreakdownCard(), if (state.invoiceHistory.isNotEmpty) InvoiceHistorySection(invoices: state.invoiceHistory), diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart index d9d48dd9..542ebc28 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart @@ -1,19 +1,21 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.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 '../widgets/completion_review/completion_review_actions.dart'; -import '../widgets/completion_review/completion_review_amount.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'; +import 'package:billing/src/presentation/widgets/completion_review/completion_review_actions.dart'; +import 'package:billing/src/presentation/widgets/completion_review/completion_review_amount.dart'; +import 'package:billing/src/presentation/widgets/completion_review/completion_review_info.dart'; +/// Page for reviewing and approving/disputing an invoice. class ShiftCompletionReviewPage extends StatefulWidget { + /// Creates a [ShiftCompletionReviewPage]. const ShiftCompletionReviewPage({this.invoice, super.key}); - final BillingInvoice? invoice; + /// The invoice to review. + final Invoice? invoice; @override State createState() => @@ -21,31 +23,45 @@ class ShiftCompletionReviewPage extends StatefulWidget { } class _ShiftCompletionReviewPageState extends State { - late BillingInvoice invoice; - String searchQuery = ''; - int selectedTab = 0; // 0: Needs Review (mocked as empty), 1: All + /// The resolved invoice, or null if route data is missing/invalid. + late final Invoice? invoice; @override void initState() { super.initState(); - // Use widget.invoice if provided, else try to get from arguments - invoice = widget.invoice ?? Modular.args.data as BillingInvoice; + invoice = widget.invoice ?? + (Modular.args.data is Invoice + ? Modular.args.data as Invoice + : null); } @override Widget build(BuildContext context) { - final List filteredWorkers = invoice.workers.where(( - BillingWorkerRecord w, - ) { - if (searchQuery.isEmpty) return true; - return w.workerName.toLowerCase().contains(searchQuery.toLowerCase()) || - w.roleName.toLowerCase().contains(searchQuery.toLowerCase()); - }).toList(); + final Invoice? resolvedInvoice = invoice; + if (resolvedInvoice == null) { + return Scaffold( + appBar: UiAppBar( + title: t.client_billing.review_and_approve, + showBackButton: true, + ), + 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( appBar: UiAppBar( - title: invoice.title, - subtitle: invoice.clientName, + title: resolvedInvoice.invoiceNumber, + subtitle: resolvedInvoice.vendorName ?? '', showBackButton: true, ), body: SafeArea( @@ -55,26 +71,13 @@ class _ShiftCompletionReviewPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: UiConstants.space4), - CompletionReviewInfo(invoice: invoice), + CompletionReviewInfo( + dateLabel: dateLabel, + vendorName: resolvedInvoice.vendorName, + ), const SizedBox(height: UiConstants.space4), - CompletionReviewAmount(invoice: invoice), + CompletionReviewAmount(amountCents: resolvedInvoice.amountCents), 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 { top: BorderSide(color: UiColors.border.withValues(alpha: 0.5)), ), ), - child: SafeArea(child: CompletionReviewActions(invoiceId: invoice.id)), + child: SafeArea( + child: CompletionReviewActions(invoiceId: resolvedInvoice.invoiceId), + ), ), ); } diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart index d7620b3b..358b955d 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart @@ -2,14 +2,17 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:intl/intl.dart'; +import 'package:krow_domain/krow_domain.dart'; -import '../blocs/billing_bloc.dart'; -import '../blocs/billing_event.dart'; -import '../blocs/billing_state.dart'; -import '../models/billing_invoice_model.dart'; -import '../widgets/invoices_list_skeleton.dart'; +import 'package:billing/src/presentation/blocs/billing_bloc.dart'; +import 'package:billing/src/presentation/blocs/billing_event.dart'; +import 'package:billing/src/presentation/blocs/billing_state.dart'; +import 'package:billing/src/presentation/widgets/invoices_list_skeleton.dart'; +/// Page displaying invoices that are ready. class InvoiceReadyPage extends StatelessWidget { + /// Creates an [InvoiceReadyPage]. const InvoiceReadyPage({super.key}); @override @@ -21,7 +24,9 @@ class InvoiceReadyPage extends StatelessWidget { } } +/// View for the invoice ready page. class InvoiceReadyView extends StatelessWidget { + /// Creates an [InvoiceReadyView]. const InvoiceReadyView({super.key}); @override @@ -60,7 +65,7 @@ class InvoiceReadyView extends StatelessWidget { separatorBuilder: (BuildContext context, int index) => const SizedBox(height: 16), itemBuilder: (BuildContext context, int index) { - final BillingInvoice invoice = state.invoiceHistory[index]; + final Invoice invoice = state.invoiceHistory[index]; return _InvoiceSummaryCard(invoice: invoice); }, ); @@ -72,10 +77,17 @@ class InvoiceReadyView extends StatelessWidget { class _InvoiceSummaryCard extends StatelessWidget { const _InvoiceSummaryCard({required this.invoice}); - final BillingInvoice invoice; + + final Invoice invoice; @override 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( padding: const EdgeInsets.all(20), decoration: BoxDecoration( @@ -106,22 +118,26 @@ class _InvoiceSummaryCard extends StatelessWidget { borderRadius: BorderRadius.circular(20), ), child: Text( - 'READY', + invoice.status.value.toUpperCase(), style: UiTypography.titleUppercase4b.copyWith( color: UiColors.success, ), ), ), - Text(invoice.date, style: UiTypography.footnote2r.textTertiary), + Text(dateLabel, style: UiTypography.footnote2r.textTertiary), ], ), const SizedBox(height: 16), - Text(invoice.title, style: UiTypography.title2b.textPrimary), - const SizedBox(height: 8), Text( - invoice.locationAddress, - style: UiTypography.body2r.textSecondary, + invoice.invoiceNumber, + style: UiTypography.title2b.textPrimary, ), + const SizedBox(height: 8), + if (invoice.vendorName != null) + Text( + invoice.vendorName!, + style: UiTypography.body2r.textSecondary, + ), const Divider(height: 32), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -134,7 +150,7 @@ class _InvoiceSummaryCard extends StatelessWidget { style: UiTypography.titleUppercase4m.textSecondary, ), Text( - '\$${invoice.totalAmount.toStringAsFixed(2)}', + '\$${amountDollars.toStringAsFixed(2)}', style: UiTypography.title2b.primary, ), ], diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart index 3b29c4b5..0291a4f5 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart @@ -5,12 +5,14 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import '../blocs/billing_bloc.dart'; -import '../blocs/billing_state.dart'; -import '../widgets/invoices_list_skeleton.dart'; -import '../widgets/pending_invoices_section.dart'; +import 'package:billing/src/presentation/blocs/billing_bloc.dart'; +import 'package:billing/src/presentation/blocs/billing_state.dart'; +import 'package:billing/src/presentation/widgets/invoices_list_skeleton.dart'; +import 'package:billing/src/presentation/widgets/pending_invoices_section.dart'; +/// Page listing all invoices awaiting client approval. class PendingInvoicesPage extends StatelessWidget { + /// Creates a [PendingInvoicesPage]. const PendingInvoicesPage({super.key}); @override @@ -44,7 +46,7 @@ class PendingInvoicesPage extends StatelessWidget { UiConstants.space5, UiConstants.space5, UiConstants.space5, - 100, // Bottom padding for scroll clearance + 100, ), itemCount: state.pendingInvoices.length, 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. diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_header.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_header.dart index 59618c02..3cd46ddf 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_header.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_header.dart @@ -6,23 +6,26 @@ import 'package:flutter/material.dart'; class BillingHeader extends StatelessWidget { /// Creates a [BillingHeader]. const BillingHeader({ - required this.currentBill, - required this.savings, + required this.currentBillCents, + required this.savingsCents, required this.onBack, super.key, }); - /// The amount of the current bill. - final double currentBill; + /// The amount of the current bill in cents. + final int currentBillCents; - /// The amount saved in the current period. - final double savings; + /// The savings amount in cents. + final int savingsCents; /// Callback when the back button is pressed. final VoidCallback onBack; @override Widget build(BuildContext context) { + final double billDollars = currentBillCents / 100.0; + final double savingsDollars = savingsCents / 100.0; + return Container( padding: EdgeInsets.fromLTRB( UiConstants.space5, @@ -54,10 +57,9 @@ class BillingHeader extends StatelessWidget { ), const SizedBox(height: UiConstants.space1), Text( - '\$${currentBill.toStringAsFixed(2)}', + '\$${billDollars.toStringAsFixed(2)}', style: UiTypography.display1b.copyWith(color: UiColors.white), ), - const SizedBox(height: UiConstants.space2), Container( padding: const EdgeInsets.symmetric( @@ -79,7 +81,7 @@ class BillingHeader extends StatelessWidget { const SizedBox(width: UiConstants.space1), Text( t.client_billing.saved_amount( - amount: savings.toStringAsFixed(0), + amount: savingsDollars.toStringAsFixed(0), ), style: UiTypography.footnote2b.copyWith( color: UiColors.foreground, diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart index 2e34a81e..475bd5bb 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart @@ -5,87 +5,91 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import '../../blocs/shift_completion_review/shift_completion_review_bloc.dart'; -import '../../blocs/shift_completion_review/shift_completion_review_event.dart'; -import '../../blocs/shift_completion_review/shift_completion_review_state.dart'; -import '../../blocs/billing_bloc.dart'; -import '../../blocs/billing_event.dart'; +import 'package:billing/src/presentation/blocs/billing_bloc.dart'; +import 'package:billing/src/presentation/blocs/billing_event.dart'; +import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.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'; +/// Action buttons (approve / flag) at the bottom of the review page. class CompletionReviewActions extends StatelessWidget { + /// Creates a [CompletionReviewActions]. const CompletionReviewActions({required this.invoiceId, super.key}); + /// The invoice ID to act upon. final String invoiceId; @override Widget build(BuildContext context) { - return BlocProvider.value( - value: Modular.get(), + return BlocProvider( + create: (_) => Modular.get(), child: BlocConsumer( - listener: (BuildContext context, ShiftCompletionReviewState state) { - if (state.status == ShiftCompletionReviewStatus.success) { - final String message = state.message == 'approved' - ? t.client_billing.approved_success - : t.client_billing.flagged_success; - final UiSnackbarType type = state.message == 'approved' - ? UiSnackbarType.success - : UiSnackbarType.warning; + listener: (BuildContext context, ShiftCompletionReviewState state) { + if (state.status == ShiftCompletionReviewStatus.success) { + final String message = state.message == 'approved' + ? t.client_billing.approved_success + : t.client_billing.flagged_success; + final UiSnackbarType type = state.message == 'approved' + ? UiSnackbarType.success + : UiSnackbarType.warning; - UiSnackbar.show(context, message: message, type: type); - Modular.get().add(const BillingLoadStarted()); - Modular.to.toAwaitingApproval(); - } else if (state.status == ShiftCompletionReviewStatus.failure) { - UiSnackbar.show( - context, - message: state.errorMessage ?? t.errors.generic.unknown, - type: UiSnackbarType.error, - ); - } - }, - builder: (BuildContext context, ShiftCompletionReviewState state) { - final bool isLoading = - state.status == ShiftCompletionReviewStatus.loading; + UiSnackbar.show(context, message: message, type: type); + Modular.get().add(const BillingLoadStarted()); + Modular.to.toAwaitingApproval(); + } else if (state.status == ShiftCompletionReviewStatus.failure) { + UiSnackbar.show( + context, + message: state.errorMessage ?? t.errors.generic.unknown, + type: UiSnackbarType.error, + ); + } + }, + builder: (BuildContext context, ShiftCompletionReviewState state) { + final bool isLoading = + state.status == ShiftCompletionReviewStatus.loading; - return Row( - spacing: UiConstants.space2, - children: [ - Expanded( - child: UiButton.secondary( - text: t.client_billing.actions.flag_review, - leadingIcon: UiIcons.warning, - onPressed: isLoading - ? null - : () => _showFlagDialog(context, state), - size: UiButtonSize.large, - style: OutlinedButton.styleFrom( - foregroundColor: UiColors.destructive, - side: BorderSide.none, - ), - ), + return Row( + spacing: UiConstants.space2, + children: [ + Expanded( + child: UiButton.secondary( + text: t.client_billing.actions.flag_review, + leadingIcon: UiIcons.warning, + onPressed: isLoading + ? null + : () => _showFlagDialog(context, state), + size: UiButtonSize.large, + style: OutlinedButton.styleFrom( + foregroundColor: UiColors.destructive, + side: BorderSide.none, ), - Expanded( - child: UiButton.primary( - text: t.client_billing.actions.approve_pay, - leadingIcon: isLoading ? null : UiIcons.checkCircle, - isLoading: isLoading, - onPressed: isLoading - ? null - : () { - BlocProvider.of( - context, - ).add(ShiftCompletionReviewApproved(invoiceId)); - }, - size: UiButtonSize.large, - ), - ), - ], - ); - }, - ), + ), + ), + Expanded( + child: UiButton.primary( + text: t.client_billing.actions.approve_pay, + leadingIcon: isLoading ? null : UiIcons.checkCircle, + isLoading: isLoading, + onPressed: isLoading + ? null + : () { + BlocProvider.of( + context, + ).add(ShiftCompletionReviewApproved(invoiceId)); + }, + size: UiButtonSize.large, + ), + ), + ], + ); + }, + ), ); } - void _showFlagDialog(BuildContext context, ShiftCompletionReviewState state) { + void _showFlagDialog( + BuildContext context, ShiftCompletionReviewState state) { final TextEditingController controller = TextEditingController(); showDialog( context: context, diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_amount.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_amount.dart index 5b69d84f..81e762a1 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_amount.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_amount.dart @@ -2,15 +2,18 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.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 { - 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 Widget build(BuildContext context) { + final double amountDollars = amountCents / 100.0; + return Container( width: double.infinity, padding: const EdgeInsets.all(UiConstants.space6), @@ -27,13 +30,9 @@ class CompletionReviewAmount extends StatelessWidget { ), const SizedBox(height: UiConstants.space1), Text( - '\$${invoice.totalAmount.toStringAsFixed(2)}', + '\$${amountDollars.toStringAsFixed(2)}', 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, - ), ], ), ); diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_info.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_info.dart index 6f40f884..d28a7fc2 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_info.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_info.dart @@ -1,12 +1,20 @@ import 'package:design_system/design_system.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 { - 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 Widget build(BuildContext context) { @@ -14,12 +22,9 @@ class CompletionReviewInfo extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, spacing: UiConstants.space1, children: [ - _buildInfoRow(UiIcons.calendar, invoice.date), - _buildInfoRow( - UiIcons.clock, - '${invoice.startTime ?? "--"} - ${invoice.endTime ?? "--"}', - ), - _buildInfoRow(UiIcons.mapPin, invoice.locationAddress), + _buildInfoRow(UiIcons.calendar, dateLabel), + if (vendorName != null) + _buildInfoRow(UiIcons.building, vendorName!), ], ); } diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_worker_card.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_worker_card.dart index f2490ab2..8c146aea 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_worker_card.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_worker_card.dart @@ -1,126 +1,18 @@ -import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.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 { - const CompletionReviewWorkerCard({required this.worker, super.key}); - - final BillingWorkerRecord worker; + /// Creates a [CompletionReviewWorkerCard]. + const CompletionReviewWorkerCard({super.key}); @override Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.only(bottom: UiConstants.space3), - 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: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - 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: [ - Text( - worker.workerName, - style: UiTypography.body1b.textPrimary, - ), - Text( - worker.roleName, - style: UiTypography.footnote2r.textSecondary, - ), - ], - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - 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: [ - 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: [ - 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: () {}), - ], - ), - ], - ), - ); + // Placeholder until V2 API provides worker-level invoice data. + return const SizedBox.shrink(); } } diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart index fdbb5aa9..94275770 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart @@ -1,7 +1,8 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.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. class InvoiceHistorySection extends StatelessWidget { @@ -9,7 +10,7 @@ class InvoiceHistorySection extends StatelessWidget { const InvoiceHistorySection({required this.invoices, super.key}); /// The list of historical invoices. - final List invoices; + final List invoices; @override Widget build(BuildContext context) { @@ -36,10 +37,10 @@ class InvoiceHistorySection extends StatelessWidget { ), child: Column( children: invoices.asMap().entries.map(( - MapEntry entry, + MapEntry entry, ) { final int index = entry.key; - final BillingInvoice invoice = entry.value; + final Invoice invoice = entry.value; return Column( children: [ if (index > 0) @@ -58,10 +59,18 @@ class InvoiceHistorySection extends StatelessWidget { class _InvoiceItem extends StatelessWidget { const _InvoiceItem({required this.invoice}); - final BillingInvoice invoice; + final Invoice invoice; @override 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( padding: const EdgeInsets.symmetric( horizontal: UiConstants.space4, @@ -86,11 +95,11 @@ class _InvoiceItem extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(invoice.title, style: UiTypography.body1r.textPrimary), Text( - invoice.date, - style: UiTypography.footnote2r.textSecondary, + invoice.invoiceNumber, + style: UiTypography.body1r.textPrimary, ), + Text(dateLabel, style: UiTypography.footnote2r.textSecondary), ], ), ), @@ -98,7 +107,7 @@ class _InvoiceItem extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - '\$${invoice.totalAmount.toStringAsFixed(2)}', + '\$${amountDollars.toStringAsFixed(2)}', style: UiTypography.body1b.textPrimary.copyWith(fontSize: 15), ), _StatusBadge(status: invoice.status), @@ -113,11 +122,11 @@ class _InvoiceItem extends StatelessWidget { class _StatusBadge extends StatelessWidget { const _StatusBadge({required this.status}); - final String status; + final InvoiceStatus status; @override Widget build(BuildContext context) { - final bool isPaid = status.toUpperCase() == 'PAID'; + final bool isPaid = status == InvoiceStatus.paid; return Container( padding: const EdgeInsets.symmetric( horizontal: UiConstants.space1 + 2, diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/payment_method_card.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/payment_method_card.dart index 346380e7..63696c68 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/payment_method_card.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/payment_method_card.dart @@ -3,8 +3,9 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.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. class PaymentMethodCard extends StatelessWidget { @@ -15,8 +16,8 @@ class PaymentMethodCard extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (BuildContext context, BillingState state) { - final List accounts = state.bankAccounts; - final BusinessBankAccount? account = + final List accounts = state.bankAccounts; + final BillingAccount? account = accounts.isNotEmpty ? accounts.first : null; if (account == null) { @@ -24,11 +25,10 @@ class PaymentMethodCard extends StatelessWidget { } final String bankLabel = - account.bankName.isNotEmpty == true ? account.bankName : '----'; + account.bankName.isNotEmpty ? account.bankName : '----'; final String last4 = - account.last4.isNotEmpty == true ? account.last4 : '----'; + account.last4?.isNotEmpty == true ? account.last4! : '----'; final bool isPrimary = account.isPrimary; - final String expiryLabel = _formatExpiry(account.expiryTime); return Container( padding: const EdgeInsets.all(UiConstants.space4), @@ -87,11 +87,11 @@ class PaymentMethodCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '•••• $last4', + '\u2022\u2022\u2022\u2022 $last4', style: UiTypography.body2b.textPrimary, ), Text( - t.client_billing.expires(date: expiryLabel), + account.accountType.name.toUpperCase(), 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'; - } } diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart index 4ce1ee12..3b594017 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart @@ -2,9 +2,9 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; - -import '../models/billing_invoice_model.dart'; +import 'package:krow_domain/krow_domain.dart'; /// Section showing a banner for invoices awaiting approval. class PendingInvoicesSection extends StatelessWidget { @@ -12,7 +12,7 @@ class PendingInvoicesSection extends StatelessWidget { const PendingInvoicesSection({required this.invoices, super.key}); /// The list of pending invoices. - final List invoices; + final List invoices; @override Widget build(BuildContext context) { @@ -93,10 +93,17 @@ class PendingInvoiceCard extends StatelessWidget { /// Creates a [PendingInvoiceCard]. const PendingInvoiceCard({required this.invoice, super.key}); - final BillingInvoice invoice; + /// The invoice to display. + final Invoice invoice; @override 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( decoration: BoxDecoration( color: UiColors.white, @@ -108,42 +115,33 @@ class PendingInvoiceCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(invoice.title, style: UiTypography.headline4b.textPrimary), + Text( + invoice.invoiceNumber, + style: UiTypography.headline4b.textPrimary, + ), const SizedBox(height: UiConstants.space3), - Row( - children: [ - const Icon( - UiIcons.mapPin, - size: 16, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space2), - Expanded( - child: Text( - invoice.locationAddress, - style: UiTypography.footnote2r.textSecondary, - maxLines: 1, - overflow: TextOverflow.ellipsis, + if (invoice.vendorName != null) ...[ + Row( + children: [ + const Icon( + UiIcons.building, + size: 16, + color: UiColors.iconSecondary, ), - ), - ], - ), - const SizedBox(height: UiConstants.space2), - Row( - children: [ - Text( - invoice.clientName, - style: UiTypography.footnote2r.textSecondary, - ), - const SizedBox(width: UiConstants.space2), - Text('•', style: UiTypography.footnote2r.textInactive), - const SizedBox(width: UiConstants.space2), - Text( - invoice.date, - style: UiTypography.footnote2r.textSecondary, - ), - ], - ), + const SizedBox(width: UiConstants.space2), + Expanded( + child: Text( + invoice.vendorName!, + style: UiTypography.footnote2r.textSecondary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: UiConstants.space2), + ], + Text(dateLabel, style: UiTypography.footnote2r.textSecondary), const SizedBox(height: UiConstants.space3), Row( children: [ @@ -157,7 +155,7 @@ class PendingInvoiceCard extends StatelessWidget { ), const SizedBox(width: UiConstants.space2), Text( - t.client_billing.pending_badge.toUpperCase(), + invoice.status.value.toUpperCase(), style: UiTypography.titleUppercase4b.copyWith( color: UiColors.textWarning, ), @@ -168,40 +166,10 @@ class PendingInvoiceCard extends StatelessWidget { const Divider(height: 1, color: UiColors.border), Padding( padding: const EdgeInsets.symmetric(vertical: UiConstants.space4), - child: Row( - children: [ - Expanded( - child: _buildStatItem( - 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, - ), - ), - ], + child: _buildStatItem( + UiIcons.dollar, + '\$${amountDollars.toStringAsFixed(2)}', + t.client_billing.stats.total, ), ), const Divider(height: 1, color: UiColors.border), diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/spending_breakdown_card.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/spending_breakdown_card.dart index d46b48c2..56999845 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/spending_breakdown_card.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/spending_breakdown_card.dart @@ -3,10 +3,10 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../blocs/billing_bloc.dart'; -import '../blocs/billing_state.dart'; -import '../blocs/billing_event.dart'; -import '../models/spending_breakdown_model.dart'; + +import 'package:billing/src/presentation/blocs/billing_bloc.dart'; +import 'package:billing/src/presentation/blocs/billing_event.dart'; +import 'package:billing/src/presentation/blocs/billing_state.dart'; /// Card showing the spending breakdown for the current period. class SpendingBreakdownCard extends StatefulWidget { @@ -37,10 +37,7 @@ class _SpendingBreakdownCardState extends State Widget build(BuildContext context) { return BlocBuilder( builder: (BuildContext context, BillingState state) { - final double total = state.spendingBreakdown.fold( - 0.0, - (double sum, SpendingBreakdownItem item) => sum + item.amount, - ); + final double totalDollars = state.spendTotalCents / 100.0; return Container( padding: const EdgeInsets.all(UiConstants.space4), @@ -97,11 +94,12 @@ class _SpendingBreakdownCardState extends State ), dividerColor: UiColors.transparent, onTap: (int index) { - final BillingPeriod period = - index == 0 ? BillingPeriod.week : BillingPeriod.month; - ReadContext(context).read().add( - BillingPeriodChanged(period), - ); + final BillingPeriodTab tab = index == 0 + ? BillingPeriodTab.week + : BillingPeriodTab.month; + ReadContext(context) + .read() + .add(BillingPeriodChanged(tab)); }, tabs: [ Tab(text: t.client_billing.week), @@ -112,8 +110,8 @@ class _SpendingBreakdownCardState extends State ], ), const SizedBox(height: UiConstants.space4), - ...state.spendingBreakdown.map( - (SpendingBreakdownItem item) => _buildBreakdownRow(item), + ...state.spendBreakdown.map( + (SpendItem item) => _buildBreakdownRow(item), ), const Padding( padding: EdgeInsets.symmetric(vertical: UiConstants.space2), @@ -127,7 +125,7 @@ class _SpendingBreakdownCardState extends State style: UiTypography.body2b.textPrimary, ), Text( - '\$${total.toStringAsFixed(2)}', + '\$${totalDollars.toStringAsFixed(2)}', style: UiTypography.body2b.textPrimary, ), ], @@ -139,7 +137,8 @@ class _SpendingBreakdownCardState extends State ); } - Widget _buildBreakdownRow(SpendingBreakdownItem item) { + Widget _buildBreakdownRow(SpendItem item) { + final double amountDollars = item.amountCents / 100.0; return Padding( padding: const EdgeInsets.only(bottom: UiConstants.space2), child: Row( @@ -151,14 +150,14 @@ class _SpendingBreakdownCardState extends State children: [ Text(item.category, style: UiTypography.body2r.textPrimary), Text( - t.client_billing.hours(count: item.hours), + '${item.percentage.toStringAsFixed(1)}%', style: UiTypography.footnote2r.textSecondary, ), ], ), ), Text( - '\$${item.amount.toStringAsFixed(2)}', + '\$${amountDollars.toStringAsFixed(2)}', style: UiTypography.body2m.textPrimary, ), ], diff --git a/apps/mobile/packages/features/client/billing/pubspec.yaml b/apps/mobile/packages/features/client/billing/pubspec.yaml index d7fdc295..0b07cb2b 100644 --- a/apps/mobile/packages/features/client/billing/pubspec.yaml +++ b/apps/mobile/packages/features/client/billing/pubspec.yaml @@ -10,12 +10,12 @@ environment: dependencies: flutter: sdk: flutter - + # Architecture flutter_modular: ^6.3.2 flutter_bloc: ^8.1.3 equatable: ^2.0.5 - + # Shared packages design_system: path: ../../../design_system @@ -25,12 +25,10 @@ dependencies: path: ../../../domain krow_core: path: ../../../core - krow_data_connect: - path: ../../../data_connect - + # UI intl: ^0.20.0 - firebase_data_connect: ^0.2.2+1 + dev_dependencies: flutter_test: sdk: flutter diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/coverage_module.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/coverage_module.dart index cd741711..3d7e2db1 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/coverage_module.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/coverage_module.dart @@ -1,26 +1,35 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; -import 'data/repositories_impl/coverage_repository_impl.dart'; -import 'domain/repositories/coverage_repository.dart'; -import 'domain/usecases/get_coverage_stats_usecase.dart'; -import 'domain/usecases/get_shifts_for_date_usecase.dart'; -import 'presentation/blocs/coverage_bloc.dart'; -import 'presentation/pages/coverage_page.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:client_coverage/src/data/repositories_impl/coverage_repository_impl.dart'; +import 'package:client_coverage/src/domain/repositories/coverage_repository.dart'; +import 'package:client_coverage/src/domain/usecases/cancel_late_worker_usecase.dart'; +import 'package:client_coverage/src/domain/usecases/get_coverage_stats_usecase.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. +/// +/// Uses the V2 REST API via [BaseApiService] for all backend access. class CoverageModule extends Module { @override - List get imports => [DataConnectModule()]; + List get imports => [CoreModule()]; @override void binds(Injector i) { // Repositories - i.addLazySingleton(CoverageRepositoryImpl.new); + i.addLazySingleton( + () => CoverageRepositoryImpl(apiService: i.get()), + ); // Use Cases i.addLazySingleton(GetShiftsForDateUseCase.new); i.addLazySingleton(GetCoverageStatsUseCase.new); + i.addLazySingleton(SubmitWorkerReviewUseCase.new); + i.addLazySingleton(CancelLateWorkerUseCase.new); // BLoCs i.addLazySingleton(CoverageBloc.new); @@ -28,7 +37,9 @@ class CoverageModule extends Module { @override void routes(RouteManager r) { - r.child(ClientPaths.childRoute(ClientPaths.coverage, ClientPaths.coverage), - child: (_) => const CoveragePage()); + r.child( + ClientPaths.childRoute(ClientPaths.coverage, ClientPaths.coverage), + child: (_) => const CoveragePage(), + ); } } diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart index 562bf308..c6fa62fd 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart @@ -1,62 +1,89 @@ -// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs -import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/repositories/coverage_repository.dart'; -/// Implementation of [CoverageRepository] that delegates to [dc.CoverageConnectorRepository]. +import 'package:client_coverage/src/domain/repositories/coverage_repository.dart'; + +/// V2 API implementation of [CoverageRepository]. /// -/// This implementation follows the "Buffer Layer" pattern by using a dedicated -/// connector repository from the data_connect package. +/// Uses [BaseApiService] with [V2ApiEndpoints] for all backend access. class CoverageRepositoryImpl implements CoverageRepository { + /// Creates a [CoverageRepositoryImpl]. + CoverageRepositoryImpl({required BaseApiService apiService}) + : _apiService = apiService; - CoverageRepositoryImpl({ - dc.CoverageConnectorRepository? connectorRepository, - dc.DataConnectService? service, - }) : _connectorRepository = connectorRepository ?? - dc.DataConnectService.instance.getCoverageRepository(), - _service = service ?? dc.DataConnectService.instance; - final dc.CoverageConnectorRepository _connectorRepository; - final dc.DataConnectService _service; + final BaseApiService _apiService; @override - Future> getShiftsForDate({required DateTime date}) async { - final String businessId = await _service.getBusinessId(); - return _connectorRepository.getShiftsForDate( - businessId: businessId, - date: date, + Future> getShiftsForDate({ + required DateTime date, + }) async { + final String dateStr = + '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + final ApiResponse response = await _apiService.get( + V2ApiEndpoints.clientCoverage, + params: {'date': dateStr}, ); + final List items = response.data['items'] as List; + return items + .map((dynamic e) => + ShiftWithWorkers.fromJson(e as Map)) + .toList(); } @override Future getCoverageStats({required DateTime date}) async { - final List shifts = await getShiftsForDate(date: date); - - final int totalNeeded = shifts.fold( - 0, - (int sum, CoverageShift shift) => sum + shift.workersNeeded, + final String dateStr = + '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + final ApiResponse response = await _apiService.get( + V2ApiEndpoints.clientCoverageStats, + params: {'date': dateStr}, ); + return CoverageStats.fromJson(response.data as Map); + } - final List allWorkers = - shifts.expand((CoverageShift shift) => shift.workers).toList(); - final int totalConfirmed = allWorkers.length; - final int checkedIn = allWorkers - .where((CoverageWorker w) => w.status == CoverageWorkerStatus.checkedIn) - .length; - final int enRoute = allWorkers - .where((CoverageWorker w) => - w.status == CoverageWorkerStatus.confirmed && w.checkInTime == null) - .length; - final int late = allWorkers - .where((CoverageWorker w) => w.status == CoverageWorkerStatus.late) - .length; + @override + Future submitWorkerReview({ + required String staffId, + required int rating, + String? assignmentId, + String? feedback, + List? issueFlags, + bool? markAsFavorite, + }) async { + final Map body = { + 'staffId': staffId, + 'rating': rating, + }; + if (assignmentId != null) { + body['assignmentId'] = assignmentId; + } + if (feedback != null) { + body['feedback'] = feedback; + } + if (issueFlags != null && issueFlags.isNotEmpty) { + body['issueFlags'] = issueFlags; + } + if (markAsFavorite != null) { + body['markAsFavorite'] = markAsFavorite; + } + await _apiService.post( + V2ApiEndpoints.clientCoverageReviews, + data: body, + ); + } - return CoverageStats( - totalNeeded: totalNeeded, - totalConfirmed: totalConfirmed, - checkedIn: checkedIn, - enRoute: enRoute, - late: late, + @override + Future cancelLateWorker({ + required String assignmentId, + String? reason, + }) async { + final Map body = {}; + if (reason != null) { + body['reason'] = reason; + } + await _apiService.post( + V2ApiEndpoints.clientCoverageCancelLateWorker(assignmentId), + data: body, ); } } - diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/cancel_late_worker_arguments.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/cancel_late_worker_arguments.dart new file mode 100644 index 00000000..a263c707 --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/cancel_late_worker_arguments.dart @@ -0,0 +1,19 @@ +import 'package:krow_core/core.dart'; + +/// Arguments for cancelling a late worker's assignment. +class CancelLateWorkerArguments extends UseCaseArgument { + /// Creates [CancelLateWorkerArguments]. + const CancelLateWorkerArguments({ + required this.assignmentId, + this.reason, + }); + + /// The assignment ID to cancel. + final String assignmentId; + + /// Optional cancellation reason. + final String? reason; + + @override + List get props => [assignmentId, reason]; +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/get_coverage_stats_arguments.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/get_coverage_stats_arguments.dart index 105733c3..5b803ff9 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/get_coverage_stats_arguments.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/get_coverage_stats_arguments.dart @@ -1,9 +1,6 @@ import 'package:krow_core/core.dart'; /// Arguments for fetching coverage statistics for a specific date. -/// -/// This argument class encapsulates the date parameter required by -/// the [GetCoverageStatsUseCase]. class GetCoverageStatsArguments extends UseCaseArgument { /// Creates [GetCoverageStatsArguments]. const GetCoverageStatsArguments({required this.date}); diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/get_shifts_for_date_arguments.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/get_shifts_for_date_arguments.dart index ad71b56e..bac6aa4b 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/get_shifts_for_date_arguments.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/get_shifts_for_date_arguments.dart @@ -1,9 +1,6 @@ import 'package:krow_core/core.dart'; /// Arguments for fetching shifts for a specific date. -/// -/// This argument class encapsulates the date parameter required by -/// the [GetShiftsForDateUseCase]. class GetShiftsForDateArguments extends UseCaseArgument { /// Creates [GetShiftsForDateArguments]. const GetShiftsForDateArguments({required this.date}); diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/submit_worker_review_arguments.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/submit_worker_review_arguments.dart new file mode 100644 index 00000000..74027e83 --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/submit_worker_review_arguments.dart @@ -0,0 +1,42 @@ +import 'package:krow_core/core.dart'; + +/// Arguments for submitting a worker review from the coverage page. +class SubmitWorkerReviewArguments extends UseCaseArgument { + /// Creates [SubmitWorkerReviewArguments]. + const SubmitWorkerReviewArguments({ + required this.staffId, + required this.rating, + this.assignmentId, + this.feedback, + this.issueFlags, + this.markAsFavorite, + }); + + /// The ID of the worker being reviewed. + final String staffId; + + /// The rating value (1-5). + final int rating; + + /// The assignment ID, if reviewing for a specific assignment. + final String? assignmentId; + + /// Optional text feedback. + final String? feedback; + + /// Optional list of issue flag labels. + final List? issueFlags; + + /// Whether to mark/unmark the worker as a favorite. + final bool? markAsFavorite; + + @override + List get props => [ + staffId, + rating, + assignmentId, + feedback, + issueFlags, + markAsFavorite, + ]; +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository.dart index f5c340b3..c82bd45a 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository.dart @@ -2,22 +2,35 @@ import 'package:krow_domain/krow_domain.dart'; /// Repository interface for coverage-related operations. /// -/// This interface defines the contract for accessing coverage data, +/// Defines the contract for accessing coverage data via the V2 REST API, /// acting as a boundary between the Domain and Data layers. -/// It allows the Domain layer to remain independent of specific data sources. -/// -/// Implementation of this interface must delegate all data access through -/// the `packages/data_connect` layer, ensuring compliance with Clean Architecture. abstract interface class CoverageRepository { - /// Fetches shifts for a specific date. - /// - /// Returns a list of [CoverageShift] entities representing all shifts - /// scheduled for the given [date]. - Future> getShiftsForDate({required DateTime date}); + /// Fetches shifts with assigned workers for a specific [date]. + Future> getShiftsForDate({required DateTime date}); - /// Fetches coverage statistics for a specific date. - /// - /// Returns [CoverageStats] containing aggregated metrics including - /// total workers needed, confirmed, checked in, en route, and late. + /// Fetches aggregated coverage statistics for a specific [date]. Future getCoverageStats({required DateTime date}); + + /// Submits a worker review from the coverage page. + /// + /// [staffId] identifies the worker being reviewed. + /// [rating] is an integer from 1 to 5. + /// Optional fields: [assignmentId], [feedback], [issueFlags], [markAsFavorite]. + Future submitWorkerReview({ + required String staffId, + required int rating, + String? assignmentId, + String? feedback, + List? issueFlags, + bool? markAsFavorite, + }); + + /// Cancels a late worker's assignment. + /// + /// [assignmentId] identifies the assignment to cancel. + /// [reason] is an optional cancellation reason. + Future cancelLateWorker({ + required String assignmentId, + String? reason, + }); } diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/cancel_late_worker_usecase.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/cancel_late_worker_usecase.dart new file mode 100644 index 00000000..2cc4e509 --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/cancel_late_worker_usecase.dart @@ -0,0 +1,23 @@ +import 'package:krow_core/core.dart'; + +import 'package:client_coverage/src/domain/arguments/cancel_late_worker_arguments.dart'; +import 'package:client_coverage/src/domain/repositories/coverage_repository.dart'; + +/// Use case for cancelling a late worker's assignment. +/// +/// Delegates to [CoverageRepository] to cancel the assignment via V2 API. +class CancelLateWorkerUseCase + implements UseCase { + /// Creates a [CancelLateWorkerUseCase]. + CancelLateWorkerUseCase(this._repository); + + final CoverageRepository _repository; + + @override + Future call(CancelLateWorkerArguments arguments) { + return _repository.cancelLateWorker( + assignmentId: arguments.assignmentId, + reason: arguments.reason, + ); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_coverage_stats_usecase.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_coverage_stats_usecase.dart index a2fa4a50..b26034aa 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_coverage_stats_usecase.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_coverage_stats_usecase.dart @@ -1,20 +1,12 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../arguments/get_coverage_stats_arguments.dart'; -import '../repositories/coverage_repository.dart'; +import 'package:client_coverage/src/domain/arguments/get_coverage_stats_arguments.dart'; +import 'package:client_coverage/src/domain/repositories/coverage_repository.dart'; -/// Use case for fetching coverage statistics for a specific date. +/// Use case for fetching aggregated coverage statistics for a specific date. /// -/// This use case encapsulates the logic for retrieving coverage metrics including -/// total workers needed, confirmed, checked in, en route, and late. -/// It delegates the data retrieval to the [CoverageRepository]. -/// -/// Follows the KROW Clean Architecture pattern by: -/// - Extending from [UseCase] base class -/// - Using [GetCoverageStatsArguments] for input -/// - Returning domain entities ([CoverageStats]) -/// - Delegating to repository abstraction +/// Delegates to [CoverageRepository] and returns a [CoverageStats] entity. class GetCoverageStatsUseCase implements UseCase { /// Creates a [GetCoverageStatsUseCase]. diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_shifts_for_date_usecase.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_shifts_for_date_usecase.dart index 1b17c969..7e021a18 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_shifts_for_date_usecase.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_shifts_for_date_usecase.dart @@ -1,27 +1,21 @@ import 'package:krow_core/core.dart'; -import '../arguments/get_shifts_for_date_arguments.dart'; -import '../repositories/coverage_repository.dart'; import 'package:krow_domain/krow_domain.dart'; -/// Use case for fetching shifts for a specific date. +import 'package:client_coverage/src/domain/arguments/get_shifts_for_date_arguments.dart'; +import 'package:client_coverage/src/domain/repositories/coverage_repository.dart'; + +/// Use case for fetching shifts with workers for a specific date. /// -/// This use case encapsulates the logic for retrieving all shifts scheduled for a given date. -/// It delegates the data retrieval to the [CoverageRepository]. -/// -/// Follows the KROW Clean Architecture pattern by: -/// - Extending from [UseCase] base class -/// - Using [GetShiftsForDateArguments] for input -/// - Returning domain entities ([CoverageShift]) -/// - Delegating to repository abstraction +/// Delegates to [CoverageRepository] and returns V2 [ShiftWithWorkers] entities. class GetShiftsForDateUseCase - implements UseCase> { + implements UseCase> { /// Creates a [GetShiftsForDateUseCase]. GetShiftsForDateUseCase(this._repository); final CoverageRepository _repository; @override - Future> call(GetShiftsForDateArguments arguments) { + Future> call(GetShiftsForDateArguments arguments) { return _repository.getShiftsForDate(date: arguments.date); } } diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/submit_worker_review_usecase.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/submit_worker_review_usecase.dart new file mode 100644 index 00000000..be9a17d1 --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/submit_worker_review_usecase.dart @@ -0,0 +1,30 @@ +import 'package:krow_core/core.dart'; + +import 'package:client_coverage/src/domain/arguments/submit_worker_review_arguments.dart'; +import 'package:client_coverage/src/domain/repositories/coverage_repository.dart'; + +/// Use case for submitting a worker review from the coverage page. +/// +/// Validates the rating range and delegates to [CoverageRepository]. +class SubmitWorkerReviewUseCase + implements UseCase { + /// Creates a [SubmitWorkerReviewUseCase]. + SubmitWorkerReviewUseCase(this._repository); + + final CoverageRepository _repository; + + @override + Future call(SubmitWorkerReviewArguments arguments) async { + if (arguments.rating < 1 || arguments.rating > 5) { + throw ArgumentError('Rating must be between 1 and 5'); + } + return _repository.submitWorkerReview( + staffId: arguments.staffId, + rating: arguments.rating, + assignmentId: arguments.assignmentId, + feedback: arguments.feedback, + issueFlags: arguments.issueFlags, + markAsFavorite: arguments.markAsFavorite, + ); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_bloc.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_bloc.dart index c7105bd5..96bc79d4 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_bloc.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_bloc.dart @@ -1,35 +1,46 @@ import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../domain/arguments/get_coverage_stats_arguments.dart'; -import '../../domain/arguments/get_shifts_for_date_arguments.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/usecases/get_coverage_stats_usecase.dart'; -import '../../domain/usecases/get_shifts_for_date_usecase.dart'; -import 'coverage_event.dart'; -import 'coverage_state.dart'; + +import 'package:client_coverage/src/domain/arguments/cancel_late_worker_arguments.dart'; +import 'package:client_coverage/src/domain/arguments/get_coverage_stats_arguments.dart'; +import 'package:client_coverage/src/domain/arguments/get_shifts_for_date_arguments.dart'; +import 'package:client_coverage/src/domain/arguments/submit_worker_review_arguments.dart'; +import 'package:client_coverage/src/domain/usecases/cancel_late_worker_usecase.dart'; +import 'package:client_coverage/src/domain/usecases/get_coverage_stats_usecase.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_event.dart'; +import 'package:client_coverage/src/presentation/blocs/coverage_state.dart'; /// BLoC for managing coverage feature state. /// -/// This BLoC handles: -/// - Loading shifts for a specific date -/// - Loading coverage statistics -/// - Refreshing coverage data +/// Handles loading shifts, coverage statistics, worker reviews, +/// and late-worker cancellation for a selected date. class CoverageBloc extends Bloc with BlocErrorHandler { /// Creates a [CoverageBloc]. CoverageBloc({ required GetShiftsForDateUseCase getShiftsForDate, required GetCoverageStatsUseCase getCoverageStats, - }) : _getShiftsForDate = getShiftsForDate, + required SubmitWorkerReviewUseCase submitWorkerReview, + required CancelLateWorkerUseCase cancelLateWorker, + }) : _getShiftsForDate = getShiftsForDate, _getCoverageStats = getCoverageStats, + _submitWorkerReview = submitWorkerReview, + _cancelLateWorker = cancelLateWorker, super(const CoverageState()) { on(_onLoadRequested); on(_onRefreshRequested); on(_onRepostShiftRequested); + on(_onSubmitReviewRequested); + on(_onCancelLateWorkerRequested); } final GetShiftsForDateUseCase _getShiftsForDate; final GetCoverageStatsUseCase _getCoverageStats; + final SubmitWorkerReviewUseCase _submitWorkerReview; + final CancelLateWorkerUseCase _cancelLateWorker; /// Handles the load requested event. Future _onLoadRequested( @@ -47,12 +58,15 @@ class CoverageBloc extends Bloc emit: emit.call, action: () async { // Fetch shifts and stats concurrently - final List results = await Future.wait(>[ - _getShiftsForDate(GetShiftsForDateArguments(date: event.date)), - _getCoverageStats(GetCoverageStatsArguments(date: event.date)), - ]); + final List results = await Future.wait( + >[ + _getShiftsForDate(GetShiftsForDateArguments(date: event.date)), + _getCoverageStats(GetCoverageStatsArguments(date: event.date)), + ], + ); - final List shifts = results[0] as List; + final List shifts = + results[0] as List; final CoverageStats stats = results[1] as CoverageStats; emit( @@ -86,17 +100,14 @@ class CoverageBloc extends Bloc CoverageRepostShiftRequested event, Emitter emit, ) async { - // In a real implementation, this would call a repository method. - // For this audit completion, we simulate the action and refresh the state. emit(state.copyWith(status: CoverageStatus.loading)); await handleError( emit: emit.call, action: () async { - // Simulating API call delay + // TODO: Implement re-post shift via V2 API when endpoint is available. await Future.delayed(const Duration(seconds: 1)); - - // Since we don't have a real re-post mutation yet, we just refresh + if (state.selectedDate != null) { add(CoverageLoadRequested(date: state.selectedDate!)); } @@ -107,5 +118,70 @@ class CoverageBloc extends Bloc ), ); } -} + /// Handles the submit review requested event. + Future _onSubmitReviewRequested( + CoverageSubmitReviewRequested event, + Emitter emit, + ) async { + emit(state.copyWith(writeStatus: CoverageWriteStatus.submitting)); + + await handleError( + emit: emit.call, + action: () async { + await _submitWorkerReview( + SubmitWorkerReviewArguments( + staffId: event.staffId, + rating: event.rating, + assignmentId: event.assignmentId, + feedback: event.feedback, + issueFlags: event.issueFlags, + markAsFavorite: event.markAsFavorite, + ), + ); + + emit(state.copyWith(writeStatus: CoverageWriteStatus.submitted)); + + // Refresh coverage data after successful review. + if (state.selectedDate != null) { + add(CoverageLoadRequested(date: state.selectedDate!)); + } + }, + onError: (String errorKey) => state.copyWith( + writeStatus: CoverageWriteStatus.submitFailure, + writeErrorMessage: errorKey, + ), + ); + } + + /// Handles the cancel late worker requested event. + Future _onCancelLateWorkerRequested( + CoverageCancelLateWorkerRequested event, + Emitter emit, + ) async { + emit(state.copyWith(writeStatus: CoverageWriteStatus.submitting)); + + await handleError( + emit: emit.call, + action: () async { + await _cancelLateWorker( + CancelLateWorkerArguments( + assignmentId: event.assignmentId, + reason: event.reason, + ), + ); + + emit(state.copyWith(writeStatus: CoverageWriteStatus.submitted)); + + // Refresh coverage data after cancellation. + if (state.selectedDate != null) { + add(CoverageLoadRequested(date: state.selectedDate!)); + } + }, + onError: (String errorKey) => state.copyWith( + writeStatus: CoverageWriteStatus.submitFailure, + writeErrorMessage: errorKey, + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_event.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_event.dart index 1900aec9..b558f332 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_event.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_event.dart @@ -38,3 +38,62 @@ final class CoverageRepostShiftRequested extends CoverageEvent { @override List get props => [shiftId]; } + +/// Event to submit a worker review. +final class CoverageSubmitReviewRequested extends CoverageEvent { + /// Creates a [CoverageSubmitReviewRequested] event. + const CoverageSubmitReviewRequested({ + required this.staffId, + required this.rating, + this.assignmentId, + this.feedback, + this.issueFlags, + this.markAsFavorite, + }); + + /// The worker ID to review. + final String staffId; + + /// Rating from 1 to 5. + final int rating; + + /// Optional assignment ID for context. + final String? assignmentId; + + /// Optional text feedback. + final String? feedback; + + /// Optional issue flag labels. + final List? issueFlags; + + /// Whether to mark/unmark as favorite. + final bool? markAsFavorite; + + @override + List get props => [ + staffId, + rating, + assignmentId, + feedback, + issueFlags, + markAsFavorite, + ]; +} + +/// Event to cancel a late worker's assignment. +final class CoverageCancelLateWorkerRequested extends CoverageEvent { + /// Creates a [CoverageCancelLateWorkerRequested] event. + const CoverageCancelLateWorkerRequested({ + required this.assignmentId, + this.reason, + }); + + /// The assignment ID to cancel. + final String assignmentId; + + /// Optional reason for cancellation. + final String? reason; + + @override + List get props => [assignmentId, reason]; +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_state.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_state.dart index e6b99656..8e82eb0f 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_state.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_state.dart @@ -16,15 +16,32 @@ enum CoverageStatus { failure, } +/// Status of a write (review / cancel) operation. +enum CoverageWriteStatus { + /// No write operation in progress. + idle, + + /// A write operation is in progress. + submitting, + + /// The write operation succeeded. + submitted, + + /// The write operation failed. + submitFailure, +} + /// State for the coverage feature. final class CoverageState extends Equatable { /// Creates a [CoverageState]. const CoverageState({ this.status = CoverageStatus.initial, this.selectedDate, - this.shifts = const [], + this.shifts = const [], this.stats, this.errorMessage, + this.writeStatus = CoverageWriteStatus.idle, + this.writeErrorMessage, }); /// The current status of data loading. @@ -33,8 +50,8 @@ final class CoverageState extends Equatable { /// The currently selected date. final DateTime? selectedDate; - /// The list of shifts for the selected date. - final List shifts; + /// The list of shifts with assigned workers for the selected date. + final List shifts; /// Coverage statistics for the selected date. final CoverageStats? stats; @@ -42,13 +59,21 @@ final class CoverageState extends Equatable { /// Error message if status is failure. final String? errorMessage; + /// Status of the current write operation (review or cancel). + final CoverageWriteStatus writeStatus; + + /// Error message from a failed write operation. + final String? writeErrorMessage; + /// Creates a copy of this state with the given fields replaced. CoverageState copyWith({ CoverageStatus? status, DateTime? selectedDate, - List? shifts, + List? shifts, CoverageStats? stats, String? errorMessage, + CoverageWriteStatus? writeStatus, + String? writeErrorMessage, }) { return CoverageState( status: status ?? this.status, @@ -56,6 +81,8 @@ final class CoverageState extends Equatable { shifts: shifts ?? this.shifts, stats: stats ?? this.stats, errorMessage: errorMessage ?? this.errorMessage, + writeStatus: writeStatus ?? this.writeStatus, + writeErrorMessage: writeErrorMessage ?? this.writeErrorMessage, ); } @@ -66,5 +93,7 @@ final class CoverageState extends Equatable { shifts, stats, errorMessage, + writeStatus, + writeErrorMessage, ]; } diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart index 529bd360..291234f6 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart @@ -5,15 +5,15 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:intl/intl.dart'; -import '../blocs/coverage_bloc.dart'; -import '../blocs/coverage_event.dart'; -import '../blocs/coverage_state.dart'; -import '../widgets/coverage_calendar_selector.dart'; -import '../widgets/coverage_page_skeleton.dart'; -import '../widgets/coverage_quick_stats.dart'; -import '../widgets/coverage_shift_list.dart'; -import '../widgets/coverage_stats_header.dart'; -import '../widgets/late_workers_alert.dart'; +import 'package:client_coverage/src/presentation/blocs/coverage_bloc.dart'; +import 'package:client_coverage/src/presentation/blocs/coverage_event.dart'; +import 'package:client_coverage/src/presentation/blocs/coverage_state.dart'; +import 'package:client_coverage/src/presentation/widgets/coverage_calendar_selector.dart'; +import 'package:client_coverage/src/presentation/widgets/coverage_page_skeleton.dart'; +import 'package:client_coverage/src/presentation/widgets/coverage_quick_stats.dart'; +import 'package:client_coverage/src/presentation/widgets/coverage_shift_list.dart'; +import 'package:client_coverage/src/presentation/widgets/coverage_stats_header.dart'; +import 'package:client_coverage/src/presentation/widgets/late_workers_alert.dart'; /// Page for displaying daily coverage information. /// @@ -102,7 +102,8 @@ class _CoveragePageState extends State { icon: Container( padding: const EdgeInsets.all(UiConstants.space2), decoration: BoxDecoration( - color: UiColors.primaryForeground.withValues(alpha: 0.2), + color: UiColors.primaryForeground + .withValues(alpha: 0.2), borderRadius: UiConstants.radiusMd, ), child: const Icon( @@ -147,11 +148,12 @@ class _CoveragePageState extends State { const SizedBox(height: UiConstants.space4), CoverageStatsHeader( coveragePercent: - (state.stats?.coveragePercent ?? 0) + (state.stats?.totalCoveragePercentage ?? 0) .toDouble(), totalConfirmed: - state.stats?.totalConfirmed ?? 0, - totalNeeded: state.stats?.totalNeeded ?? 0, + state.stats?.totalPositionsConfirmed ?? 0, + totalNeeded: + state.stats?.totalPositionsNeeded ?? 0, ), ], ), @@ -207,7 +209,8 @@ class _CoveragePageState extends State { const SizedBox(height: UiConstants.space4), UiButton.secondary( text: context.t.client_coverage.page.retry, - onPressed: () => BlocProvider.of(context).add( + onPressed: () => + BlocProvider.of(context).add( const CoverageRefreshRequested(), ), ), @@ -227,8 +230,11 @@ class _CoveragePageState extends State { Column( spacing: UiConstants.space2, children: [ - if (state.stats != null && state.stats!.late > 0) ...[ - LateWorkersAlert(lateCount: state.stats!.late), + if (state.stats != null && + state.stats!.totalWorkersLate > 0) ...[ + LateWorkersAlert( + lateCount: state.stats!.totalWorkersLate, + ), ], if (state.stats != null) ...[ CoverageQuickStats(stats: state.stats!), diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_calendar_selector.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_calendar_selector.dart index f0518e1e..8ae4ce85 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_calendar_selector.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_calendar_selector.dart @@ -3,7 +3,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'calendar_nav_button.dart'; +import 'package:client_coverage/src/presentation/widgets/calendar_nav_button.dart'; /// Calendar selector widget for choosing dates. /// diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/coverage_page_skeleton.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/coverage_page_skeleton.dart index bfb12d31..448b7f60 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/coverage_page_skeleton.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/coverage_page_skeleton.dart @@ -1,7 +1,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'shift_card_skeleton.dart'; +import 'package:client_coverage/src/presentation/widgets/coverage_page_skeleton/shift_card_skeleton.dart'; /// Shimmer loading skeleton that mimics the coverage page loaded layout. /// diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart index 7ae538b9..e1e9a85b 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart @@ -3,7 +3,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'coverage_stat_card.dart'; +import 'package:client_coverage/src/presentation/widgets/coverage_stat_card.dart'; /// Quick statistics cards showing coverage metrics. /// @@ -27,7 +27,7 @@ class CoverageQuickStats extends StatelessWidget { child: CoverageStatCard( icon: UiIcons.success, label: context.t.client_coverage.stats.checked_in, - value: stats.checkedIn.toString(), + value: stats.totalWorkersCheckedIn.toString(), color: UiColors.iconSuccess, ), ), @@ -35,7 +35,7 @@ class CoverageQuickStats extends StatelessWidget { child: CoverageStatCard( icon: UiIcons.clock, label: context.t.client_coverage.stats.en_route, - value: stats.enRoute.toString(), + value: stats.totalWorkersEnRoute.toString(), color: UiColors.textWarning, ), ), diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart index e70aa5b2..10923545 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart @@ -4,8 +4,8 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'shift_header.dart'; -import 'worker_row.dart'; +import 'package:client_coverage/src/presentation/widgets/shift_header.dart'; +import 'package:client_coverage/src/presentation/widgets/worker_row.dart'; /// List of shifts with their workers. /// @@ -18,20 +18,12 @@ class CoverageShiftList extends StatelessWidget { }); /// The list of shifts to display. - final List shifts; + final List shifts; - /// Formats a time string (HH:mm) to a readable format (h:mm a). - String _formatTime(String? time) { + /// Formats a [DateTime] to a readable time string (h:mm a). + String _formatTime(DateTime? time) { if (time == null) return ''; - final List parts = time.split(':'); - final DateTime dt = DateTime( - 2022, - 1, - 1, - int.parse(parts[0]), - int.parse(parts[1]), - ); - return DateFormat('h:mm a').format(dt); + return DateFormat('h:mm a').format(time); } @override @@ -65,7 +57,12 @@ class CoverageShiftList extends StatelessWidget { } return Column( - children: shifts.map((CoverageShift shift) { + children: shifts.map((ShiftWithWorkers shift) { + final int coveragePercent = shift.requiredWorkerCount > 0 + ? ((shift.assignedWorkerCount / shift.requiredWorkerCount) * 100) + .round() + : 0; + return Container( margin: const EdgeInsets.only(bottom: UiConstants.space3), decoration: BoxDecoration( @@ -77,29 +74,30 @@ class CoverageShiftList extends StatelessWidget { child: Column( children: [ ShiftHeader( - title: shift.title, - location: shift.location, - startTime: _formatTime(shift.startTime), - current: shift.workers.length, - total: shift.workersNeeded, - coveragePercent: shift.coveragePercent, - shiftId: shift.id, + title: shift.roleName, + location: '', // V2 API does not return location on coverage + startTime: _formatTime(shift.timeRange.startsAt), + current: shift.assignedWorkerCount, + total: shift.requiredWorkerCount, + coveragePercent: coveragePercent, + shiftId: shift.shiftId, ), - if (shift.workers.isNotEmpty) + if (shift.assignedWorkers.isNotEmpty) Padding( padding: const EdgeInsets.all(UiConstants.space3), child: Column( - children: - shift.workers.map((CoverageWorker worker) { - final bool isLast = worker == shift.workers.last; + children: shift.assignedWorkers + .map((AssignedWorker worker) { + final bool isLast = + worker == shift.assignedWorkers.last; return Padding( padding: EdgeInsets.only( bottom: isLast ? 0 : UiConstants.space2, ), child: WorkerRow( worker: worker, - shiftStartTime: _formatTime(shift.startTime), - formatTime: _formatTime, + shiftStartTime: + _formatTime(shift.timeRange.startsAt), ), ); }).toList(), diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/shift_header.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/shift_header.dart index d35c49ca..ffa56b00 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/shift_header.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/shift_header.dart @@ -1,7 +1,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'coverage_badge.dart'; +import 'package:client_coverage/src/presentation/widgets/coverage_badge.dart'; /// Header section for a shift card showing title, location, time, and coverage. class ShiftHeader extends StatelessWidget { diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_row.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_row.dart index 25171bc8..a2018238 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_row.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_row.dart @@ -1,6 +1,7 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:krow_domain/krow_domain.dart'; /// Row displaying a single worker's avatar, name, status, and badge. @@ -9,18 +10,20 @@ class WorkerRow extends StatelessWidget { const WorkerRow({ required this.worker, required this.shiftStartTime, - required this.formatTime, super.key, }); - /// The worker data to display. - final CoverageWorker worker; + /// The assigned worker data to display. + final AssignedWorker worker; /// The formatted shift start time. final String shiftStartTime; - /// Callback to format a raw time string into a readable format. - final String Function(String?) formatTime; + /// Formats a [DateTime] to a readable time string (h:mm a). + String _formatCheckInTime(DateTime? time) { + if (time == null) return ''; + return DateFormat('h:mm a').format(time); + } @override Widget build(BuildContext context) { @@ -38,21 +41,21 @@ class WorkerRow extends StatelessWidget { String badgeLabel; switch (worker.status) { - case CoverageWorkerStatus.checkedIn: + case AssignmentStatus.checkedIn: bg = UiColors.textSuccess.withAlpha(26); border = UiColors.textSuccess; textBg = UiColors.textSuccess.withAlpha(51); textColor = UiColors.textSuccess; icon = UiIcons.success; statusText = l10n.status_checked_in_at( - time: formatTime(worker.checkInTime), + time: _formatCheckInTime(worker.checkInAt), ); badgeBg = UiColors.textSuccess.withAlpha(40); badgeText = UiColors.textSuccess; badgeBorder = badgeText; badgeLabel = l10n.status_on_site; - case CoverageWorkerStatus.confirmed: - if (worker.checkInTime == null) { + case AssignmentStatus.accepted: + if (worker.checkInAt == null) { bg = UiColors.textWarning.withAlpha(26); border = UiColors.textWarning; textBg = UiColors.textWarning.withAlpha(51); @@ -75,29 +78,7 @@ class WorkerRow extends StatelessWidget { badgeBorder = badgeText; badgeLabel = l10n.status_confirmed; } - case CoverageWorkerStatus.late: - bg = UiColors.destructive.withAlpha(26); - border = UiColors.destructive; - textBg = UiColors.destructive.withAlpha(51); - textColor = UiColors.destructive; - icon = UiIcons.warning; - statusText = l10n.status_running_late; - badgeBg = UiColors.destructive.withAlpha(40); - badgeText = UiColors.destructive; - badgeBorder = badgeText; - badgeLabel = l10n.status_late; - case CoverageWorkerStatus.checkedOut: - bg = UiColors.muted.withAlpha(26); - border = UiColors.border; - textBg = UiColors.muted.withAlpha(51); - textColor = UiColors.textSecondary; - icon = UiIcons.success; - statusText = l10n.status_checked_out; - badgeBg = UiColors.textSecondary.withAlpha(40); - badgeText = UiColors.textSecondary; - badgeBorder = badgeText; - badgeLabel = l10n.status_done; - case CoverageWorkerStatus.noShow: + case AssignmentStatus.noShow: bg = UiColors.destructive.withAlpha(26); border = UiColors.destructive; textBg = UiColors.destructive.withAlpha(51); @@ -108,7 +89,18 @@ class WorkerRow extends StatelessWidget { badgeText = UiColors.destructive; badgeBorder = badgeText; badgeLabel = l10n.status_no_show; - case CoverageWorkerStatus.completed: + case AssignmentStatus.checkedOut: + bg = UiColors.muted.withAlpha(26); + border = UiColors.border; + textBg = UiColors.muted.withAlpha(51); + textColor = UiColors.textSecondary; + icon = UiIcons.success; + statusText = l10n.status_checked_out; + badgeBg = UiColors.textSecondary.withAlpha(40); + badgeText = UiColors.textSecondary; + badgeBorder = badgeText; + badgeLabel = l10n.status_done; + case AssignmentStatus.completed: bg = UiColors.iconSuccess.withAlpha(26); border = UiColors.iconSuccess; textBg = UiColors.iconSuccess.withAlpha(51); @@ -119,20 +111,20 @@ class WorkerRow extends StatelessWidget { badgeText = UiColors.textSuccess; badgeBorder = badgeText; badgeLabel = l10n.status_completed; - case CoverageWorkerStatus.pending: - case CoverageWorkerStatus.accepted: - case CoverageWorkerStatus.rejected: + case AssignmentStatus.assigned: + case AssignmentStatus.swapRequested: + case AssignmentStatus.cancelled: + case AssignmentStatus.unknown: bg = UiColors.muted.withAlpha(26); border = UiColors.border; textBg = UiColors.muted.withAlpha(51); textColor = UiColors.textSecondary; icon = UiIcons.clock; - statusText = worker.status.name.toUpperCase(); + statusText = worker.status.value; badgeBg = UiColors.textSecondary.withAlpha(40); badgeText = UiColors.textSecondary; badgeBorder = badgeText; - badgeLabel = worker.status.name[0].toUpperCase() + - worker.status.name.substring(1); + badgeLabel = worker.status.value; } return Container( @@ -156,7 +148,7 @@ class WorkerRow extends StatelessWidget { child: CircleAvatar( backgroundColor: textBg, child: Text( - worker.name.isNotEmpty ? worker.name[0] : 'W', + worker.fullName.isNotEmpty ? worker.fullName[0] : 'W', style: UiTypography.body1b.copyWith( color: textColor, ), @@ -188,7 +180,7 @@ class WorkerRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - worker.name, + worker.fullName, style: UiTypography.body2b.copyWith( color: UiColors.textPrimary, ), diff --git a/apps/mobile/packages/features/client/client_coverage/pubspec.yaml b/apps/mobile/packages/features/client/client_coverage/pubspec.yaml index 107ef9bf..a184c0fc 100644 --- a/apps/mobile/packages/features/client/client_coverage/pubspec.yaml +++ b/apps/mobile/packages/features/client/client_coverage/pubspec.yaml @@ -10,7 +10,7 @@ environment: dependencies: flutter: sdk: flutter - + # Internal packages design_system: path: ../../../design_system @@ -18,17 +18,14 @@ dependencies: path: ../../../domain krow_core: path: ../../../core - krow_data_connect: - path: ../../../data_connect core_localization: path: ../../../core_localization - + # External packages flutter_modular: ^6.3.4 flutter_bloc: ^8.1.6 equatable: ^2.0.7 intl: ^0.20.0 - firebase_data_connect: ^0.2.2+1 dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/features/client/home/lib/client_home.dart b/apps/mobile/packages/features/client/home/lib/client_home.dart index b72d7b32..44cd3fa6 100644 --- a/apps/mobile/packages/features/client/home/lib/client_home.dart +++ b/apps/mobile/packages/features/client/home/lib/client_home.dart @@ -1,12 +1,11 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_domain/krow_domain.dart'; import 'src/data/repositories_impl/home_repository_impl.dart'; import 'src/domain/repositories/home_repository_interface.dart'; import 'src/domain/usecases/get_dashboard_data_usecase.dart'; import 'src/domain/usecases/get_recent_reorders_usecase.dart'; -import 'src/domain/usecases/get_user_session_data_usecase.dart'; import 'src/presentation/blocs/client_home_bloc.dart'; import 'src/presentation/pages/client_home_page.dart'; @@ -14,24 +13,34 @@ export 'src/presentation/pages/client_home_page.dart'; /// A [Module] for the client home feature. /// -/// This module configures the dependencies for the client home feature, -/// including repositories, use cases, and BLoCs. +/// Imports [CoreModule] for [BaseApiService] and registers repositories, +/// use cases, and BLoCs for the client dashboard. class ClientHomeModule extends Module { @override - List get imports => [DataConnectModule()]; + List get imports => [CoreModule()]; @override void binds(Injector i) { // Repositories - i.addLazySingleton(HomeRepositoryImpl.new); + i.addLazySingleton( + () => HomeRepositoryImpl(apiService: i.get()), + ); // UseCases - i.addLazySingleton(GetDashboardDataUseCase.new); - i.addLazySingleton(GetRecentReordersUseCase.new); - i.addLazySingleton(GetUserSessionDataUseCase.new); + i.addLazySingleton( + () => GetDashboardDataUseCase(i.get()), + ); + i.addLazySingleton( + () => GetRecentReordersUseCase(i.get()), + ); // BLoCs - i.add(ClientHomeBloc.new); + i.add( + () => ClientHomeBloc( + getDashboardDataUseCase: i.get(), + getRecentReordersUseCase: i.get(), + ), + ); } @override diff --git a/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart b/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart index d06fc4f3..dfaf734e 100644 --- a/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart +++ b/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart @@ -1,198 +1,37 @@ -import 'package:firebase_data_connect/firebase_data_connect.dart'; -import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/repositories/home_repository_interface.dart'; -/// Implementation of [HomeRepositoryInterface] that directly interacts with the Data Connect SDK. +import 'package:client_home/src/domain/repositories/home_repository_interface.dart'; + +/// V2 API implementation of [HomeRepositoryInterface]. +/// +/// Fetches client dashboard data from `GET /client/dashboard` and recent +/// reorders from `GET /client/reorders`. class HomeRepositoryImpl implements HomeRepositoryInterface { - HomeRepositoryImpl({dc.DataConnectService? service}) - : _service = service ?? dc.DataConnectService.instance; + /// Creates a [HomeRepositoryImpl]. + HomeRepositoryImpl({required BaseApiService apiService}) + : _apiService = apiService; - final dc.DataConnectService _service; + /// The API service used for network requests. + final BaseApiService _apiService; @override - Future getDashboardData() async { - return _service.run(() async { - final String businessId = await _service.getBusinessId(); - final DateTime now = DateTime.now(); - final int daysFromMonday = now.weekday - DateTime.monday; - final DateTime monday = DateTime( - now.year, - now.month, - now.day, - ).subtract(Duration(days: daysFromMonday)); - final DateTime weekRangeStart = monday; - final DateTime weekRangeEnd = monday.add( - const Duration(days: 13, hours: 23, minutes: 59, seconds: 59), - ); - - final QueryResult< - dc.GetCompletedShiftsByBusinessIdData, - dc.GetCompletedShiftsByBusinessIdVariables - > - completedResult = await _service.connector - .getCompletedShiftsByBusinessId( - businessId: businessId, - dateFrom: _service.toTimestamp(weekRangeStart), - dateTo: _service.toTimestamp(weekRangeEnd), - ) - .execute(); - - double weeklySpending = 0.0; - double next7DaysSpending = 0.0; - int weeklyShifts = 0; - int next7DaysScheduled = 0; - - for (final dc.GetCompletedShiftsByBusinessIdShifts shift - in completedResult.data.shifts) { - final DateTime? shiftDate = _service.toDateTime(shift.date); - if (shiftDate == null) continue; - - final int offset = shiftDate.difference(weekRangeStart).inDays; - if (offset < 0 || offset > 13) continue; - - final double cost = shift.cost ?? 0.0; - if (offset <= 6) { - weeklySpending += cost; - weeklyShifts += 1; - } else { - next7DaysSpending += cost; - next7DaysScheduled += 1; - } - } - - final DateTime start = DateTime(now.year, now.month, now.day); - final DateTime end = start.add( - const Duration(hours: 23, minutes: 59, seconds: 59), - ); - - final QueryResult< - dc.ListShiftRolesByBusinessAndDateRangeData, - dc.ListShiftRolesByBusinessAndDateRangeVariables - > - result = await _service.connector - .listShiftRolesByBusinessAndDateRange( - businessId: businessId, - start: _service.toTimestamp(start), - end: _service.toTimestamp(end), - ) - .execute(); - - int totalNeeded = 0; - int totalFilled = 0; - for (final dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole - in result.data.shiftRoles) { - totalNeeded += shiftRole.count; - totalFilled += shiftRole.assigned ?? 0; - } - - return HomeDashboardData( - weeklySpending: weeklySpending, - next7DaysSpending: next7DaysSpending, - weeklyShifts: weeklyShifts, - next7DaysScheduled: next7DaysScheduled, - totalNeeded: totalNeeded, - totalFilled: totalFilled, - ); - }); + Future getDashboard() async { + final ApiResponse response = + await _apiService.get(V2ApiEndpoints.clientDashboard); + final Map data = response.data as Map; + return ClientDashboard.fromJson(data); } @override - Future getUserSessionData() async { - return await _service.run(() async { - final String businessId = await _service.getBusinessId(); - final QueryResult - businessResult = await _service.connector - .getBusinessById(id: businessId) - .execute(); - - final dc.GetBusinessByIdBusiness? b = businessResult.data.business; - if (b == null) { - throw Exception('Business data not found for ID: $businessId'); - } - - final dc.ClientSession updatedSession = dc.ClientSession( - business: dc.ClientBusinessSession( - id: b.id, - businessName: b.businessName, - email: b.email ?? '', - city: b.city ?? '', - contactName: b.contactName ?? '', - companyLogoUrl: b.companyLogoUrl, - ), - ); - dc.ClientSessionStore.instance.setSession(updatedSession); - - return UserSessionData( - businessName: b.businessName, - photoUrl: b.companyLogoUrl, - ); - }); - } - - @override - Future> getRecentReorders() async { - return _service.run(() async { - final String businessId = await _service.getBusinessId(); - final DateTime now = DateTime.now(); - final DateTime start = now.subtract(const Duration(days: 30)); - - final QueryResult< - dc.ListCompletedOrdersByBusinessAndDateRangeData, - dc.ListCompletedOrdersByBusinessAndDateRangeVariables - > - result = await _service.connector - .listCompletedOrdersByBusinessAndDateRange( - businessId: businessId, - start: _service.toTimestamp(start), - end: _service.toTimestamp(now), - ) - .execute(); - - return result.data.orders.map(( - dc.ListCompletedOrdersByBusinessAndDateRangeOrders order, - ) { - final String title = - order.eventName ?? - (order.shifts_on_order.isNotEmpty - ? order.shifts_on_order[0].title - : 'Order'); - - final String location = order.shifts_on_order.isNotEmpty - ? (order.shifts_on_order[0].location ?? - order.shifts_on_order[0].locationAddress ?? - '') - : ''; - - int totalWorkers = 0; - double totalHours = 0; - double totalRate = 0; - int roleCount = 0; - - for (final dc.ListCompletedOrdersByBusinessAndDateRangeOrdersShiftsOnOrder - shift - in order.shifts_on_order) { - for (final dc.ListCompletedOrdersByBusinessAndDateRangeOrdersShiftsOnOrderShiftRolesOnShift - role - in shift.shiftRoles_on_shift) { - totalWorkers += role.count; - totalHours += role.hours ?? 0; - totalRate += role.role.costPerHour; - roleCount++; - } - } - - return ReorderItem( - orderId: order.id, - title: title, - location: location, - totalCost: order.total ?? 0.0, - workers: totalWorkers, - type: order.orderType.stringValue, - hourlyRate: roleCount > 0 ? totalRate / roleCount : 0.0, - hours: totalHours, - ); - }).toList(); - }); + Future> getRecentReorders() async { + final ApiResponse response = + await _apiService.get(V2ApiEndpoints.clientReorders); + final Map body = response.data as Map; + final List items = body['items'] as List; + return items + .map((dynamic json) => + RecentOrder.fromJson(json as Map)) + .toList(); } } diff --git a/apps/mobile/packages/features/client/home/lib/src/domain/repositories/home_repository_interface.dart b/apps/mobile/packages/features/client/home/lib/src/domain/repositories/home_repository_interface.dart index e84df66a..8329b867 100644 --- a/apps/mobile/packages/features/client/home/lib/src/domain/repositories/home_repository_interface.dart +++ b/apps/mobile/packages/features/client/home/lib/src/domain/repositories/home_repository_interface.dart @@ -1,31 +1,15 @@ import 'package:krow_domain/krow_domain.dart'; -/// User session data for the home page. -class UserSessionData { - - /// Creates a [UserSessionData]. - const UserSessionData({ - required this.businessName, - this.photoUrl, - }); - /// The business name of the logged-in user. - final String businessName; - - /// The photo URL of the logged-in user (optional). - final String? photoUrl; -} - /// Interface for the Client Home repository. /// -/// This repository is responsible for providing data required for the -/// client home screen dashboard. +/// Provides data required for the client home screen dashboard +/// via the V2 REST API. abstract interface class HomeRepositoryInterface { - /// Fetches the [HomeDashboardData] containing aggregated dashboard metrics. - Future getDashboardData(); + /// Fetches the [ClientDashboard] containing aggregated dashboard metrics, + /// user name, and business info from `GET /client/dashboard`. + Future getDashboard(); - /// Fetches the user's session data (business name and photo). - Future getUserSessionData(); - - /// Fetches recently completed shift roles for reorder suggestions. - Future> getRecentReorders(); + /// Fetches recent completed orders for reorder suggestions + /// from `GET /client/reorders`. + Future> getRecentReorders(); } diff --git a/apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_dashboard_data_usecase.dart b/apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_dashboard_data_usecase.dart index c421674b..777940f4 100644 --- a/apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_dashboard_data_usecase.dart +++ b/apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_dashboard_data_usecase.dart @@ -1,19 +1,21 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../repositories/home_repository_interface.dart'; -/// Use case to fetch dashboard data for the client home screen. +import 'package:client_home/src/domain/repositories/home_repository_interface.dart'; + +/// Use case to fetch the client dashboard from the V2 API. /// -/// This use case coordinates with the [HomeRepositoryInterface] to retrieve -/// the [HomeDashboardData] required for the dashboard display. -class GetDashboardDataUseCase implements NoInputUseCase { - +/// Returns a [ClientDashboard] containing spending, coverage, +/// live-activity metrics and user/business info. +class GetDashboardDataUseCase implements NoInputUseCase { /// Creates a [GetDashboardDataUseCase]. GetDashboardDataUseCase(this._repository); + + /// The repository providing dashboard data. final HomeRepositoryInterface _repository; @override - Future call() { - return _repository.getDashboardData(); + Future call() { + return _repository.getDashboard(); } } diff --git a/apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_recent_reorders_usecase.dart b/apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_recent_reorders_usecase.dart index a8e3de6b..5f3d6fab 100644 --- a/apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_recent_reorders_usecase.dart +++ b/apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_recent_reorders_usecase.dart @@ -1,16 +1,20 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../repositories/home_repository_interface.dart'; -/// Use case to fetch recent completed shift roles for reorder suggestions. -class GetRecentReordersUseCase implements NoInputUseCase> { +import 'package:client_home/src/domain/repositories/home_repository_interface.dart'; +/// Use case to fetch recent completed orders for reorder suggestions. +/// +/// Returns a list of [RecentOrder] from the V2 API. +class GetRecentReordersUseCase implements NoInputUseCase> { /// Creates a [GetRecentReordersUseCase]. GetRecentReordersUseCase(this._repository); + + /// The repository providing reorder data. final HomeRepositoryInterface _repository; @override - Future> call() { + Future> call() { return _repository.getRecentReorders(); } } diff --git a/apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_user_session_data_usecase.dart b/apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_user_session_data_usecase.dart deleted file mode 100644 index f246d856..00000000 --- a/apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_user_session_data_usecase.dart +++ /dev/null @@ -1,16 +0,0 @@ -import '../repositories/home_repository_interface.dart'; - -/// Use case for retrieving user session data. -/// -/// Returns the user's business name and photo URL for display in the header. -class GetUserSessionDataUseCase { - - /// Creates a [GetUserSessionDataUseCase]. - GetUserSessionDataUseCase(this._repository); - final HomeRepositoryInterface _repository; - - /// Executes the use case to get session data. - Future call() { - return _repository.getUserSessionData(); - } -} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart index 7fef5b8e..048a4ec9 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart @@ -1,24 +1,27 @@ -import 'package:client_home/src/domain/repositories/home_repository_interface.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/usecases/get_dashboard_data_usecase.dart'; -import '../../domain/usecases/get_recent_reorders_usecase.dart'; -import '../../domain/usecases/get_user_session_data_usecase.dart'; -import 'client_home_event.dart'; -import 'client_home_state.dart'; -/// BLoC responsible for managing the state and business logic of the client home dashboard. +import 'package:client_home/src/domain/usecases/get_dashboard_data_usecase.dart'; +import 'package:client_home/src/domain/usecases/get_recent_reorders_usecase.dart'; +import 'package:client_home/src/presentation/blocs/client_home_event.dart'; +import 'package:client_home/src/presentation/blocs/client_home_state.dart'; + +/// BLoC responsible for managing the client home dashboard state. +/// +/// Fetches the [ClientDashboard] and recent reorders from the V2 API +/// and exposes layout-editing capabilities (reorder, toggle visibility). class ClientHomeBloc extends Bloc - with BlocErrorHandler, SafeBloc { + with + BlocErrorHandler, + SafeBloc { + /// Creates a [ClientHomeBloc]. ClientHomeBloc({ required GetDashboardDataUseCase getDashboardDataUseCase, required GetRecentReordersUseCase getRecentReordersUseCase, - required GetUserSessionDataUseCase getUserSessionDataUseCase, - }) : _getDashboardDataUseCase = getDashboardDataUseCase, - _getRecentReordersUseCase = getRecentReordersUseCase, - _getUserSessionDataUseCase = getUserSessionDataUseCase, - super(const ClientHomeState()) { + }) : _getDashboardDataUseCase = getDashboardDataUseCase, + _getRecentReordersUseCase = getRecentReordersUseCase, + super(const ClientHomeState()) { on(_onStarted); on(_onEditModeToggled); on(_onWidgetVisibilityToggled); @@ -27,9 +30,12 @@ class ClientHomeBloc extends Bloc add(ClientHomeStarted()); } + + /// Use case that fetches the client dashboard. final GetDashboardDataUseCase _getDashboardDataUseCase; + + /// Use case that fetches recent reorders. final GetRecentReordersUseCase _getRecentReordersUseCase; - final GetUserSessionDataUseCase _getUserSessionDataUseCase; Future _onStarted( ClientHomeStarted event, @@ -39,20 +45,15 @@ class ClientHomeBloc extends Bloc await handleError( emit: emit.call, action: () async { - // Get session data - final UserSessionData sessionData = await _getUserSessionDataUseCase(); - - // Get dashboard data - final HomeDashboardData data = await _getDashboardDataUseCase(); - final List reorderItems = await _getRecentReordersUseCase(); + final ClientDashboard dashboard = await _getDashboardDataUseCase(); + final List reorderItems = + await _getRecentReordersUseCase(); emit( state.copyWith( status: ClientHomeStatus.success, - dashboardData: data, + dashboard: dashboard, reorderItems: reorderItems, - businessName: sessionData.businessName, - photoUrl: sessionData.photoUrl, ), ); }, @@ -121,4 +122,3 @@ class ClientHomeBloc extends Bloc ); } } - diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_state.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_state.dart index e229a36d..30a373be 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_state.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_state.dart @@ -2,11 +2,23 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; /// Status of the client home dashboard. -enum ClientHomeStatus { initial, loading, success, error } +enum ClientHomeStatus { + /// Initial state before any data is loaded. + initial, + + /// Data is being fetched. + loading, + + /// Data was fetched successfully. + success, + + /// An error occurred. + error, +} /// Represents the state of the client home dashboard. class ClientHomeState extends Equatable { - + /// Creates a [ClientHomeState]. const ClientHomeState({ this.status = ClientHomeStatus.initial, this.widgetOrder = const [ @@ -25,38 +37,46 @@ class ClientHomeState extends Equatable { }, this.isEditMode = false, this.errorMessage, - this.dashboardData = const HomeDashboardData( - weeklySpending: 0.0, - next7DaysSpending: 0.0, - weeklyShifts: 0, - next7DaysScheduled: 0, - totalNeeded: 0, - totalFilled: 0, - ), - this.reorderItems = const [], - this.businessName = 'Your Company', - this.photoUrl, + this.dashboard, + this.reorderItems = const [], }); - final ClientHomeStatus status; - final List widgetOrder; - final Map widgetVisibility; - final bool isEditMode; - final String? errorMessage; - final HomeDashboardData dashboardData; - final List reorderItems; - final String businessName; - final String? photoUrl; + /// The current loading status. + final ClientHomeStatus status; + + /// Ordered list of widget identifiers for the dashboard layout. + final List widgetOrder; + + /// Visibility map keyed by widget identifier. + final Map widgetVisibility; + + /// Whether the dashboard is in edit/customise mode. + final bool isEditMode; + + /// Error key for translation when [status] is [ClientHomeStatus.error]. + final String? errorMessage; + + /// The V2 client dashboard data (null until loaded). + final ClientDashboard? dashboard; + + /// Recent orders available for quick reorder. + final List reorderItems; + + /// The business name from the dashboard, with a safe fallback. + String get businessName => dashboard?.businessName ?? 'Your Company'; + + /// The user display name from the dashboard. + String get userName => dashboard?.userName ?? ''; + + /// Creates a copy of this state with the given fields replaced. ClientHomeState copyWith({ ClientHomeStatus? status, List? widgetOrder, Map? widgetVisibility, bool? isEditMode, String? errorMessage, - HomeDashboardData? dashboardData, - List? reorderItems, - String? businessName, - String? photoUrl, + ClientDashboard? dashboard, + List? reorderItems, }) { return ClientHomeState( status: status ?? this.status, @@ -64,23 +84,19 @@ class ClientHomeState extends Equatable { widgetVisibility: widgetVisibility ?? this.widgetVisibility, isEditMode: isEditMode ?? this.isEditMode, errorMessage: errorMessage ?? this.errorMessage, - dashboardData: dashboardData ?? this.dashboardData, + dashboard: dashboard ?? this.dashboard, reorderItems: reorderItems ?? this.reorderItems, - businessName: businessName ?? this.businessName, - photoUrl: photoUrl ?? this.photoUrl, ); } @override List get props => [ - status, - widgetOrder, - widgetVisibility, - isEditMode, - errorMessage, - dashboardData, - reorderItems, - businessName, - photoUrl, - ]; + status, + widgetOrder, + widgetVisibility, + isEditMode, + errorMessage, + dashboard, + reorderItems, + ]; } diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/pages/client_home_page.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/pages/client_home_page.dart index cd1b47de..a0fbb048 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/pages/client_home_page.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/pages/client_home_page.dart @@ -3,10 +3,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import '../blocs/client_home_bloc.dart'; -import '../widgets/client_home_body.dart'; -import '../widgets/client_home_edit_banner.dart'; -import '../widgets/client_home_header.dart'; +import 'package:client_home/src/presentation/blocs/client_home_bloc.dart'; +import 'package:client_home/src/presentation/widgets/client_home_body.dart'; +import 'package:client_home/src/presentation/widgets/client_home_edit_banner.dart'; +import 'package:client_home/src/presentation/widgets/client_home_header.dart'; /// The main Home page for client users. /// diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_body.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_body.dart index 9b39ec2f..bb3a46bc 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_body.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_body.dart @@ -3,12 +3,12 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../blocs/client_home_bloc.dart'; -import '../blocs/client_home_state.dart'; -import 'client_home_edit_mode_body.dart'; -import 'client_home_error_state.dart'; -import 'client_home_normal_mode_body.dart'; -import 'client_home_page_skeleton.dart'; +import 'package:client_home/src/presentation/blocs/client_home_bloc.dart'; +import 'package:client_home/src/presentation/blocs/client_home_state.dart'; +import 'package:client_home/src/presentation/widgets/client_home_edit_mode_body.dart'; +import 'package:client_home/src/presentation/widgets/client_home_error_state.dart'; +import 'package:client_home/src/presentation/widgets/client_home_normal_mode_body.dart'; +import 'package:client_home/src/presentation/widgets/client_home_page_skeleton.dart'; /// Main body widget for the client home page. /// diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart index 0a1f4489..d9e10e65 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart @@ -1,9 +1,9 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../blocs/client_home_bloc.dart'; -import '../blocs/client_home_event.dart'; -import '../blocs/client_home_state.dart'; +import 'package:client_home/src/presentation/blocs/client_home_bloc.dart'; +import 'package:client_home/src/presentation/blocs/client_home_event.dart'; +import 'package:client_home/src/presentation/blocs/client_home_state.dart'; /// A banner displayed when edit mode is active. /// diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_mode_body.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_mode_body.dart index 5acdb4bc..f58b780c 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_mode_body.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_mode_body.dart @@ -2,10 +2,10 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../blocs/client_home_bloc.dart'; -import '../blocs/client_home_event.dart'; -import '../blocs/client_home_state.dart'; -import 'dashboard_widget_builder.dart'; +import 'package:client_home/src/presentation/blocs/client_home_bloc.dart'; +import 'package:client_home/src/presentation/blocs/client_home_event.dart'; +import 'package:client_home/src/presentation/blocs/client_home_state.dart'; +import 'package:client_home/src/presentation/widgets/dashboard_widget_builder.dart'; /// Widget that displays the home dashboard in edit mode with drag-and-drop support. /// diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_error_state.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_error_state.dart index a1c6e4f5..91999e01 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_error_state.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_error_state.dart @@ -3,9 +3,9 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../blocs/client_home_bloc.dart'; -import '../blocs/client_home_event.dart'; -import '../blocs/client_home_state.dart'; +import 'package:client_home/src/presentation/blocs/client_home_bloc.dart'; +import 'package:client_home/src/presentation/blocs/client_home_event.dart'; +import 'package:client_home/src/presentation/blocs/client_home_state.dart'; /// Widget that displays an error state for the client home page. /// diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_header.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_header.dart index 9d311d2f..26239f86 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_header.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_header.dart @@ -3,23 +3,24 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import '../blocs/client_home_bloc.dart'; -import '../blocs/client_home_event.dart'; -import '../blocs/client_home_state.dart'; -import 'header_icon_button.dart'; -import 'client_home_header_skeleton.dart'; + +import 'package:client_home/src/presentation/blocs/client_home_bloc.dart'; +import 'package:client_home/src/presentation/blocs/client_home_event.dart'; +import 'package:client_home/src/presentation/blocs/client_home_state.dart'; +import 'package:client_home/src/presentation/widgets/header_icon_button.dart'; +import 'package:client_home/src/presentation/widgets/client_home_header_skeleton.dart'; /// The header section of the client home page. /// /// Displays the user's business name, avatar, and action buttons -/// (edit mode, notifications, settings). +/// (edit mode, settings). class ClientHomeHeader extends StatelessWidget { - /// Creates a [ClientHomeHeader]. const ClientHomeHeader({ required this.i18n, super.key, }); + /// The internationalization object for localized strings. final dynamic i18n; @@ -33,7 +34,6 @@ class ClientHomeHeader extends StatelessWidget { } final String businessName = state.businessName; - final String? photoUrl = state.photoUrl; final String avatarLetter = businessName.trim().isNotEmpty ? businessName.trim()[0].toUpperCase() : 'C'; @@ -62,18 +62,12 @@ class ClientHomeHeader extends StatelessWidget { ), child: CircleAvatar( backgroundColor: UiColors.primary.withValues(alpha: 0.1), - backgroundImage: - photoUrl != null && photoUrl.isNotEmpty - ? NetworkImage(photoUrl) - : null, - child: photoUrl != null && photoUrl.isNotEmpty - ? null - : Text( - avatarLetter, - style: UiTypography.body2b.copyWith( - color: UiColors.primary, - ), - ), + child: Text( + avatarLetter, + style: UiTypography.body2b.copyWith( + color: UiColors.primary, + ), + ), ), ), const SizedBox(width: UiConstants.space3), diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_normal_mode_body.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_normal_mode_body.dart index 9583ece3..fcec6d84 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_normal_mode_body.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_normal_mode_body.dart @@ -1,8 +1,8 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import '../blocs/client_home_state.dart'; -import 'dashboard_widget_builder.dart'; +import 'package:client_home/src/presentation/blocs/client_home_state.dart'; +import 'package:client_home/src/presentation/widgets/dashboard_widget_builder.dart'; /// Widget that displays the home dashboard in normal mode. /// diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_sheets.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_sheets.dart deleted file mode 100644 index eaf9984a..00000000 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_sheets.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:flutter/material.dart'; -import 'shift_order_form_sheet.dart'; - -/// Helper class for showing modal sheets in the client home feature. -class ClientHomeSheets { - /// Shows the shift order form bottom sheet. - /// - /// Optionally accepts [initialData] to pre-populate the form for reordering. - /// Calls [onSubmit] when the user submits the form successfully. - static void showOrderFormSheet( - BuildContext context, - Map? initialData, { - required void Function(Map) onSubmit, - }) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (BuildContext context) { - return ShiftOrderFormSheet( - initialData: initialData, - onSubmit: onSubmit, - ); - }, - ); - } -} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/coverage_dashboard.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/coverage_dashboard.dart deleted file mode 100644 index 2e9dd11a..00000000 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/coverage_dashboard.dart +++ /dev/null @@ -1,217 +0,0 @@ -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; - -/// A dashboard widget that displays today's coverage status. -class CoverageDashboard extends StatelessWidget { - /// Creates a [CoverageDashboard]. - const CoverageDashboard({ - super.key, - required this.shifts, - required this.applications, - }); - - /// The list of shifts for today. - final List shifts; - - /// The list of applications for today's shifts. - final List applications; - - @override - Widget build(BuildContext context) { - int totalNeeded = 0; - int totalConfirmed = 0; - double todayCost = 0; - - for (final dynamic s in shifts) { - final int needed = - (s as Map)['workersNeeded'] as int? ?? 0; - final int confirmed = s['filled'] as int? ?? 0; - final double rate = s['hourlyRate'] as double? ?? 0.0; - final double hours = s['hours'] as double? ?? 0.0; - - totalNeeded += needed; - totalConfirmed += confirmed; - todayCost += rate * hours; - } - - final int coveragePercent = totalNeeded > 0 - ? ((totalConfirmed / totalNeeded) * 100).round() - : 100; - final int unfilledPositions = totalNeeded - totalConfirmed; - - final int checkedInCount = applications - .where( - (dynamic a) => (a as Map)['checkInTime'] != null, - ) - .length; - final int lateWorkersCount = applications - .where((dynamic a) => (a as Map)['status'] == 'LATE') - .length; - - final bool isCoverageGood = coveragePercent >= 90; - final Color coverageBadgeColor = isCoverageGood - ? UiColors.tagSuccess - : UiColors.tagPending; - final Color coverageTextColor = isCoverageGood - ? UiColors.textSuccess - : UiColors.textWarning; - - return Container( - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border, width: 0.5), - ), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text("Today's Status", style: UiTypography.body1m.textSecondary), - if (totalNeeded > 0 || totalConfirmed > 0) - Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space2, - vertical: 2.0, - ), - decoration: BoxDecoration( - color: coverageBadgeColor, - borderRadius: UiConstants.radiusMd, - ), - child: Text( - '$coveragePercent% Covered', - style: UiTypography.footnote1b.copyWith( - color: coverageTextColor, - ), - ), - ), - ], - ), - const SizedBox(height: UiConstants.space4), - - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - children: [ - _StatusCard( - label: 'Unfilled Today', - value: '$unfilledPositions', - icon: UiIcons.warning, - isWarning: unfilledPositions > 0, - ), - const SizedBox(height: UiConstants.space2), - _StatusCard( - label: 'Running Late', - value: '$lateWorkersCount', - icon: UiIcons.error, - isError: true, - ), - ], - ), - ), - const SizedBox(width: UiConstants.space2), - Expanded( - child: Column( - children: [ - _StatusCard( - label: 'Checked In', - value: '$checkedInCount/$totalNeeded', - icon: UiIcons.success, - isInfo: true, - ), - const SizedBox(height: UiConstants.space2), - _StatusCard( - label: "Today's Cost", - value: '\$${todayCost.round()}', - icon: UiIcons.dollar, - isInfo: true, - ), - ], - ), - ), - ], - ), - ], - ), - ); - } -} - -class _StatusCard extends StatelessWidget { - const _StatusCard({ - required this.label, - required this.value, - required this.icon, - this.isWarning = false, - this.isError = false, - this.isInfo = false, - }); - final String label; - final String value; - final IconData icon; - final bool isWarning; - final bool isError; - final bool isInfo; - - @override - Widget build(BuildContext context) { - Color bg = UiColors.bgSecondary; - Color border = UiColors.border; - Color iconColor = UiColors.iconSecondary; - Color textColor = UiColors.textPrimary; - - if (isWarning) { - bg = UiColors.tagPending.withAlpha(80); - border = UiColors.textWarning.withAlpha(80); - iconColor = UiColors.textWarning; - textColor = UiColors.textWarning; - } else if (isError) { - bg = UiColors.tagError.withAlpha(80); - border = UiColors.borderError.withAlpha(80); - iconColor = UiColors.textError; - textColor = UiColors.textError; - } else if (isInfo) { - bg = UiColors.tagInProgress.withAlpha(80); - border = UiColors.primary.withValues(alpha: 0.2); - iconColor = UiColors.primary; - textColor = UiColors.primary; - } - - return Container( - padding: const EdgeInsets.all(UiConstants.space3), - decoration: BoxDecoration( - color: bg, - border: Border.all(color: border), - borderRadius: UiConstants.radiusMd, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(icon, size: 16, color: iconColor), - const SizedBox(width: UiConstants.space2), - Expanded( - child: Text( - label, - style: UiTypography.footnote1m.copyWith( - color: textColor.withValues(alpha: 0.8), - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - const SizedBox(height: UiConstants.space1), - Text( - value, - style: UiTypography.headline3m.copyWith(color: textColor), - ), - ], - ), - ); - } -} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/dashboard_widget_builder.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/dashboard_widget_builder.dart index 038c6238..5d9da011 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/dashboard_widget_builder.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/dashboard_widget_builder.dart @@ -2,18 +2,20 @@ import 'package:core_localization/core_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import '../blocs/client_home_state.dart'; -import '../widgets/actions_widget.dart'; -import '../widgets/coverage_widget.dart'; -import '../widgets/draggable_widget_wrapper.dart'; -import '../widgets/live_activity_widget.dart'; -import '../widgets/reorder_widget.dart'; -import '../widgets/spending_widget.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:client_home/src/presentation/blocs/client_home_state.dart'; +import 'package:client_home/src/presentation/widgets/actions_widget.dart'; +import 'package:client_home/src/presentation/widgets/coverage_widget.dart'; +import 'package:client_home/src/presentation/widgets/draggable_widget_wrapper.dart'; +import 'package:client_home/src/presentation/widgets/live_activity_widget.dart'; +import 'package:client_home/src/presentation/widgets/reorder_widget.dart'; +import 'package:client_home/src/presentation/widgets/spending_widget.dart'; /// A widget that builds dashboard content based on widget ID. /// -/// This widget encapsulates the logic for rendering different dashboard -/// widgets based on their unique identifiers and current state. +/// Renders different dashboard sections depending on their unique identifier +/// and the current [ClientHomeState]. class DashboardWidgetBuilder extends StatelessWidget { /// Creates a [DashboardWidgetBuilder]. const DashboardWidgetBuilder({ @@ -55,11 +57,16 @@ class DashboardWidgetBuilder extends StatelessWidget { } /// Builds the actual widget content based on the widget ID. - Widget _buildWidgetContent(BuildContext context, TranslationsClientHomeWidgetsEn i18n) { + Widget _buildWidgetContent( + BuildContext context, + TranslationsClientHomeWidgetsEn i18n, + ) { final String title = _getWidgetTitle(i18n); // Only show subtitle in normal mode final String? subtitle = !isEditMode ? _getWidgetSubtitle(id) : null; + final ClientDashboard? dashboard = state.dashboard; + switch (id) { case 'actions': return ActionsWidget(title: title, subtitle: subtitle); @@ -71,28 +78,32 @@ class DashboardWidgetBuilder extends StatelessWidget { ); case 'spending': return SpendingWidget( - weeklySpending: state.dashboardData.weeklySpending, - next7DaysSpending: state.dashboardData.next7DaysSpending, - weeklyShifts: state.dashboardData.weeklyShifts, - next7DaysScheduled: state.dashboardData.next7DaysScheduled, + weeklySpendCents: dashboard?.spending.weeklySpendCents ?? 0, + projectedNext7DaysCents: + dashboard?.spending.projectedNext7DaysCents ?? 0, title: title, subtitle: subtitle, ); case 'coverage': + final CoverageMetrics? coverage = dashboard?.coverage; + final int needed = coverage?.neededWorkersToday ?? 0; + final int filled = coverage?.filledWorkersToday ?? 0; return CoverageWidget( - totalNeeded: state.dashboardData.totalNeeded, - totalConfirmed: state.dashboardData.totalFilled, - coveragePercent: state.dashboardData.totalNeeded > 0 - ? ((state.dashboardData.totalFilled / - state.dashboardData.totalNeeded) * - 100) - .toInt() - : 0, + totalNeeded: needed, + totalConfirmed: filled, + coveragePercent: needed > 0 ? ((filled / needed) * 100).toInt() : 0, title: title, subtitle: subtitle, ); case 'liveActivity': return LiveActivityWidget( + metrics: dashboard?.liveActivity ?? + const LiveActivityMetrics( + lateWorkersToday: 0, + checkedInWorkersToday: 0, + averageShiftCostCents: 0, + ), + coverageNeeded: dashboard?.coverage.neededWorkersToday ?? 0, onViewAllPressed: () => Modular.to.toClientCoverage(), title: title, subtitle: subtitle, @@ -106,20 +117,21 @@ class DashboardWidgetBuilder extends StatelessWidget { String _getWidgetTitle(dynamic i18n) { switch (id) { case 'actions': - return i18n.actions; + return i18n.actions as String; case 'reorder': - return i18n.reorder; + return i18n.reorder as String; case 'coverage': - return i18n.coverage; + return i18n.coverage as String; case 'spending': - return i18n.spending; + return i18n.spending as String; case 'liveActivity': - return i18n.live_activity; + return i18n.live_activity as String; default: return ''; } } + /// Returns the subtitle for the widget based on its ID. String _getWidgetSubtitle(String id) { switch (id) { case 'actions': diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/draggable_widget_wrapper.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/draggable_widget_wrapper.dart index fc819c78..84782902 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/draggable_widget_wrapper.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/draggable_widget_wrapper.dart @@ -1,8 +1,8 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../blocs/client_home_bloc.dart'; -import '../blocs/client_home_event.dart'; +import 'package:client_home/src/presentation/blocs/client_home_bloc.dart'; +import 'package:client_home/src/presentation/blocs/client_home_event.dart'; /// A wrapper for dashboard widgets in edit mode. /// diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/live_activity_widget.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/live_activity_widget.dart index 4aef5629..a091b8b6 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/live_activity_widget.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/live_activity_widget.dart @@ -1,21 +1,31 @@ import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; -import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_domain/krow_domain.dart'; -import 'coverage_dashboard.dart'; -import 'section_layout.dart'; - -/// A widget that displays live activity information. -class LiveActivityWidget extends StatefulWidget { +import 'package:client_home/src/presentation/widgets/section_layout.dart'; +/// A widget that displays live activity metrics for today. +/// +/// Renders checked-in count, late workers, and average shift cost +/// from the [LiveActivityMetrics] provided by the V2 dashboard endpoint. +class LiveActivityWidget extends StatelessWidget { /// Creates a [LiveActivityWidget]. const LiveActivityWidget({ super.key, + required this.metrics, + required this.coverageNeeded, required this.onViewAllPressed, this.title, - this.subtitle + this.subtitle, }); + + /// Live activity metrics from the V2 dashboard. + final LiveActivityMetrics metrics; + + /// Workers needed today (from coverage metrics) for the checked-in ratio. + final int coverageNeeded; + /// Callback when "View all" is pressed. final VoidCallback onViewAllPressed; @@ -25,159 +35,180 @@ class LiveActivityWidget extends StatefulWidget { /// Optional subtitle for the section. final String? subtitle; - @override - State createState() => _LiveActivityWidgetState(); -} - -class _LiveActivityWidgetState extends State { - late final Future<_LiveActivityData> _liveActivityFuture = - _loadLiveActivity(); - - Future<_LiveActivityData> _loadLiveActivity() async { - final String? businessId = - dc.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - return _LiveActivityData.empty(); - } - - final DateTime now = DateTime.now(); - final DateTime start = DateTime(now.year, now.month, now.day); - final DateTime end = DateTime(now.year, now.month, now.day, 23, 59, 59, 999); - final fdc.QueryResult shiftRolesResult = - await dc.ExampleConnector.instance - .listShiftRolesByBusinessAndDateRange( - businessId: businessId, - start: _toTimestamp(start), - end: _toTimestamp(end), - ) - .execute(); - final fdc.QueryResult result = - await dc.ExampleConnector.instance - .listStaffsApplicationsByBusinessForDay( - businessId: businessId, - dayStart: _toTimestamp(start), - dayEnd: _toTimestamp(end), - ) - .execute(); - - if (shiftRolesResult.data.shiftRoles.isEmpty && - result.data.applications.isEmpty) { - return _LiveActivityData.empty(); - } - - int totalNeeded = 0; - double totalCost = 0; - for (final dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole - in shiftRolesResult.data.shiftRoles) { - totalNeeded += shiftRole.count; - totalCost += shiftRole.totalValue ?? 0; - } - - final int totalAssigned = result.data.applications.length; - int lateCount = 0; - int checkedInCount = 0; - for (final dc.ListStaffsApplicationsByBusinessForDayApplications app - in result.data.applications) { - if (app.checkInTime != null) { - checkedInCount += 1; - } - if (app.status is dc.Known && - (app.status as dc.Known).value == - dc.ApplicationStatus.LATE) { - lateCount += 1; - } - } - - return _LiveActivityData( - totalNeeded: totalNeeded, - totalAssigned: totalAssigned, - totalCost: totalCost, - checkedInCount: checkedInCount, - lateCount: lateCount, - ); - } - - fdc.Timestamp _toTimestamp(DateTime dateTime) { - final DateTime utc = dateTime.toUtc(); - final int seconds = utc.millisecondsSinceEpoch ~/ 1000; - final int nanoseconds = - (utc.millisecondsSinceEpoch % 1000) * 1000000; - return fdc.Timestamp(nanoseconds, seconds); - } - @override Widget build(BuildContext context) { final TranslationsClientHomeEn i18n = t.client_home; + final int checkedIn = metrics.checkedInWorkersToday; + final int late_ = metrics.lateWorkersToday; + final String avgCostDisplay = + '\$${(metrics.averageShiftCostCents / 100).toStringAsFixed(0)}'; + + final int coveragePercent = + coverageNeeded > 0 ? ((checkedIn / coverageNeeded) * 100).round() : 100; + + final bool isCoverageGood = coveragePercent >= 90; + final Color coverageBadgeColor = + isCoverageGood ? UiColors.tagSuccess : UiColors.tagPending; + final Color coverageTextColor = + isCoverageGood ? UiColors.textSuccess : UiColors.textWarning; + return SectionLayout( - title: widget.title, - subtitle: widget.subtitle, + title: title, + subtitle: subtitle, action: i18n.dashboard.view_all, - onAction: widget.onViewAllPressed, - child: FutureBuilder<_LiveActivityData>( - future: _liveActivityFuture, - builder: (BuildContext context, - AsyncSnapshot<_LiveActivityData> snapshot) { - final _LiveActivityData data = - snapshot.data ?? _LiveActivityData.empty(); - final List> shifts = - >[ - { - 'workersNeeded': data.totalNeeded, - 'filled': data.totalAssigned, - 'hourlyRate': 1.0, - 'hours': data.totalCost, - 'status': 'OPEN', - 'date': DateTime.now().toIso8601String().split('T')[0], - }, - ]; - final List> applications = - >[]; - for (int i = 0; i < data.checkedInCount; i += 1) { - applications.add( - { - 'status': 'CONFIRMED', - 'checkInTime': '09:00', - }, - ); - } - for (int i = 0; i < data.lateCount; i += 1) { - applications.add({'status': 'LATE'}); - } - return CoverageDashboard( - shifts: shifts, - applications: applications, - ); - }, + onAction: onViewAllPressed, + child: Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border, width: 0.5), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // ASSUMPTION: Reusing hardcoded string from previous + // CoverageDashboard widget — a future localization pass should + // add a dedicated i18n key. + Text( + "Today's Status", + style: UiTypography.body1m.textSecondary, + ), + if (coverageNeeded > 0) + Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: 2.0, + ), + decoration: BoxDecoration( + color: coverageBadgeColor, + borderRadius: UiConstants.radiusMd, + ), + child: Text( + i18n.dashboard.percent_covered(percent: coveragePercent), + style: UiTypography.footnote1b.copyWith( + color: coverageTextColor, + ), + ), + ), + ], + ), + const SizedBox(height: UiConstants.space4), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + children: [ + // ASSUMPTION: Reusing hardcoded strings from previous + // CoverageDashboard widget. + _StatusCard( + label: 'Running Late', + value: '$late_', + icon: UiIcons.error, + isError: true, + ), + const SizedBox(height: UiConstants.space2), + _StatusCard( + label: "Today's Cost", + value: avgCostDisplay, + icon: UiIcons.dollar, + isInfo: true, + ), + ], + ), + ), + const SizedBox(width: UiConstants.space2), + Expanded( + child: Column( + children: [ + _StatusCard( + label: 'Checked In', + value: '$checkedIn/$coverageNeeded', + icon: UiIcons.success, + isInfo: true, + ), + ], + ), + ), + ], + ), + ], + ), ), ); } } -class _LiveActivityData { - - factory _LiveActivityData.empty() { - return const _LiveActivityData( - totalNeeded: 0, - totalAssigned: 0, - totalCost: 0, - checkedInCount: 0, - lateCount: 0, - ); - } - const _LiveActivityData({ - required this.totalNeeded, - required this.totalAssigned, - required this.totalCost, - required this.checkedInCount, - required this.lateCount, +class _StatusCard extends StatelessWidget { + const _StatusCard({ + required this.label, + required this.value, + required this.icon, + this.isError = false, + this.isInfo = false, }); - final int totalNeeded; - final int totalAssigned; - final double totalCost; - final int checkedInCount; - final int lateCount; + final String label; + final String value; + final IconData icon; + final bool isError; + final bool isInfo; + + @override + Widget build(BuildContext context) { + Color bg = UiColors.bgSecondary; + Color border = UiColors.border; + Color iconColor = UiColors.iconSecondary; + Color textColor = UiColors.textPrimary; + + if (isError) { + bg = UiColors.tagError.withAlpha(80); + border = UiColors.borderError.withAlpha(80); + iconColor = UiColors.textError; + textColor = UiColors.textError; + } else if (isInfo) { + bg = UiColors.tagInProgress.withAlpha(80); + border = UiColors.primary.withValues(alpha: 0.2); + iconColor = UiColors.primary; + textColor = UiColors.primary; + } + + return Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: bg, + border: Border.all(color: border), + borderRadius: UiConstants.radiusMd, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: iconColor), + const SizedBox(width: UiConstants.space2), + Expanded( + child: Text( + label, + style: UiTypography.footnote1m.copyWith( + color: textColor.withValues(alpha: 0.8), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: UiConstants.space1), + Text( + value, + style: UiTypography.headline3m.copyWith(color: textColor), + ), + ], + ), + ); + } } diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart index 2d0baa23..4a8f8e1f 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart @@ -5,9 +5,11 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'section_layout.dart'; +import 'package:client_home/src/presentation/widgets/section_layout.dart'; -/// A widget that allows clients to reorder recent shifts. +/// A widget that allows clients to reorder recent orders. +/// +/// Displays a horizontal list of [RecentOrder] cards with a reorder button. class ReorderWidget extends StatelessWidget { /// Creates a [ReorderWidget]. const ReorderWidget({ @@ -18,7 +20,7 @@ class ReorderWidget extends StatelessWidget { }); /// Recent completed orders for reorder. - final List orders; + final List orders; /// Optional title for the section. final String? title; @@ -34,21 +36,18 @@ class ReorderWidget extends StatelessWidget { final TranslationsClientHomeReorderEn i18n = t.client_home.reorder; - final List recentOrders = orders; - return SectionLayout( title: title, subtitle: subtitle, child: SizedBox( - height: 164, + height: 140, child: ListView.separated( scrollDirection: Axis.horizontal, - itemCount: recentOrders.length, + itemCount: orders.length, separatorBuilder: (BuildContext context, int index) => const SizedBox(width: UiConstants.space3), itemBuilder: (BuildContext context, int index) { - final ReorderItem order = recentOrders[index]; - final double totalCost = order.totalCost; + final RecentOrder order = orders[index]; return Container( width: 260, @@ -71,9 +70,7 @@ class ReorderWidget extends StatelessWidget { width: 36, height: 36, decoration: BoxDecoration( - color: UiColors.primary.withValues( - alpha: 0.1, - ), + color: UiColors.primary.withValues(alpha: 0.1), borderRadius: UiConstants.radiusLg, ), child: const Icon( @@ -92,12 +89,14 @@ class ReorderWidget extends StatelessWidget { style: UiTypography.body2b, overflow: TextOverflow.ellipsis, ), - Text( - order.location, - style: - UiTypography.footnote1r.textSecondary, - overflow: TextOverflow.ellipsis, - ), + if (order.hubName != null && + order.hubName!.isNotEmpty) + Text( + order.hubName!, + style: + UiTypography.footnote1r.textSecondary, + overflow: TextOverflow.ellipsis, + ), ], ), ), @@ -107,12 +106,11 @@ class ReorderWidget extends StatelessWidget { Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ + // ASSUMPTION: No i18n key for 'positions' under + // reorder section — carrying forward existing + // hardcoded string pattern for this migration. Text( - '\$${totalCost.toStringAsFixed(0)}', - style: UiTypography.body1b, - ), - Text( - '${i18n.per_hr(amount: order.hourlyRate.toString())} · ${order.hours}h', + '${order.positionCount} positions', style: UiTypography.footnote2r.textSecondary, ), ], @@ -124,7 +122,7 @@ class ReorderWidget extends StatelessWidget { children: [ _Badge( icon: UiIcons.success, - text: order.type, + text: order.orderType.value, color: UiColors.primary, bg: UiColors.buttonSecondaryStill, textColor: UiColors.primary, @@ -132,7 +130,7 @@ class ReorderWidget extends StatelessWidget { const SizedBox(width: UiConstants.space2), _Badge( icon: UiIcons.building, - text: '${order.workers}', + text: '${order.positionCount}', color: UiColors.textSecondary, bg: UiColors.buttonSecondaryStill, textColor: UiColors.textSecondary, @@ -140,24 +138,13 @@ class ReorderWidget extends StatelessWidget { ], ), const Spacer(), - UiButton.secondary( size: UiButtonSize.small, text: i18n.reorder_button, leadingIcon: UiIcons.zap, iconSize: 12, fullWidth: true, - onPressed: () => - _handleReorderPressed(context, { - 'orderId': order.orderId, - 'title': order.title, - 'location': order.location, - 'hourlyRate': order.hourlyRate, - 'hours': order.hours, - 'workers': order.workers, - 'type': order.type, - 'totalCost': order.totalCost, - }), + onPressed: () => _handleReorderPressed(order), ), ], ), @@ -168,28 +155,27 @@ class ReorderWidget extends StatelessWidget { ); } - void _handleReorderPressed(BuildContext context, Map data) { - // Override start date with today's date as requested - final Map populatedData = Map.from(data) - ..['startDate'] = DateTime.now(); + /// Navigates to the appropriate create-order form pre-populated + /// with data from the selected [order]. + void _handleReorderPressed(RecentOrder order) { + final Map populatedData = { + 'orderId': order.id, + 'title': order.title, + 'location': order.hubName ?? '', + 'workers': order.positionCount, + 'type': order.orderType.value, + 'startDate': DateTime.now(), + }; - final String? typeStr = populatedData['type']?.toString(); - if (typeStr == null || typeStr.isEmpty) { - return; - } - - final OrderType orderType = OrderType.fromString(typeStr); - switch (orderType) { + switch (order.orderType) { case OrderType.recurring: Modular.to.toCreateOrderRecurring(arguments: populatedData); - break; case OrderType.permanent: Modular.to.toCreateOrderPermanent(arguments: populatedData); - break; case OrderType.oneTime: - default: + case OrderType.rapid: + case OrderType.unknown: Modular.to.toCreateOrderOneTime(arguments: populatedData); - break; } } } @@ -202,6 +188,7 @@ class _Badge extends StatelessWidget { required this.bg, required this.textColor, }); + final IconData icon; final String text; final Color color; diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/shift_order_form_sheet.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/shift_order_form_sheet.dart deleted file mode 100644 index 8bb83203..00000000 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/shift_order_form_sheet.dart +++ /dev/null @@ -1,1449 +0,0 @@ -import 'package:core_localization/core_localization.dart'; -import 'package:design_system/design_system.dart'; -import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:krow_data_connect/krow_data_connect.dart' as dc; - -class _RoleOption { - const _RoleOption({ - required this.id, - required this.name, - required this.costPerHour, - }); - - final String id; - final String name; - final double costPerHour; -} - -class _VendorOption { - const _VendorOption({required this.id, required this.name}); - - final String id; - final String name; -} - -/// A bottom sheet form for creating or reordering shifts. -/// -/// This widget provides a comprehensive form matching the design patterns -/// used in view_order_card.dart for consistency across the app. -class ShiftOrderFormSheet extends StatefulWidget { - - /// Creates a [ShiftOrderFormSheet]. - const ShiftOrderFormSheet({ - super.key, - this.initialData, - required this.onSubmit, - this.isLoading = false, - }); - /// Initial data for the form (e.g. from a reorder action). - final Map? initialData; - - /// Callback when the form is submitted. - final Function(Map data) onSubmit; - - /// Whether the submission is loading. - final bool isLoading; - - @override - State createState() => _ShiftOrderFormSheetState(); -} - -class _ShiftOrderFormSheetState extends State { - late TextEditingController _dateController; - late TextEditingController _globalLocationController; - late TextEditingController _orderNameController; - - late List> _positions; - - final dc.ExampleConnector _dataConnect = dc.ExampleConnector.instance; - List<_VendorOption> _vendors = const <_VendorOption>[]; - List<_RoleOption> _roles = const <_RoleOption>[]; - String? _selectedVendorId; - List _hubs = const []; - dc.ListTeamHubsByOwnerIdTeamHubs? _selectedHub; - bool _showSuccess = false; - Map? _submitData; - bool _isSubmitting = false; - String? _errorMessage; - - @override - void initState() { - super.initState(); - - // Initialize date controller (always today for reorder sheet) - final DateTime today = DateTime.now(); - final String initialDate = today.toIso8601String().split('T')[0]; - _dateController = TextEditingController(text: initialDate); - - // Initialize location controller - _globalLocationController = TextEditingController( - text: widget.initialData?['location'] ?? - widget.initialData?['locationAddress'] ?? - '', - ); - _orderNameController = TextEditingController( - text: widget.initialData?['eventName']?.toString() ?? '', - ); - - // Initialize positions - _positions = >[ - { - 'roleId': widget.initialData?['roleId'] ?? '', - 'roleName': widget.initialData?['title'] ?? widget.initialData?['role'] ?? '', - 'count': widget.initialData?['workersNeeded'] ?? - widget.initialData?['workers_needed'] ?? - 1, - 'start_time': widget.initialData?['startTime'] ?? - widget.initialData?['start_time'] ?? - '09:00', - 'end_time': widget.initialData?['endTime'] ?? - widget.initialData?['end_time'] ?? - '17:00', - 'lunch_break': 'NO_BREAK', - 'location': null, - }, - ]; - - _loadVendors(); - _loadHubs(); - _loadOrderDetails(); - } - - @override - void dispose() { - _dateController.dispose(); - _globalLocationController.dispose(); - _orderNameController.dispose(); - super.dispose(); - } - - void _addPosition() { - setState(() { - _positions.add({ - 'roleId': '', - 'roleName': '', - 'count': 1, - 'start_time': '09:00', - 'end_time': '17:00', - 'lunch_break': 'NO_BREAK', - 'location': null, - }); - }); - } - - void _removePosition(int index) { - if (_positions.length > 1) { - setState(() => _positions.removeAt(index)); - } - } - - void _updatePosition(int index, String key, dynamic value) { - setState(() => _positions[index][key] = value); - } - - double _calculateTotalCost() { - double total = 0; - for (final Map pos in _positions) { - double hours = 8.0; - try { - final List startParts = pos['start_time'].toString().split(':'); - final List endParts = pos['end_time'].toString().split(':'); - final double startH = - int.parse(startParts[0]) + int.parse(startParts[1]) / 60; - final double endH = - int.parse(endParts[0]) + int.parse(endParts[1]) / 60; - hours = endH - startH; - if (hours < 0) hours += 24; - } catch (_) {} - final String roleId = pos['roleId']?.toString() ?? ''; - final double rate = _rateForRole(roleId); - total += hours * rate * (pos['count'] as int); - } - return total; - } - - String _getShiftType() { - final String? type = widget.initialData?['type']?.toString(); - if (type != null && type.isNotEmpty) { - switch (type) { - case 'PERMANENT': - return 'Long Term'; - case 'RECURRING': - return 'Multi-Day'; - case 'RAPID': - return 'Rapid'; - case 'ONE_TIME': - return 'One-Time Order'; - } - } - // Determine shift type based on initial data - final dynamic initialData = widget.initialData; - if (initialData != null) { - if (initialData['permanent'] == true || initialData['duration_months'] != null) { - return 'Long Term'; - } - if (initialData['recurring'] == true || initialData['duration_days'] != null) { - return 'Multi-Day'; - } - } - return 'One-Time Order'; - } - - Future _handleSubmit() async { - if (_isSubmitting) return; - - setState(() { - _isSubmitting = true; - _errorMessage = null; - }); - - try { - await _submitNewOrder(); - } catch (e) { - if (!mounted) return; - setState(() { - _isSubmitting = false; - _errorMessage = 'Failed to create order. Please try again.'; - }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(_errorMessage!)), - ); - } - } - - Future _submitNewOrder() async { - final String? businessId = dc.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - return; - } - final dc.ListTeamHubsByOwnerIdTeamHubs? selectedHub = _selectedHub; - if (selectedHub == null) { - return; - } - - final DateTime date = DateTime.parse(_dateController.text); - final DateTime dateOnly = DateTime.utc(date.year, date.month, date.day); - final fdc.Timestamp orderTimestamp = _toTimestamp(dateOnly); - final dc.OrderType orderType = - _orderTypeFromValue(widget.initialData?['type']?.toString()); - - final fdc.OperationResult - orderResult = await _dataConnect - .createOrder( - businessId: businessId, - orderType: orderType, - teamHubId: selectedHub.id, - ) - .vendorId(_selectedVendorId) - .eventName(_orderNameController.text) - .status(dc.OrderStatus.POSTED) - .date(orderTimestamp) - .execute(); - - final String orderId = orderResult.data.order_insert.id; - - final int workersNeeded = _positions.fold( - 0, - (int sum, Map pos) => sum + (pos['count'] as int), - ); - final String shiftTitle = - 'Shift 1 ${DateFormat('yyyy-MM-dd').format(date)}'; - final double shiftCost = _calculateTotalCost(); - - final fdc.OperationResult - shiftResult = await _dataConnect - .createShift(title: shiftTitle, orderId: orderId) - .date(orderTimestamp) - .location(selectedHub.hubName) - .locationAddress(selectedHub.address) - .latitude(selectedHub.latitude) - .longitude(selectedHub.longitude) - .placeId(selectedHub.placeId) - .city(selectedHub.city) - .state(selectedHub.state) - .street(selectedHub.street) - .country(selectedHub.country) - .status(dc.ShiftStatus.OPEN) - .workersNeeded(workersNeeded) - .filled(0) - .durationDays(1) - .cost(shiftCost) - .execute(); - - final String shiftId = shiftResult.data.shift_insert.id; - - for (final Map pos in _positions) { - final String roleId = pos['roleId']?.toString() ?? ''; - if (roleId.isEmpty) { - continue; - } - final DateTime start = _parseTime(date, pos['start_time'].toString()); - final DateTime end = _parseTime(date, pos['end_time'].toString()); - final DateTime normalizedEnd = - end.isBefore(start) ? end.add(const Duration(days: 1)) : end; - final double hours = normalizedEnd.difference(start).inMinutes / 60.0; - final int count = pos['count'] as int; - final double rate = _rateForRole(roleId); - final double totalValue = rate * hours * count; - final String lunchBreak = pos['lunch_break'] as String; - - await _dataConnect - .createShiftRole( - shiftId: shiftId, - roleId: roleId, - count: count, - ) - .startTime(_toTimestamp(start)) - .endTime(_toTimestamp(normalizedEnd)) - .hours(hours) - .breakType(_breakDurationFromValue(lunchBreak)) - .isBreakPaid(_isBreakPaid(lunchBreak)) - .totalValue(totalValue) - .execute(); - } - - await _dataConnect - .updateOrder(id: orderId, teamHubId: selectedHub.id) - .shifts(fdc.AnyValue([shiftId])) - .execute(); - - if (!mounted) return; - setState(() { - _submitData = { - 'orderId': orderId, - 'date': _dateController.text, - }; - _showSuccess = true; - _isSubmitting = false; - }); - } - - Future _loadVendors() async { - try { - final fdc.QueryResult result = - await _dataConnect.listVendors().execute(); - final List<_VendorOption> vendors = result.data.vendors - .map( - (dc.ListVendorsVendors vendor) => - _VendorOption(id: vendor.id, name: vendor.companyName), - ) - .toList(); - if (!mounted) return; - setState(() { - _vendors = vendors; - final String? current = _selectedVendorId; - if (current == null || - !vendors.any((_VendorOption v) => v.id == current)) { - _selectedVendorId = vendors.isNotEmpty ? vendors.first.id : null; - } - }); - if (_selectedVendorId != null) { - await _loadRolesForVendor(_selectedVendorId!); - } - } catch (_) { - if (!mounted) return; - setState(() { - _vendors = const <_VendorOption>[]; - _roles = const <_RoleOption>[]; - }); - } - } - - Future _loadHubs() async { - final String? businessId = dc.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - return; - } - - try { - final fdc.QueryResult< - dc.ListTeamHubsByOwnerIdData, - dc.ListTeamHubsByOwnerIdVariables> result = - await _dataConnect.listTeamHubsByOwnerId(ownerId: businessId).execute(); - final List hubs = result.data.teamHubs; - if (!mounted) return; - setState(() { - _hubs = hubs; - _selectedHub = hubs.isNotEmpty ? hubs.first : null; - if (_selectedHub != null) { - _globalLocationController.text = _selectedHub!.address; - } - }); - } catch (_) { - if (!mounted) return; - setState(() { - _hubs = const []; - _selectedHub = null; - }); - } - } - - Future _loadRolesForVendor(String vendorId) async { - try { - final fdc.QueryResult - result = - await _dataConnect.listRolesByVendorId(vendorId: vendorId).execute(); - final List<_RoleOption> roles = result.data.roles - .map( - (dc.ListRolesByVendorIdRoles role) => _RoleOption( - id: role.id, - name: role.name, - costPerHour: role.costPerHour, - ), - ) - .toList(); - if (!mounted) return; - setState(() => _roles = roles); - } catch (_) { - if (!mounted) return; - setState(() => _roles = const <_RoleOption>[]); - } - } - - Future _loadOrderDetails() async { - final String? orderId = widget.initialData?['orderId']?.toString(); - if (orderId == null || orderId.isEmpty) { - return; - } - - final String? businessId = dc.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - return; - } - - try { - final fdc.QueryResult< - dc.ListShiftRolesByBusinessAndOrderData, - dc.ListShiftRolesByBusinessAndOrderVariables> result = await _dataConnect - .listShiftRolesByBusinessAndOrder( - businessId: businessId, - orderId: orderId, - ) - .execute(); - - final List shiftRoles = - result.data.shiftRoles; - if (shiftRoles.isEmpty) { - return; - } - - final dc.ListShiftRolesByBusinessAndOrderShiftRolesShift firstShift = - shiftRoles.first.shift; - final dc.ListShiftRolesByBusinessAndOrderShiftRolesShiftOrderTeamHub - teamHub = firstShift.order.teamHub; - await _loadHubsAndSelect( - placeId: teamHub.placeId, - hubName: teamHub.hubName, - address: teamHub.address, - ); - _orderNameController.text = firstShift.order.eventName ?? ''; - - final String? vendorId = firstShift.order.vendorId; - if (mounted) { - setState(() { - _selectedVendorId = vendorId; - }); - } - if (vendorId != null && vendorId.isNotEmpty) { - await _loadRolesForVendor(vendorId); - } - - final List> positions = - shiftRoles.map((dc.ListShiftRolesByBusinessAndOrderShiftRoles role) { - return { - 'roleId': role.roleId, - 'roleName': role.role.name, - 'count': role.count, - 'start_time': _formatTimeForField(role.startTime), - 'end_time': _formatTimeForField(role.endTime), - 'lunch_break': _breakValueFromDuration(role.breakType), - 'location': null, - }; - }).toList(); - - if (!mounted) return; - setState(() { - _positions = positions; - }); - } catch (_) { - // Keep defaults on failure. - } - } - - Future _loadHubsAndSelect({ - String? placeId, - String? hubName, - String? address, - }) async { - final String? businessId = dc.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - return; - } - - try { - final fdc.QueryResult< - dc.ListTeamHubsByOwnerIdData, - dc.ListTeamHubsByOwnerIdVariables> result = - await _dataConnect.listTeamHubsByOwnerId(ownerId: businessId).execute(); - final List hubs = result.data.teamHubs; - dc.ListTeamHubsByOwnerIdTeamHubs? selected; - - if (placeId != null && placeId.isNotEmpty) { - for (final dc.ListTeamHubsByOwnerIdTeamHubs hub in hubs) { - if (hub.placeId == placeId) { - selected = hub; - break; - } - } - } - - if (selected == null && hubName != null && hubName.isNotEmpty) { - for (final dc.ListTeamHubsByOwnerIdTeamHubs hub in hubs) { - if (hub.hubName == hubName) { - selected = hub; - break; - } - } - } - - if (selected == null && address != null && address.isNotEmpty) { - for (final dc.ListTeamHubsByOwnerIdTeamHubs hub in hubs) { - if (hub.address == address) { - selected = hub; - break; - } - } - } - - selected ??= hubs.isNotEmpty ? hubs.first : null; - - if (!mounted) return; - setState(() { - _hubs = hubs; - _selectedHub = selected; - if (selected != null) { - _globalLocationController.text = selected.address; - } - }); - } catch (_) { - if (!mounted) return; - setState(() { - _hubs = const []; - _selectedHub = null; - }); - } - } - - String _formatTimeForField(fdc.Timestamp? value) { - if (value == null) return ''; - try { - return DateFormat('HH:mm').format(value.toDateTime()); - } catch (_) { - return ''; - } - } - - String _breakValueFromDuration(dc.EnumValue? breakType) { - final dc.BreakDuration? value = - breakType is dc.Known ? breakType.value : null; - switch (value) { - case dc.BreakDuration.MIN_10: - return 'MIN_10'; - case dc.BreakDuration.MIN_15: - return 'MIN_15'; - case dc.BreakDuration.MIN_30: - return 'MIN_30'; - case dc.BreakDuration.MIN_45: - return 'MIN_45'; - case dc.BreakDuration.MIN_60: - return 'MIN_60'; - case dc.BreakDuration.NO_BREAK: - case null: - return 'NO_BREAK'; - } - } - - dc.BreakDuration _breakDurationFromValue(String value) { - switch (value) { - case 'MIN_10': - return dc.BreakDuration.MIN_10; - case 'MIN_15': - return dc.BreakDuration.MIN_15; - case 'MIN_30': - return dc.BreakDuration.MIN_30; - case 'MIN_45': - return dc.BreakDuration.MIN_45; - case 'MIN_60': - return dc.BreakDuration.MIN_60; - default: - return dc.BreakDuration.NO_BREAK; - } - } - - bool _isBreakPaid(String value) { - return value == 'MIN_10' || value == 'MIN_15'; - } - - dc.OrderType _orderTypeFromValue(String? value) { - switch (value) { - case 'PERMANENT': - return dc.OrderType.PERMANENT; - case 'RECURRING': - return dc.OrderType.RECURRING; - case 'RAPID': - return dc.OrderType.RAPID; - case 'ONE_TIME': - default: - return dc.OrderType.ONE_TIME; - } - } - - _RoleOption? _roleById(String roleId) { - for (final _RoleOption role in _roles) { - if (role.id == roleId) { - return role; - } - } - return null; - } - - double _rateForRole(String roleId) { - return _roleById(roleId)?.costPerHour ?? 0; - } - - DateTime _parseTime(DateTime date, String time) { - DateTime parsed; - try { - parsed = DateFormat.Hm().parse(time); - } catch (_) { - parsed = DateFormat.jm().parse(time); - } - return DateTime( - date.year, - date.month, - date.day, - parsed.hour, - parsed.minute, - ); - } - - fdc.Timestamp _toTimestamp(DateTime date) { - final DateTime utc = date.toUtc(); - final int millis = utc.millisecondsSinceEpoch; - final int seconds = millis ~/ 1000; - final int nanos = (millis % 1000) * 1000000; - return fdc.Timestamp(nanos, seconds); - } - - @override - Widget build(BuildContext context) { - if (_showSuccess) { - final TranslationsClientCreateOrderOneTimeEn labels = - t.client_create_order.one_time; - return _buildSuccessView( - title: labels.success_title, - message: labels.success_message, - buttonLabel: labels.back_to_orders, - ); - } - - return Container( - height: MediaQuery.of(context).size.height * 0.95, - decoration: const BoxDecoration( - color: UiColors.bgPrimary, - borderRadius: BorderRadius.vertical(top: Radius.circular(UiConstants.space6)), - ), - child: Column( - children: [ - _buildHeader(), - Expanded( - child: ListView( - padding: const EdgeInsets.all(UiConstants.space5), - children: [ - Text( - widget.initialData != null ? 'Edit Your Order' : 'Create New Order', - style: UiTypography.headline3m.textPrimary, - ), - const SizedBox(height: UiConstants.space2), - Text( - widget.initialData != null - ? 'Review and adjust the details below' - : 'Fill in the details for your staffing needs', - style: UiTypography.body2r.textSecondary, - ), - const SizedBox(height: UiConstants.space5), - - // Shift Type Badge - Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space3, - vertical: UiConstants.space2, - ), - decoration: BoxDecoration( - color: UiColors.primary.withValues(alpha: 0.1), - borderRadius: UiConstants.radiusFull, - border: Border.all( - color: UiColors.primary.withValues(alpha: 0.3), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 8, - height: 8, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: UiColors.primary, - ), - ), - const SizedBox(width: UiConstants.space2), - Text( - _getShiftType(), - style: UiTypography.footnote1b.copyWith( - color: UiColors.primary, - ), - ), - ], - ), - ), - const SizedBox(height: UiConstants.space5), - - _buildSectionHeader('VENDOR'), - _buildVendorDropdown(), - const SizedBox(height: UiConstants.space4), - - _buildSectionHeader('ORDER NAME'), - _buildOrderNameField(), - const SizedBox(height: UiConstants.space4), - - _buildSectionHeader('DATE'), - _buildDateField(), - const SizedBox(height: UiConstants.space4), - - _buildSectionHeader('HUB'), - _buildHubField(), - const SizedBox(height: UiConstants.space5), - - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'POSITIONS', - style: UiTypography.footnote2r.textSecondary, - ), - GestureDetector( - onTap: _addPosition, - child: Row( - children: [ - const Icon( - UiIcons.add, - size: 16, - color: UiColors.primary, - ), - const SizedBox(width: UiConstants.space1), - Text( - 'Add Position', - style: UiTypography.footnote1m.copyWith( - color: UiColors.primary, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: UiConstants.space3), - - ..._positions.asMap().entries.map((MapEntry> entry) { - return _buildPositionCard(entry.key, entry.value); - }), - - const SizedBox(height: UiConstants.space5), - - // Total Cost Display - Container( - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - color: UiColors.bgSecondary, - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Estimated Total', - style: UiTypography.body1b.textPrimary, - ), - Text( - '\$${_calculateTotalCost().toStringAsFixed(2)}', - style: UiTypography.headline3m.copyWith( - color: UiColors.primary, - ), - ), - ], - ), - ), - const SizedBox(height: UiConstants.space5), - - UiButton.primary( - text: widget.initialData != null ? 'Update Order' : 'Post Order', - onPressed: (widget.isLoading || _isSubmitting) ? null : _handleSubmit, - ), - SizedBox(height: MediaQuery.of(context).padding.bottom + UiConstants.space5), - ], - ), - ), - ], - ), - ); - } - - Widget _buildHeader() { - return Container( - padding: const EdgeInsets.all(UiConstants.space5), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - UiColors.primary, - UiColors.primary.withValues(alpha: 0.8), - ], - ), - borderRadius: const BorderRadius.vertical(top: Radius.circular(UiConstants.space6)), - ), - child: Row( - children: [ - GestureDetector( - onTap: () => Navigator.pop(context), - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: UiColors.white.withValues(alpha: 0.2), - borderRadius: UiConstants.radiusMd, - ), - child: const Icon( - UiIcons.chevronLeft, - color: UiColors.white, - size: 24, - ), - ), - ), - const SizedBox(width: UiConstants.space3), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _getShiftType(), - style: UiTypography.headline3m.copyWith(color: UiColors.white), - ), - Text( - 'Configure your staffing needs', - style: UiTypography.footnote2r.copyWith( - color: UiColors.white.withValues(alpha: 0.8), - ), - ), - ], - ), - ], - ), - ); - } - - Widget _buildSectionHeader(String title) { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text(title, style: UiTypography.footnote2r.textSecondary), - ); - } - - Widget _buildVendorDropdown() { - return Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space3, - ), - height: 48, - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusMd, - border: Border.all(color: UiColors.border), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - isExpanded: true, - value: _selectedVendorId, - icon: const Icon( - UiIcons.chevronDown, - size: 18, - color: UiColors.iconSecondary, - ), - style: UiTypography.body2r.textPrimary, - items: _vendors.map((_VendorOption vendor) { - return DropdownMenuItem( - value: vendor.id, - child: Text(vendor.name), - ); - }).toList(), - onChanged: (String? newValue) { - if (newValue != null) { - setState(() { - _selectedVendorId = newValue; - }); - _loadRolesForVendor(newValue); - } - }, - ), - ), - ); - } - - Widget _buildDateField() { - return GestureDetector( - onTap: () async { - final DateTime? selectedDate = await showDatePicker( - context: context, - initialDate: _dateController.text.isNotEmpty - ? DateTime.parse(_dateController.text) - : DateTime.now().add(const Duration(days: 1)), - firstDate: DateTime.now(), - lastDate: DateTime.now().add(const Duration(days: 365 * 2)), - ); - if (selectedDate != null) { - setState(() { - _dateController.text = - selectedDate.toIso8601String().split('T')[0]; - }); - } - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space3, - vertical: UiConstants.space3, - ), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusMd, - border: Border.all(color: UiColors.border), - ), - child: Row( - children: [ - const Icon(UiIcons.calendar, size: 20, color: UiColors.iconSecondary), - const SizedBox(width: UiConstants.space2), - Expanded( - child: Text( - _dateController.text.isNotEmpty - ? DateFormat('EEEE, MMM d, y') - .format(DateTime.parse(_dateController.text)) - : 'Select date', - style: _dateController.text.isNotEmpty - ? UiTypography.body2r.textPrimary - : UiTypography.body2r.textSecondary, - ), - ), - const Icon( - UiIcons.chevronDown, - size: 18, - color: UiColors.iconSecondary, - ), - ], - ), - ), - ); - } - - Widget _buildHubField() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusMd, - border: Border.all(color: UiColors.border), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - isExpanded: true, - value: _selectedHub, - icon: const Icon( - UiIcons.chevronDown, - size: 18, - color: UiColors.iconSecondary, - ), - onChanged: (dc.ListTeamHubsByOwnerIdTeamHubs? hub) { - if (hub != null) { - setState(() { - _selectedHub = hub; - _globalLocationController.text = hub.address; - }); - } - }, - items: _hubs.map((dc.ListTeamHubsByOwnerIdTeamHubs hub) { - return DropdownMenuItem( - value: hub, - child: Text( - hub.hubName, - style: UiTypography.body2r.textPrimary, - ), - ); - }).toList(), - ), - ), - ); - } - - Widget _buildOrderNameField() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusMd, - border: Border.all(color: UiColors.border), - ), - child: TextField( - controller: _orderNameController, - decoration: const InputDecoration( - hintText: 'Order name', - border: InputBorder.none, - ), - style: UiTypography.body2r.textPrimary, - ), - ); - } - - Widget _buildPositionCard(int index, Map pos) { - return Container( - margin: const EdgeInsets.only(bottom: UiConstants.space3), - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'POSITION #${index + 1}', - style: UiTypography.footnote1m.textSecondary, - ), - if (_positions.length > 1) - GestureDetector( - onTap: () => _removePosition(index), - child: Text( - 'Remove', - style: UiTypography.footnote1m.copyWith( - color: UiColors.destructive, - ), - ), - ), - ], - ), - const SizedBox(height: UiConstants.space3), - - _buildDropdownField( - hint: 'Select role', - value: pos['roleId'], - items: [ - ..._roles.map((_RoleOption role) => role.id), - if (pos['roleId'] != null && - pos['roleId'].toString().isNotEmpty && - !_roles.any( - (_RoleOption role) => role.id == pos['roleId'].toString(), - )) - pos['roleId'].toString(), - ], - itemBuilder: (dynamic roleId) { - final _RoleOption? role = _roleById(roleId.toString()); - if (role == null) { - final String fallback = pos['roleName']?.toString() ?? ''; - return fallback.isEmpty ? roleId.toString() : fallback; - } - return '${role.name} - \$${role.costPerHour.toStringAsFixed(0)}/hr'; - }, - onChanged: (dynamic val) { - final String roleId = val?.toString() ?? ''; - final _RoleOption? role = _roleById(roleId); - setState(() { - _positions[index]['roleId'] = roleId; - _positions[index]['roleName'] = role?.name ?? ''; - }); - }, - ), - - const SizedBox(height: UiConstants.space3), - - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Lunch Break', - style: UiTypography.footnote2r.textSecondary, - ), - const SizedBox(height: UiConstants.space1), - _buildDropdownField( - hint: 'No Break', - value: pos['lunch_break'], - items: [ - 'NO_BREAK', - 'MIN_10', - 'MIN_15', - 'MIN_30', - 'MIN_45', - 'MIN_60', - ], - itemBuilder: (dynamic value) { - switch (value.toString()) { - case 'MIN_10': - return '10 min (Paid)'; - case 'MIN_15': - return '15 min (Paid)'; - case 'MIN_30': - return '30 min (Unpaid)'; - case 'MIN_45': - return '45 min (Unpaid)'; - case 'MIN_60': - return '60 min (Unpaid)'; - default: - return 'No Break'; - } - }, - onChanged: (dynamic val) => _updatePosition(index, 'lunch_break', val), - ), - ], - ), - ), - ], - ), - - const SizedBox(height: UiConstants.space3), - - Row( - children: [ - Expanded( - child: _buildInlineTimeInput( - label: 'Start', - value: pos['start_time'], - onTap: () async { - final TimeOfDay? time = await showTimePicker( - context: context, - initialTime: TimeOfDay.now(), - ); - if (time != null) { - _updatePosition( - index, - 'start_time', - '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}', - ); - } - }, - ), - ), - const SizedBox(width: UiConstants.space2), - Expanded( - child: _buildInlineTimeInput( - label: 'End', - value: pos['end_time'], - onTap: () async { - final TimeOfDay? time = await showTimePicker( - context: context, - initialTime: TimeOfDay.now(), - ); - if (time != null) { - _updatePosition( - index, - 'end_time', - '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}', - ); - } - }, - ), - ), - const SizedBox(width: UiConstants.space2), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Workers', - style: UiTypography.footnote2r.textSecondary, - ), - const SizedBox(height: UiConstants.space1), - Container( - height: 40, - decoration: BoxDecoration( - color: UiColors.bgSecondary, - borderRadius: UiConstants.radiusSm, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - GestureDetector( - onTap: () { - if ((pos['count'] as int) > 1) { - _updatePosition( - index, - 'count', - (pos['count'] as int) - 1, - ); - } - }, - child: const Icon(UiIcons.minus, size: 12), - ), - Text( - '${pos['count']}', - style: UiTypography.body2b.textPrimary, - ), - GestureDetector( - onTap: () => _updatePosition( - index, - 'count', - (pos['count'] as int) + 1, - ), - child: const Icon(UiIcons.add, size: 12), - ), - ], - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: UiConstants.space4), - - if (pos['location'] == null) - GestureDetector( - onTap: () => _updatePosition(index, 'location', ''), - child: Row( - children: [ - const Icon(UiIcons.mapPin, size: 14, color: UiColors.primary), - const SizedBox(width: UiConstants.space1), - Text( - 'Use different location for this position', - style: UiTypography.footnote1m.copyWith( - color: UiColors.primary, - ), - ), - ], - ), - ) - else - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Custom Location', - style: UiTypography.footnote2r.textSecondary, - ), - GestureDetector( - onTap: () => _updatePosition(index, 'location', null), - child: Text( - 'Remove', - style: UiTypography.footnote1m.copyWith( - color: UiColors.destructive, - ), - ), - ), - ], - ), - const SizedBox(height: UiConstants.space2), - Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space3, - vertical: UiConstants.space2, - ), - decoration: BoxDecoration( - color: UiColors.bgSecondary, - borderRadius: UiConstants.radiusSm, - border: Border.all(color: UiColors.border), - ), - child: TextField( - controller: TextEditingController(text: pos['location']), - decoration: const InputDecoration( - hintText: 'Enter custom location', - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, - ), - style: UiTypography.body2r.textPrimary, - onChanged: (String value) => - _updatePosition(index, 'location', value), - ), - ), - ], - ), - ], - ), - ); - } - - Widget _buildDropdownField({ - required String hint, - required dynamic value, - required List items, - required String Function(T) itemBuilder, - required void Function(T?) onChanged, - }) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), - height: 48, - decoration: BoxDecoration( - color: UiColors.bgSecondary, - borderRadius: UiConstants.radiusMd, - border: Border.all(color: UiColors.border), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - isExpanded: true, - value: value.toString().isEmpty ? null : value as T?, - hint: Text(hint, style: UiTypography.body2r.textSecondary), - icon: const Icon(UiIcons.chevronDown, size: 18), - style: UiTypography.body2r.textPrimary, - items: items - .map( - (T item) => DropdownMenuItem( - value: item, - child: Text(itemBuilder(item)), - ), - ) - .toList(), - onChanged: onChanged, - ), - ), - ); - } - - Widget _buildSuccessView({ - required String title, - required String message, - required String buttonLabel, - }) { - return Container( - width: double.infinity, - height: MediaQuery.of(context).size.height * 0.95, - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [UiColors.primary, UiColors.buttonPrimaryHover], - ), - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - child: SafeArea( - child: Center( - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 40), - padding: const EdgeInsets.all(UiConstants.space8), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusLg * 1.5, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.2), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 64, - height: 64, - decoration: const BoxDecoration( - color: UiColors.accent, - shape: BoxShape.circle, - ), - child: const Center( - child: Icon( - UiIcons.check, - color: UiColors.black, - size: 32, - ), - ), - ), - const SizedBox(height: UiConstants.space6), - Text( - title, - style: UiTypography.headline2m.textPrimary, - textAlign: TextAlign.center, - ), - const SizedBox(height: UiConstants.space3), - Text( - message, - textAlign: TextAlign.center, - style: UiTypography.body2r.textSecondary.copyWith( - height: 1.5, - ), - ), - const SizedBox(height: UiConstants.space8), - SizedBox( - width: double.infinity, - child: UiButton.primary( - text: buttonLabel, - onPressed: () { - widget.onSubmit(_submitData ?? {}); - Navigator.pop(context); - }, - size: UiButtonSize.large, - ), - ), - ], - ), - ), - ), - ), - ); - } - - Widget _buildInlineTimeInput({ - required String label, - required String value, - required VoidCallback onTap, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: UiTypography.footnote2r.textSecondary), - const SizedBox(height: UiConstants.space1), - GestureDetector( - onTap: onTap, - child: Container( - height: 40, - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space2), - decoration: BoxDecoration( - color: UiColors.bgSecondary, - borderRadius: UiConstants.radiusSm, - border: Border.all(color: UiColors.border), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(UiIcons.clock, size: 14, color: UiColors.iconSecondary), - const SizedBox(width: UiConstants.space1), - Text( - value.isEmpty ? '--:--' : value, - style: UiTypography.body2r.textPrimary, - ), - ], - ), - ), - ), - ], - ); - } -} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/spending_widget.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/spending_widget.dart index 0ebb262b..007dca5a 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/spending_widget.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/spending_widget.dart @@ -2,32 +2,26 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'section_layout.dart'; +import 'package:client_home/src/presentation/widgets/section_layout.dart'; /// A widget that displays spending insights for the client. +/// +/// All monetary values are in **cents** and converted to dollars for display. class SpendingWidget extends StatelessWidget { - /// Creates a [SpendingWidget]. const SpendingWidget({ super.key, - required this.weeklySpending, - required this.next7DaysSpending, - required this.weeklyShifts, - required this.next7DaysScheduled, + required this.weeklySpendCents, + required this.projectedNext7DaysCents, this.title, this.subtitle, }); - /// The spending this week. - final double weeklySpending; - /// The spending for the next 7 days. - final double next7DaysSpending; + /// Total spend this week in cents. + final int weeklySpendCents; - /// The number of shifts this week. - final int weeklyShifts; - - /// The number of scheduled shifts for next 7 days. - final int next7DaysScheduled; + /// Projected spend for the next 7 days in cents. + final int projectedNext7DaysCents; /// Optional title for the section. final String? title; @@ -37,6 +31,11 @@ class SpendingWidget extends StatelessWidget { @override Widget build(BuildContext context) { + final String weeklyDisplay = + '\$${(weeklySpendCents / 100).toStringAsFixed(0)}'; + final String projectedDisplay = + '\$${(projectedNext7DaysCents / 100).toStringAsFixed(0)}'; + return SectionLayout( title: title, subtitle: subtitle, @@ -77,19 +76,12 @@ class SpendingWidget extends StatelessWidget { ), const SizedBox(height: UiConstants.space1), Text( - '\$${weeklySpending.toStringAsFixed(0)}', + weeklyDisplay, style: UiTypography.headline3m.copyWith( color: UiColors.white, fontWeight: FontWeight.bold, ), ), - Text( - t.client_home.dashboard.spending.shifts_count(count: weeklyShifts), - style: UiTypography.footnote2r.white.copyWith( - color: UiColors.white.withValues(alpha: 0.6), - fontSize: 9, - ), - ), ], ), ), @@ -106,19 +98,12 @@ class SpendingWidget extends StatelessWidget { ), const SizedBox(height: UiConstants.space1), Text( - '\$${next7DaysSpending.toStringAsFixed(0)}', + projectedDisplay, style: UiTypography.headline4m.copyWith( color: UiColors.white, fontWeight: FontWeight.bold, ), ), - Text( - t.client_home.dashboard.spending.scheduled_count(count: next7DaysScheduled), - style: UiTypography.footnote2r.white.copyWith( - color: UiColors.white.withValues(alpha: 0.6), - fontSize: 9, - ), - ), ], ), ), diff --git a/apps/mobile/packages/features/client/home/pubspec.yaml b/apps/mobile/packages/features/client/home/pubspec.yaml index e2a0a1df..c5043183 100644 --- a/apps/mobile/packages/features/client/home/pubspec.yaml +++ b/apps/mobile/packages/features/client/home/pubspec.yaml @@ -14,19 +14,16 @@ dependencies: flutter_bloc: ^8.1.0 flutter_modular: ^6.3.0 equatable: ^2.0.5 - + # Architecture Packages design_system: path: ../../../design_system core_localization: path: ../../../core_localization krow_domain: ^0.0.1 - krow_data_connect: ^0.0.1 krow_core: path: ../../../core - firebase_data_connect: any - intl: any dev_dependencies: flutter_test: sdk: flutter diff --git a/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart b/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart index 87876299..35e95fbb 100644 --- a/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart +++ b/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart @@ -3,34 +3,38 @@ library; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; -import 'src/data/repositories_impl/hub_repository_impl.dart'; -import 'src/domain/repositories/hub_repository_interface.dart'; -import 'src/domain/usecases/assign_nfc_tag_usecase.dart'; -import 'src/domain/usecases/create_hub_usecase.dart'; -import 'src/domain/usecases/delete_hub_usecase.dart'; -import 'src/domain/usecases/get_cost_centers_usecase.dart'; -import 'src/domain/usecases/get_hubs_usecase.dart'; -import 'src/domain/usecases/update_hub_usecase.dart'; -import 'src/presentation/blocs/client_hubs_bloc.dart'; -import 'src/presentation/blocs/edit_hub/edit_hub_bloc.dart'; -import 'src/presentation/blocs/hub_details/hub_details_bloc.dart'; -import 'src/presentation/pages/client_hubs_page.dart'; -import 'src/presentation/pages/edit_hub_page.dart'; -import 'src/presentation/pages/hub_details_page.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:client_hubs/src/data/repositories_impl/hub_repository_impl.dart'; +import 'package:client_hubs/src/domain/repositories/hub_repository_interface.dart'; +import 'package:client_hubs/src/domain/usecases/assign_nfc_tag_usecase.dart'; +import 'package:client_hubs/src/domain/usecases/create_hub_usecase.dart'; +import 'package:client_hubs/src/domain/usecases/delete_hub_usecase.dart'; +import 'package:client_hubs/src/domain/usecases/get_cost_centers_usecase.dart'; +import 'package:client_hubs/src/domain/usecases/get_hubs_usecase.dart'; +import 'package:client_hubs/src/domain/usecases/update_hub_usecase.dart'; +import 'package:client_hubs/src/presentation/blocs/client_hubs_bloc.dart'; +import 'package:client_hubs/src/presentation/blocs/edit_hub/edit_hub_bloc.dart'; +import 'package:client_hubs/src/presentation/blocs/hub_details/hub_details_bloc.dart'; +import 'package:client_hubs/src/presentation/pages/client_hubs_page.dart'; +import 'package:client_hubs/src/presentation/pages/edit_hub_page.dart'; +import 'package:client_hubs/src/presentation/pages/hub_details_page.dart'; + export 'src/presentation/pages/client_hubs_page.dart'; /// A [Module] for the client hubs feature. +/// +/// Uses [BaseApiService] for all backend access via V2 REST API. class ClientHubsModule extends Module { @override - List get imports => [DataConnectModule()]; + List get imports => [CoreModule()]; @override void binds(Injector i) { // Repositories - i.addLazySingleton(HubRepositoryImpl.new); + i.addLazySingleton( + () => HubRepositoryImpl(apiService: i.get()), + ); // UseCases i.addLazySingleton(GetHubsUseCase.new); @@ -55,7 +59,8 @@ class ClientHubsModule extends Module { r.child( ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.hubDetails), child: (_) { - final Map data = r.args.data as Map; + final Map data = + r.args.data as Map; final Hub hub = data['hub'] as Hub; return HubDetailsPage(hub: hub); }, @@ -65,18 +70,18 @@ class ClientHubsModule extends Module { transition: TransitionType.custom, customTransition: CustomTransition( opaque: false, - transitionBuilder: - ( - BuildContext context, - Animation animation, - Animation secondaryAnimation, - Widget child, - ) { - return FadeTransition(opacity: animation, child: child); - }, + transitionBuilder: ( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return FadeTransition(opacity: animation, child: child); + }, ), child: (_) { - final Map data = r.args.data as Map; + final Map data = + r.args.data as Map; return EditHubPage(hub: data['hub'] as Hub?); }, ); diff --git a/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart b/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart index ac91ac28..8ab96984 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart @@ -1,51 +1,46 @@ -// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs -import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/repositories/hub_repository_interface.dart'; -/// Implementation of [HubRepositoryInterface] that delegates to [dc.HubsConnectorRepository]. +import 'package:client_hubs/src/domain/repositories/hub_repository_interface.dart'; + +/// Implementation of [HubRepositoryInterface] using the V2 REST API. /// -/// This implementation follows the "Buffer Layer" pattern by using a dedicated -/// connector repository from the data_connect package. +/// All backend calls go through [BaseApiService] with [V2ApiEndpoints]. class HubRepositoryImpl implements HubRepositoryInterface { + /// Creates a [HubRepositoryImpl]. + HubRepositoryImpl({required BaseApiService apiService}) + : _apiService = apiService; - HubRepositoryImpl({ - dc.HubsConnectorRepository? connectorRepository, - dc.DataConnectService? service, - }) : _connectorRepository = connectorRepository ?? - dc.DataConnectService.instance.getHubsRepository(), - _service = service ?? dc.DataConnectService.instance; - final dc.HubsConnectorRepository _connectorRepository; - final dc.DataConnectService _service; + /// The API service for HTTP requests. + final BaseApiService _apiService; @override Future> getHubs() async { - final String businessId = await _service.getBusinessId(); - return _connectorRepository.getHubs(businessId: businessId); + final ApiResponse response = + await _apiService.get(V2ApiEndpoints.clientHubs); + final List items = + (response.data as Map)['items'] as List; + return items + .map((dynamic json) => Hub.fromJson(json as Map)) + .toList(); } @override Future> getCostCenters() async { - return _service.run(() async { - final result = await _service.connector.listTeamHudDepartments().execute(); - final Set seen = {}; - final List costCenters = []; - for (final dc.ListTeamHudDepartmentsTeamHudDepartments dep - in result.data.teamHudDepartments) { - final String? cc = dep.costCenter; - if (cc != null && cc.isNotEmpty && !seen.contains(cc)) { - seen.add(cc); - costCenters.add(CostCenter(id: cc, name: dep.name, code: cc)); - } - } - return costCenters; - }); + final ApiResponse response = + await _apiService.get(V2ApiEndpoints.clientCostCenters); + final List items = + (response.data as Map)['items'] as List; + return items + .map((dynamic json) => + CostCenter.fromJson(json as Map)) + .toList(); } @override - Future createHub({ + Future createHub({ required String name, - required String address, + required String fullAddress, String? placeId, double? latitude, double? longitude, @@ -56,41 +51,32 @@ class HubRepositoryImpl implements HubRepositoryInterface { String? zipCode, String? costCenterId, }) async { - final String businessId = await _service.getBusinessId(); - return _connectorRepository.createHub( - businessId: businessId, - name: name, - address: address, - placeId: placeId, - latitude: latitude, - longitude: longitude, - city: city, - state: state, - street: street, - country: country, - zipCode: zipCode, - costCenterId: costCenterId, + final ApiResponse response = await _apiService.post( + V2ApiEndpoints.clientHubCreate, + data: { + 'name': name, + 'fullAddress': fullAddress, + if (placeId != null) 'placeId': placeId, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + if (city != null) 'city': city, + if (state != null) 'state': state, + if (street != null) 'street': street, + if (country != null) 'country': country, + if (zipCode != null) 'zipCode': zipCode, + if (costCenterId != null) 'costCenterId': costCenterId, + }, ); + final Map data = + response.data as Map; + return data['hubId'] as String; } @override - Future deleteHub(String id) async { - final String businessId = await _service.getBusinessId(); - return _connectorRepository.deleteHub(businessId: businessId, id: id); - } - - @override - Future assignNfcTag({required String hubId, required String nfcTagId}) { - throw UnimplementedError( - 'NFC tag assignment is not supported for team hubs.', - ); - } - - @override - Future updateHub({ - required String id, + Future updateHub({ + required String hubId, String? name, - String? address, + String? fullAddress, String? placeId, double? latitude, double? longitude, @@ -101,22 +87,66 @@ class HubRepositoryImpl implements HubRepositoryInterface { String? zipCode, String? costCenterId, }) async { - final String businessId = await _service.getBusinessId(); - return _connectorRepository.updateHub( - businessId: businessId, - id: id, - name: name, - address: address, - placeId: placeId, - latitude: latitude, - longitude: longitude, - city: city, - state: state, - street: street, - country: country, - zipCode: zipCode, - costCenterId: costCenterId, + final ApiResponse response = await _apiService.put( + V2ApiEndpoints.clientHubUpdate(hubId), + data: { + 'hubId': hubId, + if (name != null) 'name': name, + if (fullAddress != null) 'fullAddress': fullAddress, + if (placeId != null) 'placeId': placeId, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + if (city != null) 'city': city, + if (state != null) 'state': state, + if (street != null) 'street': street, + if (country != null) 'country': country, + if (zipCode != null) 'zipCode': zipCode, + if (costCenterId != null) 'costCenterId': costCenterId, + }, + ); + final Map data = + response.data as Map; + return data['hubId'] as String; + } + + @override + Future deleteHub(String hubId) async { + await _apiService.delete(V2ApiEndpoints.clientHubDelete(hubId)); + } + + @override + Future assignNfcTag({ + required String hubId, + required String nfcTagId, + }) async { + await _apiService.post( + V2ApiEndpoints.clientHubAssignNfc(hubId), + data: {'nfcTagId': nfcTagId}, + ); + } + + @override + Future> getManagers(String hubId) async { + final ApiResponse response = + await _apiService.get(V2ApiEndpoints.clientHubManagers(hubId)); + final List items = + (response.data as Map)['items'] as List; + return items + .map((dynamic json) => + HubManager.fromJson(json as Map)) + .toList(); + } + + @override + Future assignManagers({ + required String hubId, + required List businessMembershipIds, + }) async { + await _apiService.post( + V2ApiEndpoints.clientHubAssignManagers(hubId), + data: { + 'businessMembershipIds': businessMembershipIds, + }, ); } } - diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/arguments/assign_nfc_tag_arguments.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/arguments/assign_nfc_tag_arguments.dart index 76f854ca..d3eddead 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/domain/arguments/assign_nfc_tag_arguments.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/arguments/assign_nfc_tag_arguments.dart @@ -1,14 +1,12 @@ import 'package:krow_core/core.dart'; -/// Represents the arguments required for the AssignNfcTagUseCase. +/// Arguments for the [AssignNfcTagUseCase]. /// /// Encapsulates the hub ID and the NFC tag ID to be assigned. class AssignNfcTagArguments extends UseCaseArgument { - /// Creates an [AssignNfcTagArguments] instance. - /// - /// Both [hubId] and [nfcTagId] are required. const AssignNfcTagArguments({required this.hubId, required this.nfcTagId}); + /// The unique identifier of the hub. final String hubId; diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/arguments/create_hub_arguments.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/arguments/create_hub_arguments.dart index 18e6a3fd..f3c60226 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/domain/arguments/create_hub_arguments.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/arguments/create_hub_arguments.dart @@ -1,16 +1,13 @@ import 'package:krow_core/core.dart'; -/// Represents the arguments required for the CreateHubUseCase. +/// Arguments for the [CreateHubUseCase]. /// /// Encapsulates the name and address of the hub to be created. class CreateHubArguments extends UseCaseArgument { - /// Creates a [CreateHubArguments] instance. - /// - /// Both [name] and [address] are required. const CreateHubArguments({ required this.name, - required this.address, + required this.fullAddress, this.placeId, this.latitude, this.longitude, @@ -21,36 +18,52 @@ class CreateHubArguments extends UseCaseArgument { this.zipCode, this.costCenterId, }); - /// The name of the hub. + + /// The display name of the hub. final String name; - /// The physical address of the hub. - final String address; + /// The full street address. + final String fullAddress; + /// Google Place ID. final String? placeId; + + /// GPS latitude. final double? latitude; + + /// GPS longitude. final double? longitude; + + /// City. final String? city; + + /// State. final String? state; + + /// Street. final String? street; + + /// Country. final String? country; + + /// Zip code. final String? zipCode; - - /// The cost center of the hub. + + /// Associated cost center ID. final String? costCenterId; @override List get props => [ - name, - address, - placeId, - latitude, - longitude, - city, - state, - street, - country, - zipCode, - costCenterId, - ]; + name, + fullAddress, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + costCenterId, + ]; } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart index 14e97bf2..e724c7a7 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart @@ -2,13 +2,10 @@ import 'package:krow_domain/krow_domain.dart'; /// Interface for the Hub repository. /// -/// This repository defines the contract for hub-related operations in the -/// domain layer. It handles fetching, creating, deleting hubs and assigning -/// NFC tags. The implementation will be provided in the data layer. +/// Defines the contract for hub-related operations. The implementation +/// uses the V2 REST API via [BaseApiService]. abstract interface class HubRepositoryInterface { /// Fetches the list of hubs for the current client. - /// - /// Returns a list of [Hub] entities. Future> getHubs(); /// Fetches the list of available cost centers for the current business. @@ -16,11 +13,10 @@ abstract interface class HubRepositoryInterface { /// Creates a new hub. /// - /// Takes the [name] and [address] of the new hub. - /// Returns the created [Hub] entity. - Future createHub({ + /// Returns the created hub ID. + Future createHub({ required String name, - required String address, + required String fullAddress, String? placeId, double? latitude, double? longitude, @@ -32,21 +28,19 @@ abstract interface class HubRepositoryInterface { String? costCenterId, }); - /// Deletes a hub by its [id]. - Future deleteHub(String id); + /// Deletes a hub by its [hubId]. + Future deleteHub(String hubId); /// Assigns an NFC tag to a hub. - /// - /// Takes the [hubId] and the [nfcTagId] to be associated. Future assignNfcTag({required String hubId, required String nfcTagId}); - /// Updates an existing hub by its [id]. + /// Updates an existing hub by its [hubId]. /// - /// All fields other than [id] are optional — only supplied values are updated. - Future updateHub({ - required String id, + /// Only supplied values are updated. + Future updateHub({ + required String hubId, String? name, - String? address, + String? fullAddress, String? placeId, double? latitude, double? longitude, @@ -57,4 +51,13 @@ abstract interface class HubRepositoryInterface { String? zipCode, String? costCenterId, }); + + /// Fetches managers assigned to a hub. + Future> getManagers(String hubId); + + /// Assigns managers to a hub. + Future assignManagers({ + required String hubId, + required List businessMembershipIds, + }); } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/assign_nfc_tag_usecase.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/assign_nfc_tag_usecase.dart index dc3fe00a..f58710af 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/assign_nfc_tag_usecase.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/assign_nfc_tag_usecase.dart @@ -1,17 +1,16 @@ import 'package:krow_core/core.dart'; -import '../arguments/assign_nfc_tag_arguments.dart'; -import '../repositories/hub_repository_interface.dart'; + +import 'package:client_hubs/src/domain/arguments/assign_nfc_tag_arguments.dart'; +import 'package:client_hubs/src/domain/repositories/hub_repository_interface.dart'; /// Use case for assigning an NFC tag to a hub. /// -/// This use case handles the association of a physical NFC tag with a specific -/// hub by calling the [HubRepositoryInterface]. +/// Handles the association of a physical NFC tag with a specific hub. class AssignNfcTagUseCase implements UseCase { - /// Creates an [AssignNfcTagUseCase]. - /// - /// Requires a [HubRepositoryInterface] to interact with the backend. AssignNfcTagUseCase(this._repository); + + /// The repository for hub operations. final HubRepositoryInterface _repository; @override diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/create_hub_usecase.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/create_hub_usecase.dart index 550acd89..d22e222c 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/create_hub_usecase.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/create_hub_usecase.dart @@ -1,26 +1,24 @@ import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; -import '../arguments/create_hub_arguments.dart'; -import '../repositories/hub_repository_interface.dart'; + +import 'package:client_hubs/src/domain/arguments/create_hub_arguments.dart'; +import 'package:client_hubs/src/domain/repositories/hub_repository_interface.dart'; /// Use case for creating a new hub. /// -/// This use case orchestrates the creation of a hub by interacting with the -/// [HubRepositoryInterface]. It requires [CreateHubArguments] which includes -/// the name and address of the hub. -class CreateHubUseCase implements UseCase { - +/// Orchestrates hub creation by delegating to [HubRepositoryInterface]. +/// Returns the created hub ID. +class CreateHubUseCase implements UseCase { /// Creates a [CreateHubUseCase]. - /// - /// Requires a [HubRepositoryInterface] to perform the actual creation. CreateHubUseCase(this._repository); + + /// The repository for hub operations. final HubRepositoryInterface _repository; @override - Future call(CreateHubArguments arguments) { + Future call(CreateHubArguments arguments) { return _repository.createHub( name: arguments.name, - address: arguments.address, + fullAddress: arguments.fullAddress, placeId: arguments.placeId, latitude: arguments.latitude, longitude: arguments.longitude, diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/delete_hub_usecase.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/delete_hub_usecase.dart index b89aa933..d26b46a1 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/delete_hub_usecase.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/delete_hub_usecase.dart @@ -1,16 +1,16 @@ import 'package:krow_core/core.dart'; -import '../arguments/delete_hub_arguments.dart'; -import '../repositories/hub_repository_interface.dart'; + +import 'package:client_hubs/src/domain/arguments/delete_hub_arguments.dart'; +import 'package:client_hubs/src/domain/repositories/hub_repository_interface.dart'; /// Use case for deleting a hub. /// -/// This use case removes a hub from the system via the [HubRepositoryInterface]. +/// Removes a hub from the system via [HubRepositoryInterface]. class DeleteHubUseCase implements UseCase { - /// Creates a [DeleteHubUseCase]. - /// - /// Requires a [HubRepositoryInterface] to perform the deletion. DeleteHubUseCase(this._repository); + + /// The repository for hub operations. final HubRepositoryInterface _repository; @override diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/get_cost_centers_usecase.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/get_cost_centers_usecase.dart index 32f9d895..66d30c48 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/get_cost_centers_usecase.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/get_cost_centers_usecase.dart @@ -1,13 +1,17 @@ import 'package:krow_domain/krow_domain.dart'; -import '../repositories/hub_repository_interface.dart'; -/// Usecase to fetch all available cost centers. +import 'package:client_hubs/src/domain/repositories/hub_repository_interface.dart'; + +/// Use case to fetch all available cost centers. class GetCostCentersUseCase { + /// Creates a [GetCostCentersUseCase]. GetCostCentersUseCase({required HubRepositoryInterface repository}) : _repository = repository; + /// The repository for hub operations. final HubRepositoryInterface _repository; + /// Executes the use case. Future> call() async { return _repository.getCostCenters(); } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/get_hubs_usecase.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/get_hubs_usecase.dart index 450a090a..b1a80132 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/get_hubs_usecase.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/get_hubs_usecase.dart @@ -1,17 +1,16 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../repositories/hub_repository_interface.dart'; + +import 'package:client_hubs/src/domain/repositories/hub_repository_interface.dart'; /// Use case for fetching the list of hubs. /// -/// This use case retrieves all hubs associated with the current client -/// by interacting with the [HubRepositoryInterface]. +/// Retrieves all hubs associated with the current client. class GetHubsUseCase implements NoInputUseCase> { - /// Creates a [GetHubsUseCase]. - /// - /// Requires a [HubRepositoryInterface] to fetch the data. GetHubsUseCase(this._repository); + + /// The repository for hub operations. final HubRepositoryInterface _repository; @override diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart index cbfdb799..3b7968fb 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart @@ -1,14 +1,14 @@ import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; -import '../repositories/hub_repository_interface.dart'; +import 'package:client_hubs/src/domain/repositories/hub_repository_interface.dart'; -/// Arguments for the UpdateHubUseCase. +/// Arguments for the [UpdateHubUseCase]. class UpdateHubArguments extends UseCaseArgument { + /// Creates an [UpdateHubArguments] instance. const UpdateHubArguments({ - required this.id, + required this.hubId, this.name, - this.address, + this.fullAddress, this.placeId, this.latitude, this.longitude, @@ -20,48 +20,75 @@ class UpdateHubArguments extends UseCaseArgument { this.costCenterId, }); - final String id; + /// The hub ID to update. + final String hubId; + + /// Updated name. final String? name; - final String? address; + + /// Updated full address. + final String? fullAddress; + + /// Updated Google Place ID. final String? placeId; + + /// Updated latitude. final double? latitude; + + /// Updated longitude. final double? longitude; + + /// Updated city. final String? city; + + /// Updated state. final String? state; + + /// Updated street. final String? street; + + /// Updated country. final String? country; + + /// Updated zip code. final String? zipCode; + + /// Updated cost center ID. final String? costCenterId; @override List get props => [ - id, - name, - address, - placeId, - latitude, - longitude, - city, - state, - street, - country, - zipCode, - costCenterId, - ]; + hubId, + name, + fullAddress, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + costCenterId, + ]; } /// Use case for updating an existing hub. -class UpdateHubUseCase implements UseCase { - UpdateHubUseCase(this.repository); +/// +/// Returns the updated hub ID. +class UpdateHubUseCase implements UseCase { + /// Creates an [UpdateHubUseCase]. + UpdateHubUseCase(this._repository); - final HubRepositoryInterface repository; + /// The repository for hub operations. + final HubRepositoryInterface _repository; @override - Future call(UpdateHubArguments params) { - return repository.updateHub( - id: params.id, + Future call(UpdateHubArguments params) { + return _repository.updateHub( + hubId: params.hubId, name: params.name, - address: params.address, + fullAddress: params.fullAddress, placeId: params.placeId, latitude: params.latitude, longitude: params.longitude, diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_bloc.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_bloc.dart index 4bd08959..fc77cd2e 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_bloc.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_bloc.dart @@ -2,20 +2,21 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/usecases/get_hubs_usecase.dart'; -import 'client_hubs_event.dart'; -import 'client_hubs_state.dart'; -/// BLoC responsible for managing the state of the Client Hubs feature. +import 'package:client_hubs/src/domain/usecases/get_hubs_usecase.dart'; +import 'package:client_hubs/src/presentation/blocs/client_hubs_event.dart'; +import 'package:client_hubs/src/presentation/blocs/client_hubs_state.dart'; + +/// BLoC responsible for managing the state of the Client Hubs list. /// -/// It orchestrates the flow between the UI and the domain layer by invoking -/// specific use cases for fetching hubs. +/// Invokes [GetHubsUseCase] to fetch hubs from the V2 API. class ClientHubsBloc extends Bloc with BlocErrorHandler implements Disposable { + /// Creates a [ClientHubsBloc]. ClientHubsBloc({required GetHubsUseCase getHubsUseCase}) - : _getHubsUseCase = getHubsUseCase, - super(const ClientHubsState()) { + : _getHubsUseCase = getHubsUseCase, + super(const ClientHubsState()) { on(_onFetched); on(_onMessageCleared); } @@ -49,8 +50,7 @@ class ClientHubsBloc extends Bloc state.copyWith( clearErrorMessage: true, clearSuccessMessage: true, - status: - state.status == ClientHubsStatus.success || + status: state.status == ClientHubsStatus.success || state.status == ClientHubsStatus.failure ? ClientHubsStatus.success : state.status, diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_bloc.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_bloc.dart index a455c0f3..ad7eb846 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_bloc.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_bloc.dart @@ -1,24 +1,27 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../../domain/arguments/create_hub_arguments.dart'; -import '../../../domain/usecases/create_hub_usecase.dart'; -import '../../../domain/usecases/update_hub_usecase.dart'; -import '../../../domain/usecases/get_cost_centers_usecase.dart'; -import 'edit_hub_event.dart'; -import 'edit_hub_state.dart'; + +import 'package:client_hubs/src/domain/arguments/create_hub_arguments.dart'; +import 'package:client_hubs/src/domain/usecases/create_hub_usecase.dart'; +import 'package:client_hubs/src/domain/usecases/get_cost_centers_usecase.dart'; +import 'package:client_hubs/src/domain/usecases/update_hub_usecase.dart'; + +import 'package:client_hubs/src/presentation/blocs/edit_hub/edit_hub_event.dart'; +import 'package:client_hubs/src/presentation/blocs/edit_hub/edit_hub_state.dart'; /// Bloc for creating and updating hubs. class EditHubBloc extends Bloc with BlocErrorHandler { + /// Creates an [EditHubBloc]. EditHubBloc({ required CreateHubUseCase createHubUseCase, required UpdateHubUseCase updateHubUseCase, required GetCostCentersUseCase getCostCentersUseCase, - }) : _createHubUseCase = createHubUseCase, - _updateHubUseCase = updateHubUseCase, - _getCostCentersUseCase = getCostCentersUseCase, - super(const EditHubState()) { + }) : _createHubUseCase = createHubUseCase, + _updateHubUseCase = updateHubUseCase, + _getCostCentersUseCase = getCostCentersUseCase, + super(const EditHubState()) { on(_onCostCentersLoadRequested); on(_onAddRequested); on(_onUpdateRequested); @@ -35,7 +38,8 @@ class EditHubBloc extends Bloc await handleError( emit: emit.call, action: () async { - final List costCenters = await _getCostCentersUseCase.call(); + final List costCenters = + await _getCostCentersUseCase.call(); emit(state.copyWith(costCenters: costCenters)); }, onError: (String errorKey) => state.copyWith( @@ -57,7 +61,7 @@ class EditHubBloc extends Bloc await _createHubUseCase.call( CreateHubArguments( name: event.name, - address: event.address, + fullAddress: event.fullAddress, placeId: event.placeId, latitude: event.latitude, longitude: event.longitude, @@ -92,9 +96,9 @@ class EditHubBloc extends Bloc action: () async { await _updateHubUseCase.call( UpdateHubArguments( - id: event.id, + hubId: event.hubId, name: event.name, - address: event.address, + fullAddress: event.fullAddress, placeId: event.placeId, latitude: event.latitude, longitude: event.longitude, diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_event.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_event.dart index 38e25de0..9f7344d3 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_event.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_event.dart @@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart'; /// Base class for all edit hub events. abstract class EditHubEvent extends Equatable { + /// Creates an [EditHubEvent]. const EditHubEvent(); @override @@ -10,14 +11,16 @@ abstract class EditHubEvent extends Equatable { /// Event triggered to load all available cost centers. class EditHubCostCentersLoadRequested extends EditHubEvent { + /// Creates an [EditHubCostCentersLoadRequested]. const EditHubCostCentersLoadRequested(); } /// Event triggered to add a new hub. class EditHubAddRequested extends EditHubEvent { + /// Creates an [EditHubAddRequested]. const EditHubAddRequested({ required this.name, - required this.address, + required this.fullAddress, this.placeId, this.latitude, this.longitude, @@ -29,40 +32,62 @@ class EditHubAddRequested extends EditHubEvent { this.costCenterId, }); + /// Hub name. final String name; - final String address; + + /// Full street address. + final String fullAddress; + + /// Google Place ID. final String? placeId; + + /// GPS latitude. final double? latitude; + + /// GPS longitude. final double? longitude; + + /// City. final String? city; + + /// State. final String? state; + + /// Street. final String? street; + + /// Country. final String? country; + + /// Zip code. final String? zipCode; + + /// Cost center ID. final String? costCenterId; @override List get props => [ - name, - address, - placeId, - latitude, - longitude, - city, - state, - street, - country, - zipCode, - costCenterId, - ]; + name, + fullAddress, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + costCenterId, + ]; } /// Event triggered to update an existing hub. class EditHubUpdateRequested extends EditHubEvent { + /// Creates an [EditHubUpdateRequested]. const EditHubUpdateRequested({ - required this.id, + required this.hubId, required this.name, - required this.address, + required this.fullAddress, this.placeId, this.latitude, this.longitude, @@ -74,32 +99,55 @@ class EditHubUpdateRequested extends EditHubEvent { this.costCenterId, }); - final String id; + /// Hub ID to update. + final String hubId; + + /// Updated name. final String name; - final String address; + + /// Updated full address. + final String fullAddress; + + /// Updated Google Place ID. final String? placeId; + + /// Updated latitude. final double? latitude; + + /// Updated longitude. final double? longitude; + + /// Updated city. final String? city; + + /// Updated state. final String? state; + + /// Updated street. final String? street; + + /// Updated country. final String? country; + + /// Updated zip code. final String? zipCode; + + /// Updated cost center ID. final String? costCenterId; @override List get props => [ - id, - name, - address, - placeId, - latitude, - longitude, - city, - state, - street, - country, - zipCode, - costCenterId, - ]; + hubId, + name, + fullAddress, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + costCenterId, + ]; } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_bloc.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_bloc.dart index 4b91b0de..79684f20 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_bloc.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_bloc.dart @@ -1,21 +1,23 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; -import '../../../domain/arguments/assign_nfc_tag_arguments.dart'; -import '../../../domain/arguments/delete_hub_arguments.dart'; -import '../../../domain/usecases/assign_nfc_tag_usecase.dart'; -import '../../../domain/usecases/delete_hub_usecase.dart'; -import 'hub_details_event.dart'; -import 'hub_details_state.dart'; + +import 'package:client_hubs/src/domain/arguments/assign_nfc_tag_arguments.dart'; +import 'package:client_hubs/src/domain/arguments/delete_hub_arguments.dart'; +import 'package:client_hubs/src/domain/usecases/assign_nfc_tag_usecase.dart'; +import 'package:client_hubs/src/domain/usecases/delete_hub_usecase.dart'; +import 'package:client_hubs/src/presentation/blocs/hub_details/hub_details_event.dart'; +import 'package:client_hubs/src/presentation/blocs/hub_details/hub_details_state.dart'; /// Bloc for managing hub details and operations like delete and NFC assignment. class HubDetailsBloc extends Bloc with BlocErrorHandler { + /// Creates a [HubDetailsBloc]. HubDetailsBloc({ required DeleteHubUseCase deleteHubUseCase, required AssignNfcTagUseCase assignNfcTagUseCase, - }) : _deleteHubUseCase = deleteHubUseCase, - _assignNfcTagUseCase = assignNfcTagUseCase, - super(const HubDetailsState()) { + }) : _deleteHubUseCase = deleteHubUseCase, + _assignNfcTagUseCase = assignNfcTagUseCase, + super(const HubDetailsState()) { on(_onDeleteRequested); on(_onNfcTagAssignRequested); } @@ -32,7 +34,7 @@ class HubDetailsBloc extends Bloc await handleError( emit: emit.call, action: () async { - await _deleteHubUseCase.call(DeleteHubArguments(hubId: event.id)); + await _deleteHubUseCase.call(DeleteHubArguments(hubId: event.hubId)); emit( state.copyWith( status: HubDetailsStatus.deleted, diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_event.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_event.dart index 5c23da0b..9877095e 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_event.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_event.dart @@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart'; /// Base class for all hub details events. abstract class HubDetailsEvent extends Equatable { + /// Creates a [HubDetailsEvent]. const HubDetailsEvent(); @override @@ -10,21 +11,28 @@ abstract class HubDetailsEvent extends Equatable { /// Event triggered to delete a hub. class HubDetailsDeleteRequested extends HubDetailsEvent { - const HubDetailsDeleteRequested(this.id); - final String id; + /// Creates a [HubDetailsDeleteRequested]. + const HubDetailsDeleteRequested(this.hubId); + + /// The ID of the hub to delete. + final String hubId; @override - List get props => [id]; + List get props => [hubId]; } /// Event triggered to assign an NFC tag to a hub. class HubDetailsNfcTagAssignRequested extends HubDetailsEvent { + /// Creates a [HubDetailsNfcTagAssignRequested]. const HubDetailsNfcTagAssignRequested({ required this.hubId, required this.nfcTagId, }); + /// The hub ID. final String hubId; + + /// The NFC tag ID. final String nfcTagId; @override diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart index 28857947..34f9e202 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart @@ -5,19 +5,18 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:core_localization/core_localization.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../blocs/client_hubs_bloc.dart'; -import '../blocs/client_hubs_event.dart'; -import '../blocs/client_hubs_state.dart'; -import '../widgets/hub_card.dart'; -import '../widgets/hub_empty_state.dart'; -import '../widgets/hub_info_card.dart'; -import '../widgets/hubs_page_skeleton.dart'; +import 'package:client_hubs/src/presentation/blocs/client_hubs_bloc.dart'; +import 'package:client_hubs/src/presentation/blocs/client_hubs_event.dart'; +import 'package:client_hubs/src/presentation/blocs/client_hubs_state.dart'; +import 'package:client_hubs/src/presentation/widgets/hub_card.dart'; +import 'package:client_hubs/src/presentation/widgets/hub_empty_state.dart'; +import 'package:client_hubs/src/presentation/widgets/hub_info_card.dart'; +import 'package:client_hubs/src/presentation/widgets/hubs_page_skeleton.dart'; /// The main page for the client hubs feature. /// -/// This page follows the KROW Clean Architecture by being a [StatelessWidget] -/// and delegating all state management to the [ClientHubsBloc]. +/// Delegates all state management to [ClientHubsBloc]. class ClientHubsPage extends StatelessWidget { /// Creates a [ClientHubsPage]. const ClientHubsPage({super.key}); @@ -99,7 +98,8 @@ class ClientHubsPage extends StatelessWidget { else if (state.hubs.isEmpty) HubEmptyState( onAddPressed: () async { - final bool? success = await Modular.to.toEditHub(); + final bool? success = + await Modular.to.toEditHub(); if (success == true && context.mounted) { BlocProvider.of( context, @@ -112,8 +112,8 @@ class ClientHubsPage extends StatelessWidget { (Hub hub) => HubCard( hub: hub, onTap: () async { - final bool? success = await Modular.to - .toHubDetails(hub); + final bool? success = + await Modular.to.toHubDetails(hub); if (success == true && context.mounted) { BlocProvider.of( context, diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart index 12993f12..83b669c6 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart @@ -5,15 +5,17 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../blocs/edit_hub/edit_hub_bloc.dart'; -import '../blocs/edit_hub/edit_hub_event.dart'; -import '../blocs/edit_hub/edit_hub_state.dart'; -import '../widgets/hub_form.dart'; +import 'package:client_hubs/src/presentation/blocs/edit_hub/edit_hub_bloc.dart'; +import 'package:client_hubs/src/presentation/blocs/edit_hub/edit_hub_event.dart'; +import 'package:client_hubs/src/presentation/blocs/edit_hub/edit_hub_state.dart'; +import 'package:client_hubs/src/presentation/widgets/hub_form.dart'; /// A wrapper page that shows the hub form in a modal-style layout. class EditHubPage extends StatelessWidget { + /// Creates an [EditHubPage]. const EditHubPage({this.hub, super.key}); + /// The hub to edit, or null for creating a new hub. final Hub? hub; @override @@ -64,40 +66,39 @@ class EditHubPage extends StatelessWidget { hub: hub, costCenters: state.costCenters, onCancel: () => Modular.to.pop(), - onSave: - ({ - required String name, - required String address, - String? costCenterId, - String? placeId, - double? latitude, - double? longitude, - }) { - if (hub == null) { - BlocProvider.of(context).add( - EditHubAddRequested( - name: name, - address: address, - costCenterId: costCenterId, - placeId: placeId, - latitude: latitude, - longitude: longitude, - ), - ); - } else { - BlocProvider.of(context).add( - EditHubUpdateRequested( - id: hub!.id, - name: name, - address: address, - costCenterId: costCenterId, - placeId: placeId, - latitude: latitude, - longitude: longitude, - ), - ); - } - }, + onSave: ({ + required String name, + required String fullAddress, + String? costCenterId, + String? placeId, + double? latitude, + double? longitude, + }) { + if (hub == null) { + BlocProvider.of(context).add( + EditHubAddRequested( + name: name, + fullAddress: fullAddress, + costCenterId: costCenterId, + placeId: placeId, + latitude: latitude, + longitude: longitude, + ), + ); + } else { + BlocProvider.of(context).add( + EditHubUpdateRequested( + hubId: hub!.hubId, + name: name, + fullAddress: fullAddress, + costCenterId: costCenterId, + placeId: placeId, + latitude: latitude, + longitude: longitude, + ), + ); + } + }, ), ), diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart index d8725551..63fa93f6 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart @@ -6,18 +6,20 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../blocs/hub_details/hub_details_bloc.dart'; -import '../blocs/hub_details/hub_details_event.dart'; -import '../blocs/hub_details/hub_details_state.dart'; -import '../widgets/hub_details/hub_details_bottom_actions.dart'; -import '../widgets/hub_details/hub_details_item.dart'; +import 'package:client_hubs/src/presentation/blocs/hub_details/hub_details_bloc.dart'; +import 'package:client_hubs/src/presentation/blocs/hub_details/hub_details_event.dart'; +import 'package:client_hubs/src/presentation/blocs/hub_details/hub_details_state.dart'; +import 'package:client_hubs/src/presentation/widgets/hub_details/hub_details_bottom_actions.dart'; +import 'package:client_hubs/src/presentation/widgets/hub_details/hub_details_item.dart'; /// A read-only details page for a single [Hub]. /// -/// Shows hub name, address, and NFC tag assignment. +/// Shows hub name, address, NFC tag, and cost center. class HubDetailsPage extends StatelessWidget { + /// Creates a [HubDetailsPage]. const HubDetailsPage({required this.hub, super.key}); + /// The hub to display. final Hub hub; @override @@ -30,7 +32,7 @@ class HubDetailsPage extends StatelessWidget { final String message = state.successKey == 'deleted' ? t.client_hubs.hub_details.deleted_success : (state.successMessage ?? - t.client_hubs.hub_details.deleted_success); + t.client_hubs.hub_details.deleted_success); UiSnackbar.show( context, message: message, @@ -50,11 +52,12 @@ class HubDetailsPage extends StatelessWidget { child: BlocBuilder( builder: (BuildContext context, HubDetailsState state) { final bool isLoading = state.status == HubDetailsStatus.loading; + final String displayAddress = hub.fullAddress ?? ''; return Scaffold( appBar: UiAppBar( title: hub.name, - subtitle: hub.address, + subtitle: displayAddress, showBackButton: true, ), bottomNavigationBar: HubDetailsBottomActions( @@ -75,25 +78,21 @@ class HubDetailsPage extends StatelessWidget { children: [ HubDetailsItem( label: t.client_hubs.hub_details.nfc_label, - value: - hub.nfcTagId ?? + value: hub.nfcTagId ?? t.client_hubs.hub_details.nfc_not_assigned, icon: UiIcons.nfc, isHighlight: hub.nfcTagId != null, ), const SizedBox(height: UiConstants.space4), HubDetailsItem( - label: - t.client_hubs.hub_details.cost_center_label, - value: hub.costCenter != null - ? '${hub.costCenter!.name} (${hub.costCenter!.code})' - : t - .client_hubs - .hub_details - .cost_center_none, - icon: UiIcons - .bank, // Using bank icon for cost center - isHighlight: hub.costCenter != null, + label: t + .client_hubs.hub_details.cost_center_label, + value: hub.costCenterName != null + ? hub.costCenterName! + : t.client_hubs.hub_details + .cost_center_none, + icon: UiIcons.bank, + isHighlight: hub.costCenterId != null, ), ], ), @@ -143,7 +142,8 @@ class HubDetailsPage extends StatelessWidget { ); if (confirm == true) { - Modular.get().add(HubDetailsDeleteRequested(hub.id)); + Modular.get() + .add(HubDetailsDeleteRequested(hub.hubId)); } } } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/edit_hub/edit_hub_form_section.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/edit_hub/edit_hub_form_section.dart index 3a6e24f6..8473a3be 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/edit_hub/edit_hub_form_section.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/edit_hub/edit_hub_form_section.dart @@ -4,11 +4,12 @@ import 'package:flutter/material.dart'; import 'package:google_places_flutter/model/prediction.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../hub_address_autocomplete.dart'; -import 'edit_hub_field_label.dart'; +import 'package:client_hubs/src/presentation/widgets/hub_address_autocomplete.dart'; +import 'package:client_hubs/src/presentation/widgets/edit_hub/edit_hub_field_label.dart'; /// The form section for adding or editing a hub. class EditHubFormSection extends StatelessWidget { + /// Creates an [EditHubFormSection]. const EditHubFormSection({ required this.formKey, required this.nameController, @@ -16,24 +17,45 @@ class EditHubFormSection extends StatelessWidget { required this.addressFocusNode, required this.onAddressSelected, required this.onSave, + required this.onCostCenterChanged, this.costCenters = const [], this.selectedCostCenterId, - required this.onCostCenterChanged, this.isSaving = false, this.isEdit = false, super.key, }); + /// Form key for validation. final GlobalKey formKey; + + /// Controller for the name field. final TextEditingController nameController; + + /// Controller for the address field. final TextEditingController addressController; + + /// Focus node for the address field. final FocusNode addressFocusNode; + + /// Callback when an address prediction is selected. final ValueChanged onAddressSelected; + + /// Callback when the save button is pressed. final VoidCallback onSave; + + /// Available cost centers. final List costCenters; + + /// Currently selected cost center ID. final String? selectedCostCenterId; + + /// Callback when the cost center selection changes. final ValueChanged onCostCenterChanged; + + /// Whether a save operation is in progress. final bool isSaving; + + /// Whether this is an edit (vs. create) operation. final bool isEdit; @override @@ -43,7 +65,7 @@ class EditHubFormSection extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // ── Name field ────────────────────────────────── + // -- Name field -- EditHubFieldLabel(t.client_hubs.edit_hub.name_label), TextFormField( controller: nameController, @@ -60,7 +82,7 @@ class EditHubFormSection extends StatelessWidget { const SizedBox(height: UiConstants.space4), - // ── Address field ──────────────────────────────── + // -- Address field -- EditHubFieldLabel(t.client_hubs.edit_hub.address_label), HubAddressAutocomplete( controller: addressController, @@ -71,6 +93,7 @@ class EditHubFormSection extends StatelessWidget { const SizedBox(height: UiConstants.space4), + // -- Cost Center -- EditHubFieldLabel(t.client_hubs.edit_hub.cost_center_label), InkWell( onTap: () => _showCostCenterSelector(context), @@ -116,7 +139,7 @@ class EditHubFormSection extends StatelessWidget { const SizedBox(height: UiConstants.space8), - // ── Save button ────────────────────────────────── + // -- Save button -- UiButton.primary( onPressed: isSaving ? null : onSave, text: isEdit @@ -157,8 +180,9 @@ class EditHubFormSection extends StatelessWidget { String _getCostCenterName(String id) { try { - final CostCenter cc = costCenters.firstWhere((CostCenter item) => item.id == id); - return cc.code != null ? '${cc.name} (${cc.code})' : cc.name; + final CostCenter cc = + costCenters.firstWhere((CostCenter item) => item.costCenterId == id); + return cc.name; } catch (_) { return id; } @@ -181,24 +205,27 @@ class EditHubFormSection extends StatelessWidget { width: double.maxFinite, child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 400), - child : costCenters.isEmpty - ? Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Text(t.client_hubs.edit_hub.cost_centers_empty), - ) + child: costCenters.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text(t.client_hubs.edit_hub.cost_centers_empty), + ) : ListView.builder( - shrinkWrap: true, - itemCount: costCenters.length, - itemBuilder: (BuildContext context, int index) { - final CostCenter cc = costCenters[index]; - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - title: Text(cc.name, style: UiTypography.body1m.textPrimary), - subtitle: cc.code != null ? Text(cc.code!, style: UiTypography.body2r.textSecondary) : null, - onTap: () => Navigator.of(context).pop(cc), - ); - }, - ), + shrinkWrap: true, + itemCount: costCenters.length, + itemBuilder: (BuildContext context, int index) { + final CostCenter cc = costCenters[index]; + return ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 24), + title: Text( + cc.name, + style: UiTypography.body1m.textPrimary, + ), + onTap: () => Navigator.of(context).pop(cc), + ); + }, + ), ), ), ); @@ -206,7 +233,7 @@ class EditHubFormSection extends StatelessWidget { ); if (selected != null) { - onCostCenterChanged(selected.id); + onCostCenterChanged(selected.costCenterId); } } } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart index 487c55b7..7bad9647 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart @@ -4,7 +4,7 @@ import 'package:google_places_flutter/google_places_flutter.dart'; import 'package:google_places_flutter/model/prediction.dart'; import 'package:krow_core/core.dart'; -import '../../util/hubs_constants.dart'; +import 'package:client_hubs/src/util/hubs_constants.dart'; class HubAddressAutocomplete extends StatelessWidget { const HubAddressAutocomplete({ @@ -26,12 +26,11 @@ class HubAddressAutocomplete extends StatelessWidget { Widget build(BuildContext context) { return GooglePlaceAutoCompleteTextField( textEditingController: controller, - boxDecoration: null, + boxDecoration: const BoxDecoration(), focusNode: focusNode, inputDecoration: decoration ?? const InputDecoration(), googleAPIKey: AppConfig.googleMapsApiKey, debounceTime: 500, - //countries: HubsConstants.supportedCountries, isLatLngRequired: true, getPlaceDetailWithLatLng: (Prediction prediction) { onSelected?.call(prediction); diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_card.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_card.dart index eb6b1aba..f16d9dd1 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_card.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_card.dart @@ -17,6 +17,7 @@ class HubCard extends StatelessWidget { @override Widget build(BuildContext context) { final bool hasNfc = hub.nfcTagId != null; + final String displayAddress = hub.fullAddress ?? ''; return GestureDetector( onTap: onTap, @@ -50,7 +51,7 @@ class HubCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(hub.name, style: UiTypography.body1b.textPrimary), - if (hub.address.isNotEmpty) + if (displayAddress.isNotEmpty) Padding( padding: const EdgeInsets.only(top: UiConstants.space1), child: Row( @@ -64,7 +65,7 @@ class HubCard extends StatelessWidget { const SizedBox(width: UiConstants.space1), Flexible( child: Text( - hub.address, + displayAddress, style: UiTypography.footnote1r.textSecondary, maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_details/hub_details_header.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_details/hub_details_header.dart index ccf670ed..e8fc6732 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_details/hub_details_header.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_details/hub_details_header.dart @@ -29,7 +29,7 @@ class HubDetailsHeader extends StatelessWidget { const SizedBox(width: UiConstants.space1), Expanded( child: Text( - hub.address, + hub.fullAddress ?? '', style: UiTypography.body2r.textSecondary, maxLines: 2, overflow: TextOverflow.ellipsis, diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form.dart index a945097f..eafeef01 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form.dart @@ -4,11 +4,12 @@ import 'package:core_localization/core_localization.dart'; import 'package:google_places_flutter/model/prediction.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'hub_address_autocomplete.dart'; -import 'edit_hub/edit_hub_field_label.dart'; +import 'package:client_hubs/src/presentation/widgets/hub_address_autocomplete.dart'; +import 'package:client_hubs/src/presentation/widgets/edit_hub/edit_hub_field_label.dart'; -/// A bottom sheet dialog for adding or editing a hub. +/// A form for adding or editing a hub. class HubForm extends StatefulWidget { + /// Creates a [HubForm]. const HubForm({ required this.onSave, required this.onCancel, @@ -17,17 +18,23 @@ class HubForm extends StatefulWidget { super.key, }); + /// The hub to edit, or null for creating a new hub. final Hub? hub; + + /// Available cost centers. final List costCenters; + + /// Callback when the form is saved. final void Function({ required String name, - required String address, + required String fullAddress, String? costCenterId, String? placeId, double? latitude, double? longitude, - }) - onSave; + }) onSave; + + /// Callback when the form is cancelled. final VoidCallback onCancel; @override @@ -45,9 +52,10 @@ class _HubFormState extends State { void initState() { super.initState(); _nameController = TextEditingController(text: widget.hub?.name); - _addressController = TextEditingController(text: widget.hub?.address); + _addressController = + TextEditingController(text: widget.hub?.fullAddress ?? ''); _addressFocusNode = FocusNode(); - _selectedCostCenterId = widget.hub?.costCenter?.id; + _selectedCostCenterId = widget.hub?.costCenterId; } @override @@ -72,7 +80,7 @@ class _HubFormState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // ── Hub Name ──────────────────────────────── + // -- Hub Name -- EditHubFieldLabel(t.client_hubs.add_hub_dialog.name_label), const SizedBox(height: UiConstants.space2), TextFormField( @@ -91,12 +99,13 @@ class _HubFormState extends State { const SizedBox(height: UiConstants.space4), - // ── Cost Center ───────────────────────────── + // -- Cost Center -- EditHubFieldLabel(t.client_hubs.add_hub_dialog.cost_center_label), const SizedBox(height: UiConstants.space2), InkWell( onTap: _showCostCenterSelector, - borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5), + borderRadius: + BorderRadius.circular(UiConstants.radiusBase * 1.5), child: Container( padding: const EdgeInsets.symmetric( horizontal: UiConstants.space4, @@ -144,7 +153,7 @@ class _HubFormState extends State { const SizedBox(height: UiConstants.space4), - // ── Address ───────────────────────────────── + // -- Address -- EditHubFieldLabel(t.client_hubs.add_hub_dialog.address_label), const SizedBox(height: UiConstants.space2), HubAddressAutocomplete( @@ -161,7 +170,7 @@ class _HubFormState extends State { const SizedBox(height: UiConstants.space8), - // ── Save Button ───────────────────────────── + // -- Save Button -- Row( children: [ Expanded( @@ -180,7 +189,7 @@ class _HubFormState extends State { widget.onSave( name: _nameController.text.trim(), - address: _addressController.text.trim(), + fullAddress: _addressController.text.trim(), costCenterId: _selectedCostCenterId, placeId: _selectedPrediction?.placeId, latitude: double.tryParse( @@ -223,11 +232,13 @@ class _HubFormState extends State { ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5), - borderSide: BorderSide(color: UiColors.primary.withValues(alpha: 0.1)), + borderSide: + BorderSide(color: UiColors.primary.withValues(alpha: 0.1)), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5), - borderSide: BorderSide(color: UiColors.primary.withValues(alpha: 0.1)), + borderSide: + BorderSide(color: UiColors.primary.withValues(alpha: 0.1)), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5), @@ -239,7 +250,9 @@ class _HubFormState extends State { String _getCostCenterName(String id) { try { - return widget.costCenters.firstWhere((CostCenter cc) => cc.id == id).name; + return widget.costCenters + .firstWhere((CostCenter cc) => cc.costCenterId == id) + .name; } catch (_) { return id; } @@ -282,12 +295,6 @@ class _HubFormState extends State { cc.name, style: UiTypography.body1m.textPrimary, ), - subtitle: cc.code != null - ? Text( - cc.code!, - style: UiTypography.body2r.textSecondary, - ) - : null, onTap: () => Navigator.of(context).pop(cc), ); }, @@ -300,7 +307,7 @@ class _HubFormState extends State { if (selected != null) { setState(() { - _selectedCostCenterId = selected.id; + _selectedCostCenterId = selected.costCenterId; }); } } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/util/hubs_constants.dart b/apps/mobile/packages/features/client/hubs/lib/src/util/hubs_constants.dart index 441cdb3b..46dc90e9 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/util/hubs_constants.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/util/hubs_constants.dart @@ -1,3 +1,5 @@ +/// Constants used by the hubs feature. class HubsConstants { + /// Supported country codes for address autocomplete. static const List supportedCountries = ['us']; } diff --git a/apps/mobile/packages/features/client/hubs/pubspec.yaml b/apps/mobile/packages/features/client/hubs/pubspec.yaml index a6f78a82..fcd45f5e 100644 --- a/apps/mobile/packages/features/client/hubs/pubspec.yaml +++ b/apps/mobile/packages/features/client/hubs/pubspec.yaml @@ -17,20 +17,15 @@ dependencies: path: ../../../core krow_domain: path: ../../../domain - krow_data_connect: - path: ../../../data_connect design_system: path: ../../../design_system core_localization: path: ../../../core_localization - + flutter_bloc: ^8.1.0 flutter_modular: ^6.3.2 equatable: ^2.0.5 - firebase_auth: ^6.1.4 - firebase_data_connect: ^0.2.2+2 google_places_flutter: ^2.1.1 - http: ^1.2.2 dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart index 8afdfcb2..7a3203c2 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart @@ -1,16 +1,16 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; -import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_domain/krow_domain.dart'; + import 'data/repositories_impl/client_create_order_repository_impl.dart'; import 'data/repositories_impl/client_order_query_repository_impl.dart'; import 'domain/repositories/client_create_order_repository_interface.dart'; import 'domain/repositories/client_order_query_repository_interface.dart'; import 'domain/usecases/create_one_time_order_usecase.dart'; import 'domain/usecases/create_permanent_order_usecase.dart'; -import 'domain/usecases/create_recurring_order_usecase.dart'; import 'domain/usecases/create_rapid_order_usecase.dart'; +import 'domain/usecases/create_recurring_order_usecase.dart'; import 'domain/usecases/get_order_details_for_reorder_usecase.dart'; import 'domain/usecases/parse_rapid_order_usecase.dart'; import 'domain/usecases/transcribe_rapid_order_usecase.dart'; @@ -24,19 +24,17 @@ import 'presentation/pages/review_order_page.dart'; /// Module for the Client Create Order feature. /// -/// This module orchestrates the dependency injection for the create order feature, -/// connecting the domain use cases with their data layer implementations and -/// presentation layer BLoCs. +/// Uses [CoreModule] for [BaseApiService] injection (V2 API). class ClientCreateOrderModule extends Module { @override - List get imports => [DataConnectModule(), CoreModule()]; + List get imports => [CoreModule()]; @override void binds(Injector i) { // Repositories i.addLazySingleton( () => ClientCreateOrderRepositoryImpl( - service: i.get(), + apiService: i.get(), rapidOrderService: i.get(), fileUploadService: i.get(), ), @@ -44,7 +42,7 @@ class ClientCreateOrderModule extends Module { i.addLazySingleton( () => ClientOrderQueryRepositoryImpl( - service: i.get(), + apiService: i.get(), ), ); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart index 2891e30a..040cf7d7 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart @@ -1,793 +1,89 @@ -import 'package:firebase_data_connect/firebase_data_connect.dart'; -import 'package:intl/intl.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 'package:krow_domain/krow_domain.dart'; + import '../../domain/repositories/client_create_order_repository_interface.dart'; -/// Implementation of [ClientCreateOrderRepositoryInterface]. +/// V2 API implementation of [ClientCreateOrderRepositoryInterface]. /// -/// This implementation coordinates data access for order creation by [DataConnectService] from the shared -/// Data Connect package. -/// -/// It follows the KROW Clean Architecture by keeping the data layer focused -/// on delegation and data mapping, without business logic. +/// Each create method sends a single POST to the typed V2 endpoint. +/// The backend handles shift and role creation internally. class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInterface { + /// Creates an instance backed by the given [apiService]. ClientCreateOrderRepositoryImpl({ - required dc.DataConnectService service, + required BaseApiService apiService, required RapidOrderService rapidOrderService, required FileUploadService fileUploadService, - }) : _service = service, - _rapidOrderService = rapidOrderService, - _fileUploadService = fileUploadService; + }) : _api = apiService, + _rapidOrderService = rapidOrderService, + _fileUploadService = fileUploadService; - final dc.DataConnectService _service; + final BaseApiService _api; final RapidOrderService _rapidOrderService; final FileUploadService _fileUploadService; @override - Future createOneTimeOrder(domain.OneTimeOrder order) async { - return _service.run(() async { - final String businessId = await _service.getBusinessId(); - final String? vendorId = order.vendorId; - if (vendorId == null || vendorId.isEmpty) { - throw Exception('Vendor is missing.'); - } - final domain.OneTimeOrderHubDetails? hub = order.hub; - if (hub == null || hub.id.isEmpty) { - throw Exception('Hub is missing.'); - } - - final DateTime orderDateOnly = DateTime( - order.date.year, - order.date.month, - order.date.day, - ); - final Timestamp orderTimestamp = _service.toTimestamp(orderDateOnly); - final OperationResult - orderResult = await _service.connector - .createOrder( - businessId: businessId, - orderType: dc.OrderType.ONE_TIME, - teamHubId: hub.id, - ) - .vendorId(vendorId) - .eventName(order.eventName) - .status(dc.OrderStatus.POSTED) - .date(orderTimestamp) - .execute(); - - final String orderId = orderResult.data.order_insert.id; - - final int workersNeeded = order.positions.fold( - 0, - (int sum, domain.OneTimeOrderPosition position) => sum + position.count, - ); - final String shiftTitle = 'Shift 1 ${_formatDate(order.date)}'; - final double shiftCost = _calculateShiftCost(order); - - final OperationResult - shiftResult = await _service.connector - .createShift(title: shiftTitle, orderId: orderId) - .date(orderTimestamp) - .location(hub.name) - .locationAddress(hub.address) - .latitude(hub.latitude) - .longitude(hub.longitude) - .placeId(hub.placeId) - .city(hub.city) - .state(hub.state) - .street(hub.street) - .country(hub.country) - .status(dc.ShiftStatus.OPEN) - .workersNeeded(workersNeeded) - .filled(0) - .durationDays(1) - .cost(shiftCost) - .execute(); - - final String shiftId = shiftResult.data.shift_insert.id; - - for (final domain.OneTimeOrderPosition position in order.positions) { - final DateTime start = _parseTime(order.date, position.startTime); - final DateTime end = _parseTime(order.date, position.endTime); - final DateTime normalizedEnd = end.isBefore(start) - ? end.add(const Duration(days: 1)) - : end; - final double hours = normalizedEnd.difference(start).inMinutes / 60.0; - final double rate = order.roleRates[position.role] ?? 0; - final double totalValue = rate * hours * position.count; - - await _service.connector - .createShiftRole( - shiftId: shiftId, - roleId: position.role, - count: position.count, - ) - .startTime(_service.toTimestamp(start)) - .endTime(_service.toTimestamp(normalizedEnd)) - .hours(hours) - .breakType(_breakDurationFromValue(position.lunchBreak)) - .isBreakPaid(_isBreakPaid(position.lunchBreak)) - .totalValue(totalValue) - .execute(); - } - - await _service.connector - .updateOrder(id: orderId, teamHubId: hub.id) - .shifts(AnyValue([shiftId])) - .execute(); - }); + Future createOneTimeOrder(Map payload) async { + await _api.post(V2ApiEndpoints.clientOrdersOneTime, data: payload); } @override - Future createRecurringOrder(domain.RecurringOrder order) async { - return _service.run(() async { - final String businessId = await _service.getBusinessId(); - final String? vendorId = order.vendorId; - if (vendorId == null || vendorId.isEmpty) { - throw Exception('Vendor is missing.'); - } - final domain.RecurringOrderHubDetails? hub = order.hub; - if (hub == null || hub.id.isEmpty) { - throw Exception('Hub is missing.'); - } - - final DateTime orderDateOnly = DateTime( - order.startDate.year, - order.startDate.month, - order.startDate.day, - ); - final Timestamp orderTimestamp = _service.toTimestamp(orderDateOnly); - final Timestamp startTimestamp = _service.toTimestamp(order.startDate); - final Timestamp endTimestamp = _service.toTimestamp(order.endDate); - - final OperationResult - orderResult = await _service.connector - .createOrder( - businessId: businessId, - orderType: dc.OrderType.RECURRING, - teamHubId: hub.id, - ) - .vendorId(vendorId) - .eventName(order.eventName) - .status(dc.OrderStatus.POSTED) - .date(orderTimestamp) - .startDate(startTimestamp) - .endDate(endTimestamp) - .recurringDays(order.recurringDays) - .execute(); - - final String orderId = orderResult.data.order_insert.id; - - // NOTE: Recurring orders are limited to 30 days of generated shifts. - // Future shifts beyond 30 days should be created by a scheduled job. - final DateTime maxEndDate = orderDateOnly.add(const Duration(days: 29)); - final DateTime effectiveEndDate = order.endDate.isAfter(maxEndDate) - ? maxEndDate - : order.endDate; - - final Set selectedDays = Set.from(order.recurringDays); - final int workersNeeded = order.positions.fold( - 0, - (int sum, domain.RecurringOrderPosition position) => - sum + position.count, - ); - final double shiftCost = _calculateRecurringShiftCost(order); - - final List shiftIds = []; - for ( - DateTime day = orderDateOnly; - !day.isAfter(effectiveEndDate); - day = day.add(const Duration(days: 1)) - ) { - final String dayLabel = _weekdayLabel(day); - if (!selectedDays.contains(dayLabel)) { - continue; - } - - final String shiftTitle = 'Shift ${_formatDate(day)}'; - final Timestamp dayTimestamp = _service.toTimestamp( - DateTime(day.year, day.month, day.day), - ); - - final OperationResult - shiftResult = await _service.connector - .createShift(title: shiftTitle, orderId: orderId) - .date(dayTimestamp) - .location(hub.name) - .locationAddress(hub.address) - .latitude(hub.latitude) - .longitude(hub.longitude) - .placeId(hub.placeId) - .city(hub.city) - .state(hub.state) - .street(hub.street) - .country(hub.country) - .status(dc.ShiftStatus.OPEN) - .workersNeeded(workersNeeded) - .filled(0) - .durationDays(1) - .cost(shiftCost) - .execute(); - - final String shiftId = shiftResult.data.shift_insert.id; - shiftIds.add(shiftId); - - for (final domain.RecurringOrderPosition position in order.positions) { - final DateTime start = _parseTime(day, position.startTime); - final DateTime end = _parseTime(day, position.endTime); - final DateTime normalizedEnd = end.isBefore(start) - ? end.add(const Duration(days: 1)) - : end; - final double hours = normalizedEnd.difference(start).inMinutes / 60.0; - final double rate = order.roleRates[position.role] ?? 0; - final double totalValue = rate * hours * position.count; - - await _service.connector - .createShiftRole( - shiftId: shiftId, - roleId: position.role, - count: position.count, - ) - .startTime(_service.toTimestamp(start)) - .endTime(_service.toTimestamp(normalizedEnd)) - .hours(hours) - .breakType(_breakDurationFromValue(position.lunchBreak)) - .isBreakPaid(_isBreakPaid(position.lunchBreak)) - .totalValue(totalValue) - .execute(); - } - } - - await _service.connector - .updateOrder(id: orderId, teamHubId: hub.id) - .shifts(AnyValue(shiftIds)) - .execute(); - }); + Future createRecurringOrder(Map payload) async { + await _api.post(V2ApiEndpoints.clientOrdersRecurring, data: payload); } @override - Future createPermanentOrder(domain.PermanentOrder order) async { - return _service.run(() async { - final String businessId = await _service.getBusinessId(); - final String? vendorId = order.vendorId; - if (vendorId == null || vendorId.isEmpty) { - throw Exception('Vendor is missing.'); - } - final domain.OneTimeOrderHubDetails? hub = order.hub; - if (hub == null || hub.id.isEmpty) { - throw Exception('Hub is missing.'); - } - - final DateTime orderDateOnly = DateTime( - order.startDate.year, - order.startDate.month, - order.startDate.day, - ); - final Timestamp orderTimestamp = _service.toTimestamp(orderDateOnly); - final Timestamp startTimestamp = _service.toTimestamp(order.startDate); - - final OperationResult - orderResult = await _service.connector - .createOrder( - businessId: businessId, - orderType: dc.OrderType.PERMANENT, - teamHubId: hub.id, - ) - .vendorId(vendorId) - .eventName(order.eventName) - .status(dc.OrderStatus.POSTED) - .date(orderTimestamp) - .startDate(startTimestamp) - .permanentDays(order.permanentDays) - .execute(); - - final String orderId = orderResult.data.order_insert.id; - - // NOTE: Permanent orders are limited to 30 days of generated shifts. - // Future shifts beyond 30 days should be created by a scheduled job. - final DateTime maxEndDate = orderDateOnly.add(const Duration(days: 29)); - - final Set selectedDays = Set.from(order.permanentDays); - final int workersNeeded = order.positions.fold( - 0, - (int sum, domain.OneTimeOrderPosition position) => sum + position.count, - ); - final double shiftCost = _calculatePermanentShiftCost(order); - - final List shiftIds = []; - for ( - DateTime day = orderDateOnly; - !day.isAfter(maxEndDate); - day = day.add(const Duration(days: 1)) - ) { - final String dayLabel = _weekdayLabel(day); - if (!selectedDays.contains(dayLabel)) { - continue; - } - - final String shiftTitle = 'Shift ${_formatDate(day)}'; - final Timestamp dayTimestamp = _service.toTimestamp( - DateTime(day.year, day.month, day.day), - ); - - final OperationResult - shiftResult = await _service.connector - .createShift(title: shiftTitle, orderId: orderId) - .date(dayTimestamp) - .location(hub.name) - .locationAddress(hub.address) - .latitude(hub.latitude) - .longitude(hub.longitude) - .placeId(hub.placeId) - .city(hub.city) - .state(hub.state) - .street(hub.street) - .country(hub.country) - .status(dc.ShiftStatus.OPEN) - .workersNeeded(workersNeeded) - .filled(0) - .durationDays(1) - .cost(shiftCost) - .execute(); - - final String shiftId = shiftResult.data.shift_insert.id; - shiftIds.add(shiftId); - - for (final domain.OneTimeOrderPosition position in order.positions) { - final DateTime start = _parseTime(day, position.startTime); - final DateTime end = _parseTime(day, position.endTime); - final DateTime normalizedEnd = end.isBefore(start) - ? end.add(const Duration(days: 1)) - : end; - final double hours = normalizedEnd.difference(start).inMinutes / 60.0; - final double rate = order.roleRates[position.role] ?? 0; - final double totalValue = rate * hours * position.count; - - await _service.connector - .createShiftRole( - shiftId: shiftId, - roleId: position.role, - count: position.count, - ) - .startTime(_service.toTimestamp(start)) - .endTime(_service.toTimestamp(normalizedEnd)) - .hours(hours) - .breakType(_breakDurationFromValue(position.lunchBreak)) - .isBreakPaid(_isBreakPaid(position.lunchBreak)) - .totalValue(totalValue) - .execute(); - } - } - - await _service.connector - .updateOrder(id: orderId, teamHubId: hub.id) - .shifts(AnyValue(shiftIds)) - .execute(); - }); + Future createPermanentOrder(Map payload) async { + await _api.post(V2ApiEndpoints.clientOrdersPermanent, data: payload); } @override Future createRapidOrder(String description) async { - // TO-DO: connect IA and return array with the information. - throw UnimplementedError('Rapid order IA is not connected yet.'); - } - - @override - Future parseRapidOrder(String text) async { - final RapidOrderParseResponse response = await _rapidOrderService.parseText( - text: text, - ); - final RapidOrderParsedData data = response.parsed; - - // Fetch Business ID - final String businessId = await _service.getBusinessId(); - - // 1. Hub Matching - final OperationResult< - dc.ListTeamHubsByOwnerIdData, - dc.ListTeamHubsByOwnerIdVariables - > - hubResult = await _service.connector - .listTeamHubsByOwnerId(ownerId: businessId) - .execute(); - final List hubs = hubResult.data.teamHubs; - - final dc.ListTeamHubsByOwnerIdTeamHubs? bestHub = _findBestHub( - hubs, - data.locationHint, - ); - - // 2. Roles Matching - // We fetch vendors to get the first one as a context for role matching. - final OperationResult vendorResult = - await _service.connector.listVendors().execute(); - final List vendors = vendorResult.data.vendors; - - String? selectedVendorId; - List availableRoles = - []; - - if (vendors.isNotEmpty) { - selectedVendorId = vendors.first.id; - final OperationResult< - dc.ListRolesByVendorIdData, - dc.ListRolesByVendorIdVariables - > - roleResult = await _service.connector - .listRolesByVendorId(vendorId: selectedVendorId) - .execute(); - availableRoles = roleResult.data.roles; - } - - final DateTime startAt = - DateTime.tryParse(data.startAt ?? '') ?? DateTime.now(); - final DateTime endAt = - DateTime.tryParse(data.endAt ?? '') ?? - startAt.add(const Duration(hours: 8)); - - final String startTimeStr = DateFormat('hh:mm a').format(startAt.toLocal()); - final String endTimeStr = DateFormat('hh:mm a').format(endAt.toLocal()); - - return domain.OneTimeOrder( - date: startAt, - location: bestHub?.hubName ?? data.locationHint ?? '', - eventName: data.notes ?? '', - vendorId: selectedVendorId, - hub: bestHub != null - ? domain.OneTimeOrderHubDetails( - id: bestHub.id, - name: bestHub.hubName, - address: bestHub.address, - placeId: bestHub.placeId, - latitude: bestHub.latitude ?? 0, - longitude: bestHub.longitude ?? 0, - city: bestHub.city, - state: bestHub.state, - street: bestHub.street, - country: bestHub.country, - zipCode: bestHub.zipCode, - ) - : null, - positions: data.positions.map((RapidOrderPosition p) { - final dc.ListRolesByVendorIdRoles? matchedRole = _findBestRole( - availableRoles, - p.role, - ); - return domain.OneTimeOrderPosition( - role: matchedRole?.id ?? p.role, - count: p.count, - startTime: startTimeStr, - endTime: endTimeStr, - ); - }).toList(), - ); + throw UnimplementedError('Rapid order creation is not connected yet.'); } @override Future transcribeRapidOrder(String audioPath) async { - // 1. Upload the audio file first final String fileName = audioPath.split('/').last; - final FileUploadResponse uploadResponse = await _fileUploadService - .uploadFile( - filePath: audioPath, - fileName: fileName, - category: 'rapid-order-audio', - ); + final FileUploadResponse uploadResponse = + await _fileUploadService.uploadFile( + filePath: audioPath, + fileName: fileName, + category: 'rapid-order-audio', + ); - // 2. Transcribe using the remote URI - final RapidOrderTranscriptionResponse response = await _rapidOrderService - .transcribeAudio(audioFileUri: uploadResponse.fileUri); + final RapidOrderTranscriptionResponse response = + await _rapidOrderService.transcribeAudio( + audioFileUri: uploadResponse.fileUri, + ); return response.transcript; } @override - Future reorder(String previousOrderId, DateTime newDate) async { - // TODO: Implement reorder functionality to fetch the previous order and create a new one with the updated date. - throw UnimplementedError('Reorder functionality is not yet implemented.'); + Future> parseRapidOrder(String text) async { + final RapidOrderParseResponse response = + await _rapidOrderService.parseText(text: text); + final RapidOrderParsedData data = response.parsed; + + return { + 'eventName': data.notes ?? '', + 'locationHint': data.locationHint ?? '', + 'startAt': data.startAt, + 'endAt': data.endAt, + 'positions': data.positions + .map((RapidOrderPosition p) => { + 'roleName': p.role, + 'workerCount': p.count, + }) + .toList(), + }; } @override - Future getOrderDetailsForReorder(String orderId) async { - return _service.run(() async { - final String businessId = await _service.getBusinessId(); - final QueryResult< - dc.ListShiftRolesByBusinessAndOrderData, - dc.ListShiftRolesByBusinessAndOrderVariables - > - result = await _service.connector - .listShiftRolesByBusinessAndOrder( - businessId: businessId, - orderId: orderId, - ) - .execute(); - - final List shiftRoles = - result.data.shiftRoles; - - if (shiftRoles.isEmpty) { - throw Exception('Order not found or has no roles.'); - } - - final dc.ListShiftRolesByBusinessAndOrderShiftRolesShiftOrder order = - shiftRoles.first.shift.order; - - final domain.OrderType orderType = _mapOrderType(order.orderType); - - final dc.ListShiftRolesByBusinessAndOrderShiftRolesShiftOrderTeamHub - teamHub = order.teamHub; - - return domain.ReorderData( - orderId: orderId, - eventName: order.eventName ?? '', - vendorId: order.vendorId ?? '', - orderType: orderType, - hub: domain.OneTimeOrderHubDetails( - id: teamHub.id, - name: teamHub.hubName, - address: teamHub.address, - placeId: teamHub.placeId, - latitude: 0, // Not available in this query - longitude: 0, - ), - positions: shiftRoles.map(( - dc.ListShiftRolesByBusinessAndOrderShiftRoles role, - ) { - return domain.ReorderPosition( - roleId: role.roleId, - count: role.count, - startTime: _formatTimestamp(role.startTime), - endTime: _formatTimestamp(role.endTime), - lunchBreak: _formatBreakDuration(role.breakType), - ); - }).toList(), - startDate: order.startDate?.toDateTime(), - endDate: order.endDate?.toDateTime(), - recurringDays: order.recurringDays ?? const [], - permanentDays: order.permanentDays ?? const [], - ); - }); - } - - double _calculateShiftCost(domain.OneTimeOrder order) { - double total = 0; - for (final domain.OneTimeOrderPosition position in order.positions) { - final DateTime start = _parseTime(order.date, position.startTime); - final DateTime end = _parseTime(order.date, position.endTime); - final DateTime normalizedEnd = end.isBefore(start) - ? end.add(const Duration(days: 1)) - : end; - final double hours = normalizedEnd.difference(start).inMinutes / 60.0; - final double rate = order.roleRates[position.role] ?? 0; - total += rate * hours * position.count; - } - return total; - } - - double _calculateRecurringShiftCost(domain.RecurringOrder order) { - double total = 0; - for (final domain.RecurringOrderPosition position in order.positions) { - final DateTime start = _parseTime(order.startDate, position.startTime); - final DateTime end = _parseTime(order.startDate, position.endTime); - final DateTime normalizedEnd = end.isBefore(start) - ? end.add(const Duration(days: 1)) - : end; - final double hours = normalizedEnd.difference(start).inMinutes / 60.0; - final double rate = order.roleRates[position.role] ?? 0; - total += rate * hours * position.count; - } - return total; - } - - double _calculatePermanentShiftCost(domain.PermanentOrder order) { - double total = 0; - for (final domain.OneTimeOrderPosition position in order.positions) { - final DateTime start = _parseTime(order.startDate, position.startTime); - final DateTime end = _parseTime(order.startDate, position.endTime); - final DateTime normalizedEnd = end.isBefore(start) - ? end.add(const Duration(days: 1)) - : end; - final double hours = normalizedEnd.difference(start).inMinutes / 60.0; - final double rate = order.roleRates[position.role] ?? 0; - total += rate * hours * position.count; - } - return total; - } - - String _weekdayLabel(DateTime date) { - switch (date.weekday) { - case DateTime.monday: - return 'MON'; - case DateTime.tuesday: - return 'TUE'; - case DateTime.wednesday: - return 'WED'; - case DateTime.thursday: - return 'THU'; - case DateTime.friday: - return 'FRI'; - case DateTime.saturday: - return 'SAT'; - case DateTime.sunday: - default: - return 'SUN'; - } - } - - dc.BreakDuration _breakDurationFromValue(String value) { - switch (value) { - case 'MIN_10': - return dc.BreakDuration.MIN_10; - case 'MIN_15': - return dc.BreakDuration.MIN_15; - case 'MIN_30': - return dc.BreakDuration.MIN_30; - case 'MIN_45': - return dc.BreakDuration.MIN_45; - case 'MIN_60': - return dc.BreakDuration.MIN_60; - default: - return dc.BreakDuration.NO_BREAK; - } - } - - bool _isBreakPaid(String value) { - return value == 'MIN_10' || value == 'MIN_15'; - } - - DateTime _parseTime(DateTime date, String time) { - if (time.trim().isEmpty) { - throw Exception('Shift time is missing.'); - } - - DateTime parsed; - try { - parsed = DateFormat.jm().parse(time); - } catch (_) { - parsed = DateFormat.Hm().parse(time); - } - - return DateTime( - date.year, - date.month, - date.day, - parsed.hour, - parsed.minute, + Future getOrderDetailsForReorder(String orderId) async { + final ApiResponse response = await _api.get( + V2ApiEndpoints.clientOrderReorderPreview(orderId), ); - } - - String _formatDate(DateTime dateTime) { - final String year = dateTime.year.toString().padLeft(4, '0'); - final String month = dateTime.month.toString().padLeft(2, '0'); - final String day = dateTime.day.toString().padLeft(2, '0'); - return '$year-$month-$day'; - } - - String _formatTimestamp(Timestamp? value) { - if (value == null) return ''; - try { - return DateFormat('HH:mm').format(value.toDateTime()); - } catch (_) { - return ''; - } - } - - String _formatBreakDuration(dc.EnumValue? breakType) { - if (breakType is dc.Known) { - switch (breakType.value) { - case dc.BreakDuration.MIN_10: - return 'MIN_10'; - case dc.BreakDuration.MIN_15: - return 'MIN_15'; - case dc.BreakDuration.MIN_30: - return 'MIN_30'; - case dc.BreakDuration.MIN_45: - return 'MIN_45'; - case dc.BreakDuration.MIN_60: - return 'MIN_60'; - case dc.BreakDuration.NO_BREAK: - return 'NO_BREAK'; - } - } - return 'NO_BREAK'; - } - - domain.OrderType _mapOrderType(dc.EnumValue? orderType) { - if (orderType is dc.Known) { - switch (orderType.value) { - case dc.OrderType.ONE_TIME: - return domain.OrderType.oneTime; - case dc.OrderType.RECURRING: - return domain.OrderType.recurring; - case dc.OrderType.PERMANENT: - return domain.OrderType.permanent; - case dc.OrderType.RAPID: - return domain.OrderType.oneTime; - } - } - return domain.OrderType.oneTime; - } - - dc.ListTeamHubsByOwnerIdTeamHubs? _findBestHub( - List hubs, - String? hint, - ) { - if (hint == null || hint.isEmpty || hubs.isEmpty) return null; - final String normalizedHint = hint.toLowerCase(); - - dc.ListTeamHubsByOwnerIdTeamHubs? bestMatch; - double highestScore = -1; - - for (final dc.ListTeamHubsByOwnerIdTeamHubs hub in hubs) { - final String name = hub.hubName.toLowerCase(); - final String address = hub.address.toLowerCase(); - - double score = 0; - if (name == normalizedHint || address == normalizedHint) { - score = 100; - } else if (name.contains(normalizedHint) || - address.contains(normalizedHint)) { - score = 80; - } else if (normalizedHint.contains(name) || - normalizedHint.contains(address)) { - score = 60; - } else { - final List hintWords = normalizedHint.split(RegExp(r'\s+')); - final List hubWords = ('$name $address').split(RegExp(r'\s+')); - int overlap = 0; - for (final String word in hintWords) { - if (word.length > 2 && hubWords.contains(word)) overlap++; - } - score = overlap * 10.0; - } - - if (score > highestScore) { - highestScore = score; - bestMatch = hub; - } - } - - return (highestScore >= 10) ? bestMatch : null; - } - - dc.ListRolesByVendorIdRoles? _findBestRole( - List roles, - String? hint, - ) { - if (hint == null || hint.isEmpty || roles.isEmpty) return null; - final String normalizedHint = hint.toLowerCase(); - - dc.ListRolesByVendorIdRoles? bestMatch; - double highestScore = -1; - - for (final dc.ListRolesByVendorIdRoles role in roles) { - final String name = role.name.toLowerCase(); - - double score = 0; - if (name == normalizedHint) { - score = 100; - } else if (name.contains(normalizedHint)) { - score = 80; - } else if (normalizedHint.contains(name)) { - score = 60; - } else { - final List hintWords = normalizedHint.split(RegExp(r'\s+')); - final List roleWords = name.split(RegExp(r'\s+')); - int overlap = 0; - for (final String word in hintWords) { - if (word.length > 2 && roleWords.contains(word)) overlap++; - } - score = overlap * 10.0; - } - - if (score > highestScore) { - highestScore = score; - bestMatch = role; - } - } - - return (highestScore >= 10) ? bestMatch : null; + return OrderPreview.fromJson(response.data as Map); } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_order_query_repository_impl.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_order_query_repository_impl.dart index 723b536e..311e3a62 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_order_query_repository_impl.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_order_query_repository_impl.dart @@ -1,4 +1,4 @@ -import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import '../../domain/models/order_hub.dart'; @@ -6,102 +6,83 @@ import '../../domain/models/order_manager.dart'; import '../../domain/models/order_role.dart'; import '../../domain/repositories/client_order_query_repository_interface.dart'; -/// Data layer implementation of [ClientOrderQueryRepositoryInterface]. +/// V2 API implementation of [ClientOrderQueryRepositoryInterface]. /// -/// Delegates all backend calls to [dc.DataConnectService] using the -/// `_service.run()` pattern for automatic auth validation, token refresh, -/// and retry logic. Each method maps Data Connect response types to the -/// corresponding clean domain models. +/// Delegates all backend calls to [BaseApiService] with [V2ApiEndpoints]. class ClientOrderQueryRepositoryImpl implements ClientOrderQueryRepositoryInterface { - /// Creates an instance backed by the given [service]. - ClientOrderQueryRepositoryImpl({required dc.DataConnectService service}) - : _service = service; + /// Creates an instance backed by the given [apiService]. + ClientOrderQueryRepositoryImpl({required BaseApiService apiService}) + : _api = apiService; - final dc.DataConnectService _service; + final BaseApiService _api; @override Future> getVendors() async { - return _service.run(() async { - final result = await _service.connector.listVendors().execute(); - return result.data.vendors - .map( - (dc.ListVendorsVendors vendor) => Vendor( - id: vendor.id, - name: vendor.companyName, - rates: const {}, - ), - ) - .toList(); - }); + final ApiResponse response = await _api.get(V2ApiEndpoints.clientVendors); + final Map data = response.data as Map; + final List items = data['items'] as List; + return items + .map((dynamic json) => Vendor.fromJson(json as Map)) + .toList(); } @override Future> getRolesByVendor(String vendorId) async { - return _service.run(() async { - final result = await _service.connector - .listRolesByVendorId(vendorId: vendorId) - .execute(); - return result.data.roles - .map( - (dc.ListRolesByVendorIdRoles role) => OrderRole( - id: role.id, - name: role.name, - costPerHour: role.costPerHour, - ), - ) - .toList(); - }); + final ApiResponse response = + await _api.get(V2ApiEndpoints.clientVendorRoles(vendorId)); + final Map data = response.data as Map; + final List items = data['items'] as List; + return items.map((dynamic json) { + final Map role = json as Map; + return OrderRole( + id: role['roleId'] as String? ?? role['id'] as String? ?? '', + name: role['roleName'] as String? ?? role['name'] as String? ?? '', + costPerHour: + ((role['billRateCents'] as num?)?.toDouble() ?? 0) / 100.0, + ); + }).toList(); } @override - Future> getHubsByOwner(String ownerId) async { - return _service.run(() async { - final result = await _service.connector - .listTeamHubsByOwnerId(ownerId: ownerId) - .execute(); - return result.data.teamHubs - .map( - (dc.ListTeamHubsByOwnerIdTeamHubs hub) => OrderHub( - id: hub.id, - name: hub.hubName, - address: hub.address, - placeId: hub.placeId, - latitude: hub.latitude, - longitude: hub.longitude, - city: hub.city, - state: hub.state, - street: hub.street, - country: hub.country, - zipCode: hub.zipCode, - ), - ) - .toList(); - }); + Future> getHubs() async { + final ApiResponse response = await _api.get(V2ApiEndpoints.clientHubs); + final Map data = response.data as Map; + final List items = data['items'] as List; + return items.map((dynamic json) { + final Map hub = json as Map; + return OrderHub( + id: hub['hubId'] as String? ?? hub['id'] as String? ?? '', + name: hub['hubName'] as String? ?? hub['name'] as String? ?? '', + address: + hub['fullAddress'] as String? ?? hub['address'] as String? ?? '', + placeId: hub['placeId'] as String?, + latitude: (hub['latitude'] as num?)?.toDouble(), + longitude: (hub['longitude'] as num?)?.toDouble(), + city: hub['city'] as String?, + state: hub['state'] as String?, + street: hub['street'] as String?, + country: hub['country'] as String?, + zipCode: hub['zipCode'] as String?, + ); + }).toList(); } @override Future> getManagersByHub(String hubId) async { - return _service.run(() async { - final result = await _service.connector.listTeamMembers().execute(); - return result.data.teamMembers - .where( - (dc.ListTeamMembersTeamMembers member) => - member.teamHubId == hubId && - member.role is dc.Known && - (member.role as dc.Known).value == - dc.TeamMemberRole.MANAGER, - ) - .map( - (dc.ListTeamMembersTeamMembers member) => OrderManager( - id: member.id, - name: member.user.fullName ?? 'Unknown', - ), - ) - .toList(); - }); + final ApiResponse response = + await _api.get(V2ApiEndpoints.clientHubManagers(hubId)); + final Map data = response.data as Map; + final List items = data['items'] as List; + return items.map((dynamic json) { + final Map mgr = json as Map; + return OrderManager( + id: mgr['managerAssignmentId'] as String? ?? + mgr['businessMembershipId'] as String? ?? + mgr['id'] as String? ?? + '', + name: mgr['name'] as String? ?? '', + ); + }).toList(); } - - @override - Future getBusinessId() => _service.getBusinessId(); } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart index e2f03f83..0093a45e 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart @@ -1,19 +1,15 @@ import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; -/// Represents the arguments required for the [CreateOneTimeOrderUseCase]. +/// Arguments for the [CreateOneTimeOrderUseCase]. /// -/// Encapsulates the [OneTimeOrder] details required to create a new -/// one-time staffing request. +/// Wraps the V2 API payload map for a one-time order. class OneTimeOrderArguments extends UseCaseArgument { - /// Creates a [OneTimeOrderArguments] instance. - /// - /// Requires the [order] details. - const OneTimeOrderArguments({required this.order}); + /// Creates a [OneTimeOrderArguments] with the given [payload]. + const OneTimeOrderArguments({required this.payload}); - /// The order details to be created. - final OneTimeOrder order; + /// The V2 API payload map. + final Map payload; @override - List get props => [order]; + List get props => [payload]; } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart index 0c0d5736..e552278f 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart @@ -1,6 +1,10 @@ -import 'package:krow_domain/krow_domain.dart'; - +/// Arguments for the [CreatePermanentOrderUseCase]. +/// +/// Wraps the V2 API payload map for a permanent order. class PermanentOrderArguments { - const PermanentOrderArguments({required this.order}); - final PermanentOrder order; + /// Creates a [PermanentOrderArguments] with the given [payload]. + const PermanentOrderArguments({required this.payload}); + + /// The V2 API payload map. + final Map payload; } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart index 8c0c3d99..25e8df02 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart @@ -1,6 +1,10 @@ -import 'package:krow_domain/krow_domain.dart'; - +/// Arguments for the [CreateRecurringOrderUseCase]. +/// +/// Wraps the V2 API payload map for a recurring order. class RecurringOrderArguments { - const RecurringOrderArguments({required this.order}); - final RecurringOrder order; + /// Creates a [RecurringOrderArguments] with the given [payload]. + const RecurringOrderArguments({required this.payload}); + + /// The V2 API payload map. + final Map payload; } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/repositories/client_create_order_repository_interface.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/repositories/client_create_order_repository_interface.dart index 84124804..36f8145b 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/repositories/client_create_order_repository_interface.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/repositories/client_create_order_repository_interface.dart @@ -2,46 +2,33 @@ import 'package:krow_domain/krow_domain.dart'; /// Interface for the Client Create Order repository. /// -/// This repository is responsible for: -/// 1. Submitting different types of staffing orders (Rapid, One-Time, Recurring, Permanent). -/// -/// It follows the KROW Clean Architecture by defining the contract in the -/// domain layer, to be implemented in the data layer. +/// V2 API uses typed endpoints per order type. Each method receives +/// a [Map] payload matching the V2 schema. abstract interface class ClientCreateOrderRepositoryInterface { - /// Submits a one-time staffing order with specific details. + /// Submits a one-time staffing order. /// - /// [order] contains the date, location, and required positions. - Future createOneTimeOrder(OneTimeOrder order); + /// [payload] follows the V2 `clientOneTimeOrderSchema` shape. + Future createOneTimeOrder(Map payload); - /// Submits a recurring staffing order with specific details. - Future createRecurringOrder(RecurringOrder order); + /// Submits a recurring staffing order. + /// + /// [payload] follows the V2 `clientRecurringOrderSchema` shape. + Future createRecurringOrder(Map payload); - /// Submits a permanent staffing order with specific details. - Future createPermanentOrder(PermanentOrder order); + /// Submits a permanent staffing order. + /// + /// [payload] follows the V2 `clientPermanentOrderSchema` shape. + Future createPermanentOrder(Map payload); /// Submits a rapid (urgent) staffing order via a text description. - /// - /// [description] is the text message (or transcribed voice) describing the need. Future createRapidOrder(String description); /// Transcribes the audio file for a rapid order. - /// - /// [audioPath] is the local path to the recorded audio file. Future transcribeRapidOrder(String audioPath); - /// Parses the text description for a rapid order into a structured draft. - /// - /// [text] is the text message describing the need. - Future parseRapidOrder(String text); + /// Parses the text description into a structured draft payload. + Future> parseRapidOrder(String text); - /// Reorders an existing staffing order with a new date. - /// - /// [previousOrderId] is the ID of the order to reorder. - /// [newDate] is the new date for the order. - Future reorder(String previousOrderId, DateTime newDate); - - /// Fetches the details of an existing order to be used as a template for a new order. - /// - /// returns [ReorderData] containing the order details and positions. - Future getOrderDetailsForReorder(String orderId); + /// Fetches the reorder preview for an existing order. + Future getOrderDetailsForReorder(String orderId); } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/repositories/client_order_query_repository_interface.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/repositories/client_order_query_repository_interface.dart index 1ab9a2c7..cdf5c23e 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/repositories/client_order_query_repository_interface.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/repositories/client_order_query_repository_interface.dart @@ -6,34 +6,19 @@ import '../models/order_role.dart'; /// Interface for querying order-related reference data. /// -/// This repository centralises the read-only queries that the order creation -/// BLoCs need (vendors, roles, hubs, managers) so that they no longer depend -/// directly on [DataConnectService] or the `krow_data_connect` package. -/// -/// Implementations live in the data layer and translate backend responses -/// into clean domain models. +/// Implementations use V2 API endpoints for vendors, roles, hubs, and +/// managers. The V2 API resolves the business context from the auth token, +/// so no explicit business ID parameter is needed. abstract interface class ClientOrderQueryRepositoryInterface { /// Returns the list of available vendors. - /// - /// The returned [Vendor] objects come from the shared `krow_domain` package - /// because `Vendor` is already a clean domain entity. Future> getVendors(); /// Returns the roles offered by the vendor identified by [vendorId]. Future> getRolesByVendor(String vendorId); - /// Returns the team hubs owned by the business identified by [ownerId]. - Future> getHubsByOwner(String ownerId); + /// Returns the hubs for the current business. + Future> getHubs(); /// Returns the managers assigned to the hub identified by [hubId]. - /// - /// Only team members with the MANAGER role at the given hub are included. Future> getManagersByHub(String hubId); - - /// Returns the current business ID from the active client session. - /// - /// This allows BLoCs to resolve the business ID without depending on - /// the data layer's session store directly, keeping the presentation - /// layer free from `krow_data_connect` imports. - Future getBusinessId(); } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart index 4f320a65..948f0c2c 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart @@ -1,22 +1,20 @@ import 'package:krow_core/core.dart'; + import '../arguments/one_time_order_arguments.dart'; import '../repositories/client_create_order_repository_interface.dart'; /// Use case for creating a one-time staffing order. /// -/// This use case encapsulates the logic for submitting a structured -/// staffing request and delegates the data operation to the -/// [ClientCreateOrderRepositoryInterface]. +/// Delegates the V2 API payload to the repository. class CreateOneTimeOrderUseCase implements UseCase { /// Creates a [CreateOneTimeOrderUseCase]. - /// - /// Requires a [ClientCreateOrderRepositoryInterface] to interact with the data layer. const CreateOneTimeOrderUseCase(this._repository); + final ClientCreateOrderRepositoryInterface _repository; @override Future call(OneTimeOrderArguments input) { - return _repository.createOneTimeOrder(input.order); + return _repository.createOneTimeOrder(input.payload); } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart index b79b3359..0734f1ba 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart @@ -1,15 +1,17 @@ -import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; +import '../arguments/permanent_order_arguments.dart'; import '../repositories/client_create_order_repository_interface.dart'; /// Use case for creating a permanent staffing order. -class CreatePermanentOrderUseCase implements UseCase { +/// +/// Delegates the V2 API payload to the repository. +class CreatePermanentOrderUseCase { + /// Creates a [CreatePermanentOrderUseCase]. const CreatePermanentOrderUseCase(this._repository); final ClientCreateOrderRepositoryInterface _repository; - @override - Future call(PermanentOrder params) { - return _repository.createPermanentOrder(params); + /// Executes the use case with the given [args]. + Future call(PermanentOrderArguments args) { + return _repository.createPermanentOrder(args.payload); } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart index 561a5ef8..69462073 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart @@ -1,15 +1,17 @@ -import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; +import '../arguments/recurring_order_arguments.dart'; import '../repositories/client_create_order_repository_interface.dart'; /// Use case for creating a recurring staffing order. -class CreateRecurringOrderUseCase implements UseCase { +/// +/// Delegates the V2 API payload to the repository. +class CreateRecurringOrderUseCase { + /// Creates a [CreateRecurringOrderUseCase]. const CreateRecurringOrderUseCase(this._repository); final ClientCreateOrderRepositoryInterface _repository; - @override - Future call(RecurringOrder params) { - return _repository.createRecurringOrder(params); + /// Executes the use case with the given [args]. + Future call(RecurringOrderArguments args) { + return _repository.createRecurringOrder(args.payload); } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_order_details_for_reorder_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_order_details_for_reorder_usecase.dart index 9490ccb5..e9574ce4 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_order_details_for_reorder_usecase.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_order_details_for_reorder_usecase.dart @@ -1,14 +1,20 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; + import '../repositories/client_create_order_repository_interface.dart'; /// Use case for fetching order details for reordering. -class GetOrderDetailsForReorderUseCase implements UseCase { +/// +/// Returns an [OrderPreview] from the V2 reorder-preview endpoint. +class GetOrderDetailsForReorderUseCase + implements UseCase { + /// Creates a [GetOrderDetailsForReorderUseCase]. const GetOrderDetailsForReorderUseCase(this._repository); + final ClientCreateOrderRepositoryInterface _repository; @override - Future call(String orderId) { + Future call(String orderId) { return _repository.getOrderDetailsForReorder(orderId); } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/parse_rapid_order_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/parse_rapid_order_usecase.dart index 17113b2a..2475fa28 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/parse_rapid_order_usecase.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/parse_rapid_order_usecase.dart @@ -1,15 +1,18 @@ -import 'package:krow_domain/krow_domain.dart'; import '../repositories/client_create_order_repository_interface.dart'; -/// Use case for parsing rapid order text into a structured OneTimeOrder. +/// Use case for parsing rapid order text into a structured draft. +/// +/// Returns a [Map] containing parsed order data. class ParseRapidOrderTextToOrderUseCase { + /// Creates a [ParseRapidOrderTextToOrderUseCase]. ParseRapidOrderTextToOrderUseCase({ required ClientCreateOrderRepositoryInterface repository, }) : _repository = repository; final ClientCreateOrderRepositoryInterface _repository; - Future call(String text) async { + /// Parses the given [text] into an order draft map. + Future> call(String text) async { return _repository.parseRapidOrder(text); } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/reorder_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/reorder_usecase.dart deleted file mode 100644 index ddd90f2c..00000000 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/reorder_usecase.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:krow_core/core.dart'; -import '../repositories/client_create_order_repository_interface.dart'; - -/// Arguments for the ReorderUseCase. -class ReorderArguments { - const ReorderArguments({ - required this.previousOrderId, - required this.newDate, - }); - - final String previousOrderId; - final DateTime newDate; -} - -/// Use case for reordering an existing staffing order. -class ReorderUseCase implements UseCase { - const ReorderUseCase(this._repository); - - final ClientCreateOrderRepositoryInterface _repository; - - @override - Future call(ReorderArguments params) { - return _repository.reorder(params.previousOrderId, params.newDate); - } -} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart index 1f4ceb17..e6efa3af 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart @@ -13,10 +13,13 @@ import 'one_time_order_event.dart'; import 'one_time_order_state.dart'; /// BLoC for managing the multi-step one-time order creation form. +/// +/// Builds V2 API payloads and uses [OrderPreview] for reorder. class OneTimeOrderBloc extends Bloc with BlocErrorHandler, SafeBloc { + /// Creates the BLoC with required dependencies. OneTimeOrderBloc( this._createOneTimeOrderUseCase, this._getOrderDetailsForReorderUseCase, @@ -39,6 +42,7 @@ class OneTimeOrderBloc extends Bloc _loadVendors(); _loadHubs(); } + final CreateOneTimeOrderUseCase _createOneTimeOrderUseCase; final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase; final ClientOrderQueryRepositoryInterface _queryRepository; @@ -48,10 +52,7 @@ class OneTimeOrderBloc extends Bloc action: () => _queryRepository.getVendors(), onError: (_) => add(const OneTimeOrderVendorsLoaded([])), ); - - if (vendors != null) { - add(OneTimeOrderVendorsLoaded(vendors)); - } + if (vendors != null) add(OneTimeOrderVendorsLoaded(vendors)); } Future _loadRolesForVendor( @@ -63,98 +64,70 @@ class OneTimeOrderBloc extends Bloc final List result = await _queryRepository.getRolesByVendor(vendorId); return result - .map( - (OrderRole r) => OneTimeOrderRoleOption( - id: r.id, - name: r.name, - costPerHour: r.costPerHour, - ), - ) + .map((OrderRole r) => OneTimeOrderRoleOption( + id: r.id, name: r.name, costPerHour: r.costPerHour)) .toList(); }, onError: (_) => emit(state.copyWith(roles: const [])), ); - - if (roles != null) { - emit(state.copyWith(roles: roles)); - } + if (roles != null) emit(state.copyWith(roles: roles)); } Future _loadHubs() async { final List? hubs = await handleErrorWithResult( action: () async { - final String businessId = await _queryRepository.getBusinessId(); - final List result = - await _queryRepository.getHubsByOwner(businessId); + final List result = await _queryRepository.getHubs(); return result - .map( - (OrderHub h) => OneTimeOrderHubOption( - id: h.id, - name: h.name, - address: h.address, - placeId: h.placeId, - latitude: h.latitude, - longitude: h.longitude, - city: h.city, - state: h.state, - street: h.street, - country: h.country, - zipCode: h.zipCode, - ), - ) + .map((OrderHub h) => OneTimeOrderHubOption( + id: h.id, + name: h.name, + address: h.address, + placeId: h.placeId, + latitude: h.latitude, + longitude: h.longitude, + city: h.city, + state: h.state, + street: h.street, + country: h.country, + zipCode: h.zipCode, + )) .toList(); }, onError: (_) => add(const OneTimeOrderHubsLoaded([])), ); - - if (hubs != null) { - add(OneTimeOrderHubsLoaded(hubs)); - } + if (hubs != null) add(OneTimeOrderHubsLoaded(hubs)); } Future _loadManagersForHub(String hubId) async { final List? managers = await handleErrorWithResult( - action: () async { - final List result = - await _queryRepository.getManagersByHub(hubId); - return result - .map( - (OrderManager m) => OneTimeOrderManagerOption( - id: m.id, - name: m.name, - ), - ) - .toList(); - }, - onError: (_) { - add( - const OneTimeOrderManagersLoaded([]), - ); - }, - ); - - if (managers != null) { - add(OneTimeOrderManagersLoaded(managers)); - } + action: () async { + final List result = + await _queryRepository.getManagersByHub(hubId); + return result + .map((OrderManager m) => + OneTimeOrderManagerOption(id: m.id, name: m.name)) + .toList(); + }, + onError: (_) => + add(const OneTimeOrderManagersLoaded([])), + ); + if (managers != null) add(OneTimeOrderManagersLoaded(managers)); } Future _onVendorsLoaded( OneTimeOrderVendorsLoaded event, Emitter emit, ) async { - final Vendor? selectedVendor = event.vendors.isNotEmpty - ? event.vendors.first - : null; - emit( - state.copyWith( - vendors: event.vendors, - selectedVendor: selectedVendor, - isDataLoaded: true, - ), - ); + final Vendor? selectedVendor = + event.vendors.isNotEmpty ? event.vendors.first : null; + emit(state.copyWith( + vendors: event.vendors, + selectedVendor: selectedVendor, + isDataLoaded: true, + )); if (selectedVendor != null) { await _loadRolesForVendor(selectedVendor.id, emit); } @@ -172,20 +145,14 @@ class OneTimeOrderBloc extends Bloc OneTimeOrderHubsLoaded event, Emitter emit, ) { - final OneTimeOrderHubOption? selectedHub = event.hubs.isNotEmpty - ? event.hubs.first - : null; - emit( - state.copyWith( - hubs: event.hubs, - selectedHub: selectedHub, - location: selectedHub?.name ?? '', - ), - ); - - if (selectedHub != null) { - _loadManagersForHub(selectedHub.id); - } + final OneTimeOrderHubOption? selectedHub = + event.hubs.isNotEmpty ? event.hubs.first : null; + emit(state.copyWith( + hubs: event.hubs, + selectedHub: selectedHub, + location: selectedHub?.name ?? '', + )); + if (selectedHub != null) _loadManagersForHub(selectedHub.id); } void _onHubChanged( @@ -229,14 +196,9 @@ class OneTimeOrderBloc extends Bloc Emitter emit, ) { final List newPositions = - List.from(state.positions)..add( - const OneTimeOrderPosition( - role: '', - count: 1, - startTime: '09:00', - endTime: '17:00', - ), - ); + List.from(state.positions) + ..add(const OneTimeOrderPosition( + role: '', count: 1, startTime: '09:00', endTime: '17:00')); emit(state.copyWith(positions: newPositions)); } @@ -262,6 +224,7 @@ class OneTimeOrderBloc extends Bloc emit(state.copyWith(positions: newPositions)); } + /// Builds a V2 API payload and submits the one-time order. Future _onSubmitted( OneTimeOrderSubmitted event, Emitter emit, @@ -270,37 +233,45 @@ class OneTimeOrderBloc extends Bloc await handleError( emit: emit.call, action: () async { - final Map roleRates = { - for (final OneTimeOrderRoleOption role in state.roles) - role.id: role.costPerHour, - }; final OneTimeOrderHubOption? selectedHub = state.selectedHub; - if (selectedHub == null) { - throw const OrderMissingHubException(); - } - final OneTimeOrder order = OneTimeOrder( - date: state.date, - location: selectedHub.name, - positions: state.positions, - hub: OneTimeOrderHubDetails( - id: selectedHub.id, - name: selectedHub.name, - address: selectedHub.address, - placeId: selectedHub.placeId, - latitude: selectedHub.latitude, - longitude: selectedHub.longitude, - city: selectedHub.city, - state: selectedHub.state, - street: selectedHub.street, - country: selectedHub.country, - zipCode: selectedHub.zipCode, - ), - eventName: state.eventName, - vendorId: state.selectedVendor?.id, - hubManagerId: state.selectedManager?.id, - roleRates: roleRates, + if (selectedHub == null) throw const OrderMissingHubException(); + + final String orderDate = + '${state.date.year.toString().padLeft(4, '0')}-' + '${state.date.month.toString().padLeft(2, '0')}-' + '${state.date.day.toString().padLeft(2, '0')}'; + + final List> positions = + state.positions.map((OneTimeOrderPosition p) { + final OneTimeOrderRoleOption? role = state.roles + .cast() + .firstWhere( + (OneTimeOrderRoleOption? r) => r != null && r.id == p.role, + orElse: () => null, + ); + return { + if (role != null) 'roleName': role.name, + if (p.role.isNotEmpty) 'roleId': p.role, + 'workerCount': p.count, + 'startTime': p.startTime, + 'endTime': p.endTime, + if (p.lunchBreak != 'NO_BREAK' && p.lunchBreak.isNotEmpty) + 'lunchBreakMinutes': _breakMinutes(p.lunchBreak), + }; + }).toList(); + + final Map payload = { + 'hubId': selectedHub.id, + 'eventName': state.eventName, + 'orderDate': orderDate, + 'positions': positions, + if (state.selectedVendor != null) + 'vendorId': state.selectedVendor!.id, + }; + + await _createOneTimeOrderUseCase( + OneTimeOrderArguments(payload: payload), ); - await _createOneTimeOrderUseCase(OneTimeOrderArguments(order: order)); emit(state.copyWith(status: OneTimeOrderStatus.success)); }, onError: (String errorKey) => state.copyWith( @@ -310,6 +281,7 @@ class OneTimeOrderBloc extends Bloc ); } + /// Initializes the form from route arguments or reorder preview data. Future _onInitialized( OneTimeOrderInitialized event, Emitter emit, @@ -321,39 +293,30 @@ class OneTimeOrderBloc extends Bloc // Handle Rapid Order Draft if (data['isRapidDraft'] == true) { - final OneTimeOrder? order = data['order'] as OneTimeOrder?; - if (order != null) { - emit( - state.copyWith( - eventName: order.eventName ?? '', - date: order.date, - positions: order.positions, - location: order.location, - isRapidDraft: true, - ), - ); + final Map? draft = + data['order'] as Map?; + if (draft != null) { + final List draftPositions = + draft['positions'] as List? ?? const []; + final List positions = draftPositions + .map((dynamic p) { + final Map pos = p as Map; + return OneTimeOrderPosition( + role: pos['roleName'] as String? ?? '', + count: pos['workerCount'] as int? ?? 1, + startTime: pos['startTime'] as String? ?? '09:00', + endTime: pos['endTime'] as String? ?? '17:00', + ); + }) + .toList(); - // Try to match vendor if available - if (order.vendorId != null) { - final Vendor? vendor = state.vendors - .where((Vendor v) => v.id == order.vendorId) - .firstOrNull; - if (vendor != null) { - emit(state.copyWith(selectedVendor: vendor)); - await _loadRolesForVendor(vendor.id, emit); - } - } - - // Try to match hub if available - if (order.hub != null) { - final OneTimeOrderHubOption? hub = state.hubs - .where((OneTimeOrderHubOption h) => h.id == order.hub?.id) - .firstOrNull; - if (hub != null) { - emit(state.copyWith(selectedHub: hub)); - await _loadManagersForHub(hub.id); - } - } + emit(state.copyWith( + eventName: draft['eventName'] as String? ?? '', + date: startDate ?? DateTime.now(), + positions: positions.isNotEmpty ? positions : null, + location: draft['locationHint'] as String? ?? '', + isRapidDraft: true, + )); return; } } @@ -367,50 +330,26 @@ class OneTimeOrderBloc extends Bloc await handleError( emit: emit.call, action: () async { - final ReorderData orderDetails = + final OrderPreview preview = await _getOrderDetailsForReorderUseCase(orderId); - // Map positions - final List positions = orderDetails.positions.map( - (ReorderPosition role) { - return OneTimeOrderPosition( + final List positions = []; + for (final OrderPreviewShift shift in preview.shifts) { + for (final OrderPreviewRole role in shift.roles) { + positions.add(OneTimeOrderPosition( role: role.roleId, - count: role.count, - startTime: role.startTime, - endTime: role.endTime, - lunchBreak: role.lunchBreak, - ); - }, - ).toList(); - - // Update state with order details - final Vendor? selectedVendor = state.vendors - .where((Vendor v) => v.id == orderDetails.vendorId) - .firstOrNull; - - final OneTimeOrderHubOption? selectedHub = state.hubs - .where( - (OneTimeOrderHubOption h) => - h.placeId == orderDetails.hub.placeId, - ) - .firstOrNull; - - emit( - state.copyWith( - eventName: orderDetails.eventName.isNotEmpty - ? orderDetails.eventName - : title, - positions: positions, - selectedVendor: selectedVendor, - selectedHub: selectedHub, - location: selectedHub?.name ?? '', - status: OneTimeOrderStatus.initial, - ), - ); - - if (selectedVendor != null) { - await _loadRolesForVendor(selectedVendor.id, emit); + count: role.workersNeeded, + startTime: _formatTime(shift.startsAt), + endTime: _formatTime(shift.endsAt), + )); + } } + + emit(state.copyWith( + eventName: preview.title.isNotEmpty ? preview.title : title, + positions: positions.isNotEmpty ? positions : null, + status: OneTimeOrderStatus.initial, + )); }, onError: (String errorKey) => state.copyWith( status: OneTimeOrderStatus.failure, @@ -418,4 +357,29 @@ class OneTimeOrderBloc extends Bloc ), ); } + + /// Formats a [DateTime] to HH:mm string. + String _formatTime(DateTime dt) { + final DateTime local = dt.toLocal(); + return '${local.hour.toString().padLeft(2, '0')}:' + '${local.minute.toString().padLeft(2, '0')}'; + } + + /// Converts a break duration string to minutes. + int _breakMinutes(String value) { + switch (value) { + case 'MIN_10': + return 10; + case 'MIN_15': + return 15; + case 'MIN_30': + return 30; + case 'MIN_45': + return 45; + case 'MIN_60': + return 60; + default: + return 0; + } + } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart index b8e3201b..f8ab9f38 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart @@ -1,8 +1,12 @@ +import 'package:client_orders_common/client_orders_common.dart'; import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; import '../../utils/time_parsing_utils.dart'; +/// Position type alias for one-time orders. +typedef OneTimeOrderPosition = OrderPositionUiModel; + enum OneTimeOrderStatus { initial, loading, success, failure } class OneTimeOrderState extends Equatable { diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart index 928d248c..9115c729 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart @@ -6,6 +6,7 @@ import 'package:client_create_order/src/domain/usecases/create_permanent_order_u import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; +import 'package:client_create_order/src/domain/arguments/permanent_order_arguments.dart'; import 'package:krow_domain/krow_domain.dart' as domain; import 'permanent_order_event.dart'; @@ -95,12 +96,7 @@ class PermanentOrderBloc extends Bloc Future _loadHubs() async { final List? hubs = await handleErrorWithResult( action: () async { - final String? businessId = await _queryRepository.getBusinessId(); - if (businessId == null || businessId.isEmpty) { - return []; - } - final List orderHubs = - await _queryRepository.getHubsByOwner(businessId); + final List orderHubs = await _queryRepository.getHubs(); return orderHubs .map( (OrderHub hub) => PermanentOrderHubOption( @@ -327,48 +323,50 @@ class PermanentOrderBloc extends Bloc await handleError( emit: emit.call, action: () async { - final Map roleRates = { - for (final PermanentOrderRoleOption role in state.roles) - role.id: role.costPerHour, - }; final PermanentOrderHubOption? selectedHub = state.selectedHub; if (selectedHub == null) { throw const domain.OrderMissingHubException(); } - final domain.PermanentOrder order = domain.PermanentOrder( - startDate: state.startDate, - permanentDays: state.permanentDays, - positions: state.positions - .map( - (PermanentOrderPosition p) => domain.OneTimeOrderPosition( - role: p.role, - count: p.count, - startTime: p.startTime, - endTime: p.endTime, - lunchBreak: p.lunchBreak ?? 'NO_BREAK', - location: null, - ), - ) - .toList(), - hub: domain.OneTimeOrderHubDetails( - id: selectedHub.id, - name: selectedHub.name, - address: selectedHub.address, - placeId: selectedHub.placeId, - latitude: selectedHub.latitude, - longitude: selectedHub.longitude, - city: selectedHub.city, - state: selectedHub.state, - street: selectedHub.street, - country: selectedHub.country, - zipCode: selectedHub.zipCode, - ), - eventName: state.eventName, - vendorId: state.selectedVendor?.id, - hubManagerId: state.selectedManager?.id, - roleRates: roleRates, + + final String startDate = + '${state.startDate.year.toString().padLeft(4, '0')}-' + '${state.startDate.month.toString().padLeft(2, '0')}-' + '${state.startDate.day.toString().padLeft(2, '0')}'; + + final List daysOfWeek = state.permanentDays + .map((String day) => _dayLabels.indexOf(day) % 7) + .toList(); + + final List> positions = + state.positions.map((PermanentOrderPosition p) { + final PermanentOrderRoleOption? role = state.roles + .cast() + .firstWhere( + (PermanentOrderRoleOption? r) => r != null && r.id == p.role, + orElse: () => null, + ); + return { + if (role != null) 'roleName': role.name, + if (p.role.isNotEmpty) 'roleId': p.role, + 'workerCount': p.count, + 'startTime': p.startTime, + 'endTime': p.endTime, + }; + }).toList(); + + final Map payload = { + 'hubId': selectedHub.id, + 'eventName': state.eventName, + 'startDate': startDate, + 'daysOfWeek': daysOfWeek, + 'positions': positions, + if (state.selectedVendor != null) + 'vendorId': state.selectedVendor!.id, + }; + + await _createPermanentOrderUseCase( + PermanentOrderArguments(payload: payload), ); - await _createPermanentOrderUseCase(order); emit(state.copyWith(status: PermanentOrderStatus.success)); }, onError: (String errorKey) => state.copyWith( @@ -398,52 +396,32 @@ class PermanentOrderBloc extends Bloc await handleError( emit: emit.call, action: () async { - final domain.ReorderData orderDetails = + final domain.OrderPreview preview = await _getOrderDetailsForReorderUseCase(orderId); - // Map positions - final List positions = orderDetails.positions - .map((domain.ReorderPosition role) { - return PermanentOrderPosition( - role: role.roleId, - count: role.count, - startTime: role.startTime, - endTime: role.endTime, - lunchBreak: role.lunchBreak, - ); - }) - .toList(); - - // Update state with order details - final domain.Vendor? selectedVendor = state.vendors - .where((domain.Vendor v) => v.id == orderDetails.vendorId) - .firstOrNull; - - final PermanentOrderHubOption? selectedHub = state.hubs - .where( - (PermanentOrderHubOption h) => - h.placeId == orderDetails.hub.placeId, - ) - .firstOrNull; + final List positions = + []; + for (final domain.OrderPreviewShift shift in preview.shifts) { + for (final domain.OrderPreviewRole role in shift.roles) { + positions.add(PermanentOrderPosition( + role: role.roleId, + count: role.workersNeeded, + startTime: _formatTime(shift.startsAt), + endTime: _formatTime(shift.endsAt), + )); + } + } emit( state.copyWith( - eventName: orderDetails.eventName.isNotEmpty - ? orderDetails.eventName - : title, - positions: positions, - selectedVendor: selectedVendor, - selectedHub: selectedHub, - location: selectedHub?.name ?? '', + eventName: + preview.title.isNotEmpty ? preview.title : title, + positions: positions.isNotEmpty ? positions : null, status: PermanentOrderStatus.initial, - startDate: startDate ?? orderDetails.startDate ?? DateTime.now(), - permanentDays: orderDetails.permanentDays, + startDate: + startDate ?? preview.startsAt ?? DateTime.now(), ), ); - - if (selectedVendor != null) { - await _loadRolesForVendor(selectedVendor.id, emit); - } }, onError: (String errorKey) => state.copyWith( status: PermanentOrderStatus.failure, @@ -452,6 +430,13 @@ class PermanentOrderBloc extends Bloc ); } + /// Formats a [DateTime] to HH:mm string. + String _formatTime(DateTime dt) { + final DateTime local = dt.toLocal(); + return '${local.hour.toString().padLeft(2, '0')}:' + '${local.minute.toString().padLeft(2, '0')}'; + } + static List _sortDays(List days) { days.sort( (String a, String b) => diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/rapid_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/rapid_order_bloc.dart index f9cc14e6..8c3b56ce 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/rapid_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/rapid_order_bloc.dart @@ -2,8 +2,6 @@ import 'package:client_create_order/src/domain/usecases/parse_rapid_order_usecas import 'package:client_create_order/src/domain/usecases/transcribe_rapid_order_usecase.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; - import 'rapid_order_event.dart'; import 'rapid_order_state.dart'; @@ -119,9 +117,13 @@ class RapidOrderBloc extends Bloc await handleError( emit: emit.call, action: () async { - final OneTimeOrder order = await _parseRapidOrderUseCase(message); + final Map parsedDraft = + await _parseRapidOrderUseCase(message); emit( - state.copyWith(status: RapidOrderStatus.parsed, parsedOrder: order), + state.copyWith( + status: RapidOrderStatus.parsed, + parsedDraft: parsedDraft, + ), ); }, onError: (String errorKey) => diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/rapid_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/rapid_order_state.dart index af3abd99..cec172b1 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/rapid_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/rapid_order_state.dart @@ -1,9 +1,11 @@ import 'package:equatable/equatable.dart'; -import 'package:krow_domain/krow_domain.dart'; +/// Status of the rapid order creation flow. enum RapidOrderStatus { initial, submitting, parsed, failure } +/// State for the rapid order BLoC. class RapidOrderState extends Equatable { + /// Creates a [RapidOrderState]. const RapidOrderState({ this.status = RapidOrderStatus.initial, this.message = '', @@ -11,28 +13,42 @@ class RapidOrderState extends Equatable { this.isTranscribing = false, this.examples = const [], this.error, - this.parsedOrder, + this.parsedDraft, }); + /// Current status of the rapid order flow. final RapidOrderStatus status; + + /// The text message entered or transcribed. final String message; + + /// Whether the microphone is actively recording. final bool isListening; + + /// Whether audio is being transcribed. final bool isTranscribing; + + /// Example prompts for the user. final List examples; + + /// Error message, if any. final String? error; - final OneTimeOrder? parsedOrder; + + /// The parsed draft from the AI, as a map matching the V2 payload shape. + final Map? parsedDraft; @override List get props => [ - status, - message, - isListening, - isTranscribing, - examples, - error, - parsedOrder, - ]; + status, + message, + isListening, + isTranscribing, + examples, + error, + parsedDraft, + ]; + /// Creates a copy with overridden fields. RapidOrderState copyWith({ RapidOrderStatus? status, String? message, @@ -40,7 +56,7 @@ class RapidOrderState extends Equatable { bool? isTranscribing, List? examples, String? error, - OneTimeOrder? parsedOrder, + Map? parsedDraft, }) { return RapidOrderState( status: status ?? this.status, @@ -49,7 +65,7 @@ class RapidOrderState extends Equatable { isTranscribing: isTranscribing ?? this.isTranscribing, examples: examples ?? this.examples, error: error ?? this.error, - parsedOrder: parsedOrder ?? this.parsedOrder, + parsedDraft: parsedDraft ?? this.parsedDraft, ); } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart index 972db182..6154dc0c 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart @@ -6,6 +6,7 @@ import 'package:client_create_order/src/domain/usecases/create_recurring_order_u import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; +import 'package:client_create_order/src/domain/arguments/recurring_order_arguments.dart'; import 'package:krow_domain/krow_domain.dart' as domain; import 'recurring_order_event.dart'; @@ -13,10 +14,9 @@ import 'recurring_order_state.dart'; /// BLoC for managing the recurring order creation form. /// -/// This BLoC delegates all backend queries to -/// [ClientOrderQueryRepositoryInterface] and order submission to -/// [CreateRecurringOrderUseCase], keeping the presentation layer free -/// from direct `krow_data_connect` imports. +/// Delegates all backend queries to [ClientOrderQueryRepositoryInterface] +/// and order submission to [CreateRecurringOrderUseCase]. +/// Builds V2 API payloads from form state. class RecurringOrderBloc extends Bloc with BlocErrorHandler, @@ -111,9 +111,7 @@ class RecurringOrderBloc extends Bloc Future _loadHubs() async { final List? hubs = await handleErrorWithResult( action: () async { - final String businessId = await _queryRepository.getBusinessId(); - final List orderHubs = - await _queryRepository.getHubsByOwner(businessId); + final List orderHubs = await _queryRepository.getHubs(); return orderHubs .map( (OrderHub hub) => RecurringOrderHubOption( @@ -357,50 +355,56 @@ class RecurringOrderBloc extends Bloc await handleError( emit: emit.call, action: () async { - final Map roleRates = { - for (final RecurringOrderRoleOption role in state.roles) - role.id: role.costPerHour, - }; final RecurringOrderHubOption? selectedHub = state.selectedHub; if (selectedHub == null) { throw const domain.OrderMissingHubException(); } - final domain.RecurringOrder order = domain.RecurringOrder( - startDate: state.startDate, - endDate: state.endDate, - recurringDays: state.recurringDays, - location: selectedHub.name, - positions: state.positions - .map( - (RecurringOrderPosition p) => domain.RecurringOrderPosition( - role: p.role, - count: p.count, - startTime: p.startTime, - endTime: p.endTime, - lunchBreak: p.lunchBreak ?? 'NO_BREAK', - location: null, - ), - ) - .toList(), - hub: domain.RecurringOrderHubDetails( - id: selectedHub.id, - name: selectedHub.name, - address: selectedHub.address, - placeId: selectedHub.placeId, - latitude: selectedHub.latitude, - longitude: selectedHub.longitude, - city: selectedHub.city, - state: selectedHub.state, - street: selectedHub.street, - country: selectedHub.country, - zipCode: selectedHub.zipCode, - ), - eventName: state.eventName, - vendorId: state.selectedVendor?.id, - hubManagerId: state.selectedManager?.id, - roleRates: roleRates, + + final String startDate = + '${state.startDate.year.toString().padLeft(4, '0')}-' + '${state.startDate.month.toString().padLeft(2, '0')}-' + '${state.startDate.day.toString().padLeft(2, '0')}'; + final String endDate = + '${state.endDate.year.toString().padLeft(4, '0')}-' + '${state.endDate.month.toString().padLeft(2, '0')}-' + '${state.endDate.day.toString().padLeft(2, '0')}'; + + // Map day labels (MON=1, TUE=2, ..., SUN=0) to V2 int format + final List recurrenceDays = state.recurringDays + .map((String day) => _dayLabels.indexOf(day) % 7) + .toList(); + + final List> positions = + state.positions.map((RecurringOrderPosition p) { + final RecurringOrderRoleOption? role = state.roles + .cast() + .firstWhere( + (RecurringOrderRoleOption? r) => r != null && r.id == p.role, + orElse: () => null, + ); + return { + if (role != null) 'roleName': role.name, + if (p.role.isNotEmpty) 'roleId': p.role, + 'workerCount': p.count, + 'startTime': p.startTime, + 'endTime': p.endTime, + }; + }).toList(); + + final Map payload = { + 'hubId': selectedHub.id, + 'eventName': state.eventName, + 'startDate': startDate, + 'endDate': endDate, + 'recurrenceDays': recurrenceDays, + 'positions': positions, + if (state.selectedVendor != null) + 'vendorId': state.selectedVendor!.id, + }; + + await _createRecurringOrderUseCase( + RecurringOrderArguments(payload: payload), ); - await _createRecurringOrderUseCase(order); emit(state.copyWith(status: RecurringOrderStatus.success)); }, onError: (String errorKey) => state.copyWith( @@ -430,53 +434,34 @@ class RecurringOrderBloc extends Bloc await handleError( emit: emit.call, action: () async { - final domain.ReorderData orderDetails = + final domain.OrderPreview preview = await _getOrderDetailsForReorderUseCase(orderId); - // Map positions - final List positions = orderDetails.positions - .map((domain.ReorderPosition role) { - return RecurringOrderPosition( - role: role.roleId, - count: role.count, - startTime: role.startTime, - endTime: role.endTime, - lunchBreak: role.lunchBreak, - ); - }) - .toList(); - - // Update state with order details - final domain.Vendor? selectedVendor = state.vendors - .where((domain.Vendor v) => v.id == orderDetails.vendorId) - .firstOrNull; - - final RecurringOrderHubOption? selectedHub = state.hubs - .where( - (RecurringOrderHubOption h) => - h.placeId == orderDetails.hub.placeId, - ) - .firstOrNull; + // Map positions from preview shifts/roles + final List positions = + []; + for (final domain.OrderPreviewShift shift in preview.shifts) { + for (final domain.OrderPreviewRole role in shift.roles) { + positions.add(RecurringOrderPosition( + role: role.roleId, + count: role.workersNeeded, + startTime: _formatTime(shift.startsAt), + endTime: _formatTime(shift.endsAt), + )); + } + } emit( state.copyWith( - eventName: orderDetails.eventName.isNotEmpty - ? orderDetails.eventName - : title, - positions: positions, - selectedVendor: selectedVendor, - selectedHub: selectedHub, - location: selectedHub?.name ?? '', + eventName: + preview.title.isNotEmpty ? preview.title : title, + positions: positions.isNotEmpty ? positions : null, status: RecurringOrderStatus.initial, - startDate: startDate ?? orderDetails.startDate ?? DateTime.now(), - endDate: orderDetails.endDate ?? DateTime.now(), - recurringDays: orderDetails.recurringDays, + startDate: + startDate ?? preview.startsAt ?? DateTime.now(), + endDate: preview.endsAt ?? DateTime.now(), ), ); - - if (selectedVendor != null) { - await _loadRolesForVendor(selectedVendor.id, emit); - } }, onError: (String errorKey) => state.copyWith( status: RecurringOrderStatus.failure, @@ -485,6 +470,13 @@ class RecurringOrderBloc extends Bloc ); } + /// Formats a [DateTime] to HH:mm string. + String _formatTime(DateTime dt) { + final DateTime local = dt.toLocal(); + return '${local.hour.toString().padLeft(2, '0')}:' + '${local.minute.toString().padLeft(2, '0')}'; + } + static List _sortDays(List days) { days.sort( (String a, String b) => diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart index c018bfe9..7c3a1299 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart @@ -5,7 +5,7 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:client_orders_common/client_orders_common.dart'; import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart' hide PermanentOrderPosition; +import 'package:krow_domain/krow_domain.dart'; import '../blocs/permanent_order/permanent_order_bloc.dart'; import '../blocs/permanent_order/permanent_order_event.dart'; import '../blocs/permanent_order/permanent_order_state.dart'; diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart index 0da250ed..b966dbb4 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart @@ -4,7 +4,7 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:client_orders_common/client_orders_common.dart'; import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart' hide RecurringOrderPosition; +import 'package:krow_domain/krow_domain.dart'; import '../blocs/recurring_order/recurring_order_bloc.dart'; import '../blocs/recurring_order/recurring_order_event.dart'; import '../blocs/recurring_order/recurring_order_state.dart'; diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart index ad687c7d..514cc8fe 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart @@ -56,10 +56,10 @@ class _RapidOrderFormState extends State<_RapidOrderForm> { ); } } else if (state.status == RapidOrderStatus.parsed && - state.parsedOrder != null) { + state.parsedDraft != null) { Modular.to.toCreateOrderOneTime( arguments: { - 'order': state.parsedOrder, + 'order': state.parsedDraft, 'isRapidDraft': true, }, ); diff --git a/apps/mobile/packages/features/client/orders/create_order/pubspec.yaml b/apps/mobile/packages/features/client/orders/create_order/pubspec.yaml index 20a70779..fdce440c 100644 --- a/apps/mobile/packages/features/client/orders/create_order/pubspec.yaml +++ b/apps/mobile/packages/features/client/orders/create_order/pubspec.yaml @@ -22,12 +22,8 @@ dependencies: path: ../../../../domain krow_core: path: ../../../../core - krow_data_connect: - path: ../../../../data_connect client_orders_common: path: ../orders_common - firebase_data_connect: ^0.2.2+2 - firebase_auth: ^6.1.4 dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_form.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_form.dart index a21092a0..08b89f28 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_form.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_form.dart @@ -137,7 +137,7 @@ class OneTimeOrderForm extends StatelessWidget { return DropdownMenuItem( value: vendor, child: Text( - vendor.name, + vendor.companyName, style: UiTypography.body2m.textPrimary, ), ); diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_form.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_form.dart index 36d7ba08..ff6d479a 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_form.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_form.dart @@ -147,7 +147,7 @@ class PermanentOrderForm extends StatelessWidget { return DropdownMenuItem( value: vendor, child: Text( - vendor.name, + vendor.companyName, style: UiTypography.body2m.textPrimary, ), ); diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_form.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_form.dart index 2bc274bc..a80be192 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_form.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_form.dart @@ -155,7 +155,7 @@ class RecurringOrderForm extends StatelessWidget { return DropdownMenuItem( value: vendor, child: Text( - vendor.name, + vendor.companyName, style: UiTypography.body2m.textPrimary, ), ); diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift index 36b8a0c0..1f7af4bf 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,7 +7,6 @@ import Foundation import file_picker import file_selector_macos -import firebase_app_check import firebase_auth import firebase_core import flutter_local_notifications @@ -19,7 +18,6 @@ import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) - FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) diff --git a/apps/mobile/packages/features/client/orders/orders_common/pubspec.yaml b/apps/mobile/packages/features/client/orders/orders_common/pubspec.yaml index 1f17a970..6c377cdc 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/pubspec.yaml +++ b/apps/mobile/packages/features/client/orders/orders_common/pubspec.yaml @@ -14,20 +14,18 @@ dependencies: flutter_bloc: ^8.1.0 flutter_modular: ^6.3.0 equatable: ^2.0.5 - + intl: any + # Architecture Packages design_system: path: ../../../../design_system core_localization: path: ../../../../core_localization - krow_domain: ^0.0.1 - krow_data_connect: ^0.0.1 + krow_domain: + path: ../../../../domain krow_core: path: ../../../../core - firebase_data_connect: any - intl: any - dev_dependencies: flutter_test: sdk: flutter diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart index 87b9f240..1173cd20 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart @@ -1,199 +1,98 @@ -import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:krow_data_connect/krow_data_connect.dart' as dc; -import 'package:krow_domain/krow_domain.dart' as domain; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + import '../../domain/repositories/i_view_orders_repository.dart'; -/// Implementation of [IViewOrdersRepository] using Data Connect. +/// V2 API implementation of [IViewOrdersRepository]. +/// +/// Replaces the old Data Connect implementation with [BaseApiService] calls +/// to the V2 query and command API endpoints. class ViewOrdersRepositoryImpl implements IViewOrdersRepository { - ViewOrdersRepositoryImpl({required dc.DataConnectService service}) - : _service = service; - final dc.DataConnectService _service; + /// Creates an instance backed by the given [apiService]. + ViewOrdersRepositoryImpl({required BaseApiService apiService}) + : _api = apiService; + + final BaseApiService _api; @override - Future> getOrdersForRange({ + Future> getOrdersForRange({ required DateTime start, required DateTime end, }) async { - return _service.run(() async { - final String businessId = await _service.getBusinessId(); - - final fdc.Timestamp startTimestamp = _service.toTimestamp( - _startOfDay(start), - ); - final fdc.Timestamp endTimestamp = _service.toTimestamp(_endOfDay(end)); - final fdc.QueryResult< - dc.ListShiftRolesByBusinessAndDateRangeData, - dc.ListShiftRolesByBusinessAndDateRangeVariables - > - result = await _service.connector - .listShiftRolesByBusinessAndDateRange( - businessId: businessId, - start: startTimestamp, - end: endTimestamp, - ) - .execute(); - debugPrint( - 'ViewOrders range start=${start.toIso8601String()} end=${end.toIso8601String()} shiftRoles=${result.data.shiftRoles.length}', - ); - - final String businessName = - dc.ClientSessionStore.instance.session?.business?.businessName ?? - 'Your Company'; - - return result.data.shiftRoles.map(( - dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole, - ) { - final DateTime? shiftDate = shiftRole.shift.date - ?.toDateTime() - .toLocal(); - final String dateStr = shiftDate == null - ? '' - : DateFormat('yyyy-MM-dd').format(shiftDate); - final String startTime = _formatTime(shiftRole.startTime); - final String endTime = _formatTime(shiftRole.endTime); - final int filled = shiftRole.assigned ?? 0; - final int workersNeeded = shiftRole.count; - final double hours = shiftRole.hours ?? 0; - final double totalValue = shiftRole.totalValue ?? 0; - final double hourlyRate = _hourlyRate( - shiftRole.totalValue, - shiftRole.hours, - ); - // final String status = filled >= workersNeeded ? 'filled' : 'open'; - final String status = shiftRole.shift.status?.stringValue ?? 'OPEN'; - - debugPrint( - 'ViewOrders item: date=$dateStr status=$status shiftId=${shiftRole.shiftId} ' - 'roleId=${shiftRole.roleId} start=${shiftRole.startTime?.toJson()} ' - 'end=${shiftRole.endTime?.toJson()} hours=$hours totalValue=$totalValue', - ); - - final String eventName = - shiftRole.shift.order.eventName ?? shiftRole.shift.title; - - final order = shiftRole.shift.order; - final String? hubManagerId = order.hubManagerId; - final String? hubManagerName = order.hubManager?.user?.fullName; - - return domain.OrderItem( - id: _shiftRoleKey(shiftRole.shiftId, shiftRole.roleId), - orderId: order.id, - orderType: domain.OrderType.fromString( - order.orderType.stringValue, - ), - title: shiftRole.role.name, - eventName: eventName, - clientName: businessName, - status: status, - date: dateStr, - startTime: startTime, - endTime: endTime, - location: shiftRole.shift.location ?? '', - locationAddress: shiftRole.shift.locationAddress ?? '', - filled: filled, - workersNeeded: workersNeeded, - hourlyRate: hourlyRate, - hours: hours, - totalValue: totalValue, - confirmedApps: const >[], - hubManagerId: hubManagerId, - hubManagerName: hubManagerName, - ); - }).toList(); - }); + final ApiResponse response = await _api.get( + V2ApiEndpoints.clientOrdersView, + params: { + 'startDate': start.toIso8601String(), + 'endDate': end.toIso8601String(), + }, + ); + final Map data = response.data as Map; + final List items = data['items'] as List; + return items + .map((dynamic json) => + OrderItem.fromJson(json as Map)) + .toList(); } @override - Future>>> getAcceptedApplicationsForDay( - DateTime day, - ) async { - return _service.run(() async { - final String businessId = await _service.getBusinessId(); - - final fdc.Timestamp dayStart = _service.toTimestamp(_startOfDay(day)); - final fdc.Timestamp dayEnd = _service.toTimestamp(_endOfDay(day)); - final fdc.QueryResult< - dc.ListAcceptedApplicationsByBusinessForDayData, - dc.ListAcceptedApplicationsByBusinessForDayVariables - > - result = await _service.connector - .listAcceptedApplicationsByBusinessForDay( - businessId: businessId, - dayStart: dayStart, - dayEnd: dayEnd, - ) - .execute(); - - print( - 'ViewOrders day=${day.toIso8601String()} applications=${result.data.applications.length}', - ); - - final Map>> grouped = - >>{}; - for (final dc.ListAcceptedApplicationsByBusinessForDayApplications - application - in result.data.applications) { - print( - 'ViewOrders app: shiftId=${application.shiftId} roleId=${application.roleId} ' - 'checkIn=${application.checkInTime?.toJson()} checkOut=${application.checkOutTime?.toJson()}', - ); - final String key = _shiftRoleKey( - application.shiftId, - application.roleId, - ); - grouped.putIfAbsent(key, () => >[]); - grouped[key]!.add({ - 'id': application.id, - 'worker_id': application.staff.id, - 'worker_name': application.staff.fullName, - 'status': 'confirmed', - 'photo_url': application.staff.photoUrl, - 'phone': application.staff.phone, - 'rating': application.staff.averageRating, - }); - } - return grouped; - }); + Future editOrder({ + required String orderId, + required Map payload, + }) async { + final ApiResponse response = await _api.post( + V2ApiEndpoints.clientOrderEdit(orderId), + data: payload, + ); + final Map data = response.data as Map; + return data['orderId'] as String? ?? orderId; } - String _shiftRoleKey(String shiftId, String roleId) { - return '$shiftId:$roleId'; - } - - DateTime _startOfDay(DateTime dateTime) { - return DateTime(dateTime.year, dateTime.month, dateTime.day); - } - - DateTime _endOfDay(DateTime dateTime) { - // We add the current microseconds to ensure the query variables are unique - // each time we fetch, bypassing any potential Data Connect caching. - final DateTime now = DateTime.now(); - return DateTime( - dateTime.year, - dateTime.month, - dateTime.day, - 23, - 59, - 59, - now.millisecond, - now.microsecond, + @override + Future cancelOrder({ + required String orderId, + String? reason, + }) async { + await _api.post( + V2ApiEndpoints.clientOrderCancel(orderId), + data: { + if (reason != null) 'reason': reason, + }, ); } - String _formatTime(fdc.Timestamp? timestamp) { - if (timestamp == null) { - return ''; - } - final DateTime dateTime = timestamp.toDateTime().toLocal(); - return DateFormat('HH:mm').format(dateTime); + @override + Future> getVendors() async { + final ApiResponse response = await _api.get(V2ApiEndpoints.clientVendors); + final Map data = response.data as Map; + final List items = data['items'] as List; + return items + .map((dynamic json) => Vendor.fromJson(json as Map)) + .toList(); } - double _hourlyRate(double? totalValue, double? hours) { - if (totalValue == null || hours == null || hours == 0) { - return 0; - } - return totalValue / hours; + @override + Future>> getRolesByVendor(String vendorId) async { + final ApiResponse response = + await _api.get(V2ApiEndpoints.clientVendorRoles(vendorId)); + final Map data = response.data as Map; + final List items = data['items'] as List; + return items.cast>(); + } + + @override + Future>> getHubs() async { + final ApiResponse response = await _api.get(V2ApiEndpoints.clientHubs); + final Map data = response.data as Map; + final List items = data['items'] as List; + return items.cast>(); + } + + @override + Future>> getManagersByHub(String hubId) async { + final ApiResponse response = + await _api.get(V2ApiEndpoints.clientHubManagers(hubId)); + final Map data = response.data as Map; + final List items = data['items'] as List; + return items.cast>(); } } diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/arguments/orders_day_arguments.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/arguments/orders_day_arguments.dart deleted file mode 100644 index 6c74b6d7..00000000 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/arguments/orders_day_arguments.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:krow_core/core.dart'; - -class OrdersDayArguments extends UseCaseArgument { - const OrdersDayArguments({required this.day}); - - final DateTime day; - - @override - List get props => [day]; -} diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/repositories/i_view_orders_repository.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/repositories/i_view_orders_repository.dart index f2cdfae0..a2b86ccf 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/repositories/i_view_orders_repository.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/repositories/i_view_orders_repository.dart @@ -1,15 +1,40 @@ import 'package:krow_domain/krow_domain.dart'; /// Repository interface for fetching and managing client orders. +/// +/// V2 API returns workers inline with order items, so the separate +/// accepted-applications method is no longer needed. abstract class IViewOrdersRepository { - /// Fetches a list of [OrderItem] for the client. + /// Fetches [OrderItem] list for the given date range via the V2 API. Future> getOrdersForRange({ required DateTime start, required DateTime end, }); - /// Fetches accepted staff applications for the given day, grouped by shift+role. - Future>>> getAcceptedApplicationsForDay( - DateTime day, - ); + /// Submits an edit for the order identified by [orderId]. + /// + /// The [payload] map follows the V2 `clientOrderEdit` schema. + /// The backend creates a new order copy and cancels the original. + Future editOrder({ + required String orderId, + required Map payload, + }); + + /// Cancels the order identified by [orderId]. + Future cancelOrder({ + required String orderId, + String? reason, + }); + + /// Fetches available vendors for the current tenant. + Future> getVendors(); + + /// Fetches roles offered by the given [vendorId]. + Future>> getRolesByVendor(String vendorId); + + /// Fetches hubs for the current business. + Future>> getHubs(); + + /// Fetches team members for the given [hubId]. + Future>> getManagersByHub(String hubId); } diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/usecases/get_accepted_applications_for_day_use_case.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/usecases/get_accepted_applications_for_day_use_case.dart deleted file mode 100644 index 0afe115b..00000000 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/usecases/get_accepted_applications_for_day_use_case.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:krow_core/core.dart'; -import '../repositories/i_view_orders_repository.dart'; -import '../arguments/orders_day_arguments.dart'; - -class GetAcceptedApplicationsForDayUseCase - implements UseCase>>> { - const GetAcceptedApplicationsForDayUseCase(this._repository); - - final IViewOrdersRepository _repository; - - @override - Future>>> call( - OrdersDayArguments input, - ) { - return _repository.getAcceptedApplicationsForDay(input.day); - } -} diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/blocs/view_orders_cubit.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/blocs/view_orders_cubit.dart index a4bb9c25..89abd42d 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/blocs/view_orders_cubit.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/blocs/view_orders_cubit.dart @@ -1,39 +1,36 @@ -import 'package:intl/intl.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/arguments/orders_day_arguments.dart'; + import '../../domain/arguments/orders_range_arguments.dart'; -import '../../domain/usecases/get_accepted_applications_for_day_use_case.dart'; import '../../domain/usecases/get_orders_use_case.dart'; import 'view_orders_state.dart'; /// Cubit for managing the state of the View Orders feature. /// -/// This Cubit handles loading orders, date selection, and tab filtering. +/// Handles loading orders, date selection, and tab filtering. +/// V2 API returns workers inline so no separate applications fetch is needed. class ViewOrdersCubit extends Cubit with BlocErrorHandler { + /// Creates the cubit with the required use case. ViewOrdersCubit({ required GetOrdersUseCase getOrdersUseCase, - required GetAcceptedApplicationsForDayUseCase getAcceptedAppsUseCase, - }) : _getOrdersUseCase = getOrdersUseCase, - _getAcceptedAppsUseCase = getAcceptedAppsUseCase, - super(ViewOrdersState(selectedDate: DateTime.now())) { + }) : _getOrdersUseCase = getOrdersUseCase, + super(ViewOrdersState(selectedDate: DateTime.now())) { _init(); } final GetOrdersUseCase _getOrdersUseCase; - final GetAcceptedApplicationsForDayUseCase _getAcceptedAppsUseCase; int _requestId = 0; void _init() { - updateWeekOffset(0); // Initialize calendar days + updateWeekOffset(0); } + /// Loads orders for the given date range. Future _loadOrdersForRange({ required DateTime rangeStart, required DateTime rangeEnd, - required DateTime dayForApps, }) async { final int requestId = ++_requestId; emit(state.copyWith(status: ViewOrdersStatus.loading)); @@ -46,18 +43,13 @@ class ViewOrdersCubit extends Cubit final List orders = await _getOrdersUseCase( OrdersRangeArguments(start: rangeStart, end: rangeEnd), ); - final Map>> apps = - await _getAcceptedAppsUseCase(OrdersDayArguments(day: dayForApps)); - if (requestId != _requestId) { - return; - } + if (requestId != _requestId) return; - final List updatedOrders = _applyApplications(orders, apps); emit( state.copyWith( status: ViewOrdersStatus.success, - orders: updatedOrders, + orders: orders, ), ); _updateDerivedState(); @@ -69,25 +61,28 @@ class ViewOrdersCubit extends Cubit ); } + /// Selects a date and refilters. void selectDate(DateTime date) { emit(state.copyWith(selectedDate: date)); - _refreshAcceptedApplications(date); + _updateDerivedState(); } + /// Selects a filter tab and refilters. void selectFilterTab(String tabId) { emit(state.copyWith(filterTab: tabId)); _updateDerivedState(); } + /// Navigates the calendar by week offset. void updateWeekOffset(int offset) { final int newWeekOffset = state.weekOffset + offset; final List calendarDays = _calculateCalendarDays(newWeekOffset); final DateTime? selectedDate = state.selectedDate; final DateTime updatedSelectedDate = selectedDate != null && - calendarDays.any((DateTime day) => _isSameDay(day, selectedDate)) - ? selectedDate - : calendarDays.first; + calendarDays.any((DateTime day) => _isSameDay(day, selectedDate)) + ? selectedDate + : calendarDays.first; emit( state.copyWith( weekOffset: newWeekOffset, @@ -99,10 +94,10 @@ class ViewOrdersCubit extends Cubit _loadOrdersForRange( rangeStart: calendarDays.first, rangeEnd: calendarDays.last, - dayForApps: updatedSelectedDate, ); } + /// Jumps the calendar to a specific date. void jumpToDate(DateTime date) { final DateTime target = DateTime(date.year, date.month, date.day); final DateTime startDate = _calculateCalendarDays(0).first; @@ -121,14 +116,13 @@ class ViewOrdersCubit extends Cubit _loadOrdersForRange( rangeStart: calendarDays.first, rangeEnd: calendarDays.last, - dayForApps: target, ); } void _updateDerivedState() { final List filteredOrders = _calculateFilteredOrders(state); - final int activeCount = _calculateCategoryCount('active'); - final int completedCount = _calculateCategoryCount('completed'); + final int activeCount = _calculateCategoryCount(ShiftStatus.active); + final int completedCount = _calculateCategoryCount(ShiftStatus.completed); final int upNextCount = _calculateUpNextCount(); emit( @@ -141,64 +135,6 @@ class ViewOrdersCubit extends Cubit ); } - Future _refreshAcceptedApplications(DateTime day) async { - await handleErrorWithResult( - action: () async { - final Map>> apps = - await _getAcceptedAppsUseCase(OrdersDayArguments(day: day)); - final List updatedOrders = _applyApplications( - state.orders, - apps, - ); - emit(state.copyWith(orders: updatedOrders)); - _updateDerivedState(); - }, - onError: (_) { - // Keep existing data on failure, just log error via handleErrorWithResult - }, - ); - } - - List _applyApplications( - List orders, - Map>> apps, - ) { - return orders.map((OrderItem order) { - final List> confirmed = - apps[order.id] ?? const >[]; - if (confirmed.isEmpty) { - return order; - } - - final int filled = confirmed.length; - final String status = filled >= order.workersNeeded - ? 'FILLED' - : order.status; - return OrderItem( - id: order.id, - orderId: order.orderId, - orderType: order.orderType, - title: order.title, - eventName: order.eventName, - clientName: order.clientName, - status: status, - date: order.date, - startTime: order.startTime, - endTime: order.endTime, - location: order.location, - locationAddress: order.locationAddress, - filled: filled, - workersNeeded: order.workersNeeded, - hourlyRate: order.hourlyRate, - hours: order.hours, - totalValue: order.totalValue, - confirmedApps: confirmed, - hubManagerId: order.hubManagerId, - hubManagerName: order.hubManagerName, - ); - }).toList(); - } - bool _isSameDay(DateTime a, DateTime b) { return a.year == b.year && a.month == b.month && a.day == b.day; } @@ -218,103 +154,64 @@ class ViewOrdersCubit extends Cubit ); } + /// Filters orders for the selected date and tab. List _calculateFilteredOrders(ViewOrdersState state) { if (state.selectedDate == null) return []; - final String selectedDateStr = DateFormat( - 'yyyy-MM-dd', - ).format(state.selectedDate!); + final DateTime selectedDay = state.selectedDate!; - // Filter by date final List ordersOnDate = state.orders - .where((OrderItem s) => s.date == selectedDateStr) + .where((OrderItem s) => _isSameDay(s.date, selectedDay)) .toList(); - // Sort by start time ordersOnDate.sort( - (OrderItem a, OrderItem b) => a.startTime.compareTo(b.startTime), + (OrderItem a, OrderItem b) => a.startsAt.compareTo(b.startsAt), ); if (state.filterTab == 'all') { - final List filtered = ordersOnDate + return ordersOnDate .where( - (OrderItem s) => - // TODO(orders): move PENDING to its own tab once available. - [ - 'OPEN', - 'FILLED', - 'CONFIRMED', - 'PENDING', - 'ASSIGNED', - ].contains(s.status), + (OrderItem s) => [ + ShiftStatus.open, + ShiftStatus.pendingConfirmation, + ShiftStatus.assigned, + ].contains(s.status), ) .toList(); - print( - 'ViewOrders tab=all statuses=${ordersOnDate.map((OrderItem s) => s.status).toList()} filtered=${filtered.length}', - ); - return filtered; } else if (state.filterTab == 'active') { - final List filtered = ordersOnDate + return ordersOnDate .where((OrderItem s) => s.status == ShiftStatus.active) .toList(); - print( - 'ViewOrders tab=active statuses=${ordersOnDate.map((OrderItem s) => s.status).toList()} filtered=${filtered.length}', - ); - return filtered; } else if (state.filterTab == 'completed') { - final List filtered = ordersOnDate + return ordersOnDate .where((OrderItem s) => s.status == ShiftStatus.completed) .toList(); - print( - 'ViewOrders tab=completed statuses=${ordersOnDate.map((OrderItem s) => s.status).toList()} filtered=${filtered.length}', - ); - return filtered; } return []; } - int _calculateCategoryCount(String category) { + int _calculateCategoryCount(ShiftStatus targetStatus) { if (state.selectedDate == null) return 0; - - final String selectedDateStr = DateFormat( - 'yyyy-MM-dd', - ).format(state.selectedDate!); - - if (category == 'active') { - return state.orders - .where( - (OrderItem s) => - s.date == selectedDateStr && s.status == ShiftStatus.active, - ) - .length; - } else if (category == 'completed') { - return state.orders - .where( - (OrderItem s) => - s.date == selectedDateStr && s.status == ShiftStatus.completed, - ) - .length; - } - return 0; + final DateTime selectedDay = state.selectedDate!; + return state.orders + .where( + (OrderItem s) => + _isSameDay(s.date, selectedDay) && s.status == targetStatus, + ) + .length; } int _calculateUpNextCount() { if (state.selectedDate == null) return 0; - - final String selectedDateStr = DateFormat( - 'yyyy-MM-dd', - ).format(state.selectedDate!); - + final DateTime selectedDay = state.selectedDate!; return state.orders .where( (OrderItem s) => - s.date == selectedDateStr && - [ - 'OPEN', - 'FILLED', - 'CONFIRMED', - 'PENDING', - 'ASSIGNED', + _isSameDay(s.date, selectedDay) && + [ + ShiftStatus.open, + ShiftStatus.pendingConfirmation, + ShiftStatus.assigned, ].contains(s.status), ) .length; diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart index db2d1ed6..fdb1cbc8 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart @@ -1,655 +1,163 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; -import 'package:firebase_auth/firebase_auth.dart' as firebase; -import 'package:firebase_data_connect/firebase_data_connect.dart'; import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_domain/krow_domain.dart'; -class _RoleOption { - const _RoleOption({ - required this.id, - required this.name, - required this.costPerHour, - }); +import '../../domain/repositories/i_view_orders_repository.dart'; - final String id; - final String name; - final double costPerHour; -} - -class _ShiftRoleKey { - const _ShiftRoleKey({required this.shiftId, required this.roleId}); - - final String shiftId; - final String roleId; -} - -/// A sophisticated bottom sheet for editing an existing order, -/// following the Unified Order Flow prototype and matching OneTimeOrderView. +/// Bottom sheet for editing an existing order via the V2 API. +/// +/// Delegates all backend calls through [IViewOrdersRepository]. +/// The V2 `clientOrderEdit` endpoint creates an edited copy. class OrderEditSheet extends StatefulWidget { + /// Creates an [OrderEditSheet] for the given [order]. const OrderEditSheet({required this.order, this.onUpdated, super.key}); + /// The order item to edit. final OrderItem order; + + /// Called after the edit is saved successfully. final VoidCallback? onUpdated; @override State createState() => OrderEditSheetState(); } +/// State for [OrderEditSheet]. class OrderEditSheetState extends State { bool _showReview = false; bool _isLoading = false; + bool _isSuccess = false; - late TextEditingController _dateController; - late TextEditingController _globalLocationController; late TextEditingController _orderNameController; - late List> _positions; - final dc.ExampleConnector _dataConnect = dc.ExampleConnector.instance; - final firebase.FirebaseAuth _firebaseAuth = firebase.FirebaseAuth.instance; - List _vendors = const []; Vendor? _selectedVendor; - List<_RoleOption> _roles = const <_RoleOption>[]; - List _hubs = - const []; - dc.ListTeamHubsByOwnerIdTeamHubs? _selectedHub; + List> _roles = const >[]; + List> _hubs = const >[]; + Map? _selectedHub; - List _managers = const []; - dc.ListTeamMembersTeamMembers? _selectedManager; - - String? _shiftId; - List<_ShiftRoleKey> _originalShiftRoles = const <_ShiftRoleKey>[]; + late IViewOrdersRepository _repository; @override void initState() { super.initState(); - _dateController = TextEditingController(text: widget.order.date); - _globalLocationController = TextEditingController( - text: widget.order.locationAddress, - ); - _orderNameController = TextEditingController(); + _repository = Modular.get(); + _orderNameController = TextEditingController(text: widget.order.roleName); + + final String startHH = + widget.order.startsAt.toLocal().hour.toString().padLeft(2, '0'); + final String startMM = + widget.order.startsAt.toLocal().minute.toString().padLeft(2, '0'); + final String endHH = + widget.order.endsAt.toLocal().hour.toString().padLeft(2, '0'); + final String endMM = + widget.order.endsAt.toLocal().minute.toString().padLeft(2, '0'); _positions = >[ { - 'shiftId': null, - 'roleId': '', - 'roleName': '', - 'originalRoleId': null, - 'count': widget.order.workersNeeded, - 'start_time': widget.order.startTime, - 'end_time': widget.order.endTime, - 'lunch_break': 'NO_BREAK', - 'location': null, + 'roleName': widget.order.roleName, + 'workerCount': widget.order.requiredWorkerCount, + 'startTime': '$startHH:$startMM', + 'endTime': '$endHH:$endMM', + 'hourlyRateCents': widget.order.hourlyRateCents, }, ]; - _loadOrderDetails(); + _loadReferenceData(); } @override void dispose() { - _dateController.dispose(); - _globalLocationController.dispose(); _orderNameController.dispose(); super.dispose(); } - Future _loadOrderDetails() async { - final String? businessId = - dc.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - await _firebaseAuth.signOut(); - return; - } - - if (widget.order.orderId.isEmpty) { - return; - } - + /// Loads vendors, hubs, roles for the edit form. + Future _loadReferenceData() async { try { - final QueryResult< - dc.ListShiftRolesByBusinessAndOrderData, - dc.ListShiftRolesByBusinessAndOrderVariables - > - result = await _dataConnect - .listShiftRolesByBusinessAndOrder( - businessId: businessId, - orderId: widget.order.orderId, - ) - .execute(); - - final List shiftRoles = - result.data.shiftRoles; - if (shiftRoles.isEmpty) { - await _loadHubsAndSelect(); - return; - } - - final dc.ListShiftRolesByBusinessAndOrderShiftRolesShift firstShift = - shiftRoles.first.shift; - final DateTime? orderDate = firstShift.order.date?.toDateTime(); - final String dateText = orderDate == null - ? widget.order.date - : DateFormat('yyyy-MM-dd').format(orderDate); - final String location = firstShift.order.teamHub.hubName; - - _dateController.text = dateText; - _globalLocationController.text = location; - _orderNameController.text = firstShift.order.eventName ?? ''; - _shiftId = shiftRoles.first.shiftId; - - final List> positions = shiftRoles.map(( - dc.ListShiftRolesByBusinessAndOrderShiftRoles role, - ) { - return { - 'shiftId': role.shiftId, - 'roleId': role.roleId, - 'roleName': role.role.name, - 'originalRoleId': role.roleId, - 'count': role.count, - 'start_time': _formatTimeForField(role.startTime), - 'end_time': _formatTimeForField(role.endTime), - 'lunch_break': _breakValueFromDuration(role.breakType), - 'location': null, - }; - }).toList(); - - if (positions.isEmpty) { - positions.add(_emptyPosition()); - } - - final List<_ShiftRoleKey> originalShiftRoles = shiftRoles - .map( - (dc.ListShiftRolesByBusinessAndOrderShiftRoles role) => - _ShiftRoleKey(shiftId: role.shiftId, roleId: role.roleId), - ) - .toList(); - - await _loadVendorsAndSelect(firstShift.order.vendorId); - final dc.ListShiftRolesByBusinessAndOrderShiftRolesShiftOrderTeamHub - teamHub = firstShift.order.teamHub; - await _loadHubsAndSelect( - placeId: teamHub.placeId, - hubName: teamHub.hubName, - address: teamHub.address, - ); - - if (mounted) { - setState(() { - _positions = positions; - _originalShiftRoles = originalShiftRoles; - }); - } - } catch (_) { - // Keep current state on failure. - } - } - - Future _loadHubsAndSelect({ - String? placeId, - String? hubName, - String? address, - }) async { - final String? businessId = - dc.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - return; - } - - try { - final QueryResult< - dc.ListTeamHubsByOwnerIdData, - dc.ListTeamHubsByOwnerIdVariables - > - result = await _dataConnect - .listTeamHubsByOwnerId(ownerId: businessId) - .execute(); - - final List hubs = result.data.teamHubs; - dc.ListTeamHubsByOwnerIdTeamHubs? selected; - - if (placeId != null && placeId.isNotEmpty) { - for (final dc.ListTeamHubsByOwnerIdTeamHubs hub in hubs) { - if (hub.placeId == placeId) { - selected = hub; - break; - } - } - } - - if (selected == null && hubName != null && hubName.isNotEmpty) { - for (final dc.ListTeamHubsByOwnerIdTeamHubs hub in hubs) { - if (hub.hubName == hubName) { - selected = hub; - break; - } - } - } - - if (selected == null && address != null && address.isNotEmpty) { - for (final dc.ListTeamHubsByOwnerIdTeamHubs hub in hubs) { - if (hub.address == address) { - selected = hub; - break; - } - } - } - - selected ??= hubs.isNotEmpty ? hubs.first : null; - - if (mounted) { - setState(() { - _hubs = hubs; - _selectedHub = selected; - if (selected != null) { - _globalLocationController.text = selected.address; - } - }); - } - if (selected != null) { - await _loadManagersForHub(selected.id, widget.order.hubManagerId); - } - } catch (_) { - if (mounted) { - setState(() { - _hubs = const []; - _selectedHub = null; - }); - } - } - } - - Future _loadVendorsAndSelect(String? selectedVendorId) async { - try { - final QueryResult result = await _dataConnect - .listVendors() - .execute(); - final List vendors = result.data.vendors - .map( - (dc.ListVendorsVendors vendor) => Vendor( - id: vendor.id, - name: vendor.companyName, - rates: const {}, - ), - ) - .toList(); - - Vendor? selectedVendor; - if (selectedVendorId != null && selectedVendorId.isNotEmpty) { - for (final Vendor vendor in vendors) { - if (vendor.id == selectedVendorId) { - selectedVendor = vendor; - break; - } - } - } - selectedVendor ??= vendors.isNotEmpty ? vendors.first : null; - + final List vendors = await _repository.getVendors(); + final List> hubs = await _repository.getHubs(); if (mounted) { setState(() { _vendors = vendors; - _selectedVendor = selectedVendor; + _selectedVendor = vendors.isNotEmpty ? vendors.first : null; + _hubs = hubs; + if (hubs.isNotEmpty) { + // Try to match current location + final Map? matched = hubs.cast?>().firstWhere( + (Map? h) => + h != null && + (h['hubName'] as String? ?? h['name'] as String? ?? '') == + widget.order.locationName, + orElse: () => null, + ); + _selectedHub = matched ?? hubs.first; + } }); - } - - if (selectedVendor != null) { - await _loadRolesForVendor(selectedVendor.id); + if (_selectedVendor != null) { + await _loadRolesForVendor(_selectedVendor!.id); + } + // Hub manager loading is available but not wired into the UI yet. } } catch (_) { - if (mounted) { - setState(() { - _vendors = const []; - _selectedVendor = null; - _roles = const <_RoleOption>[]; - }); - } + // Keep defaults on failure } } Future _loadRolesForVendor(String vendorId) async { try { - final QueryResult< - dc.ListRolesByVendorIdData, - dc.ListRolesByVendorIdVariables - > - result = await _dataConnect - .listRolesByVendorId(vendorId: vendorId) - .execute(); - final List<_RoleOption> roles = result.data.roles - .map( - (dc.ListRolesByVendorIdRoles role) => _RoleOption( - id: role.id, - name: role.name, - costPerHour: role.costPerHour, - ), - ) - .toList(); + final List> roles = + await _repository.getRolesByVendor(vendorId); if (mounted) { setState(() => _roles = roles); } } catch (_) { - if (mounted) { - setState(() => _roles = const <_RoleOption>[]); - } + if (mounted) setState(() => _roles = const >[]); } } - Future _loadManagersForHub(String hubId, [String? preselectedId]) async { - try { - final QueryResult result = - await _dataConnect.listTeamMembers().execute(); - - final List hubManagers = result.data.teamMembers - .where( - (dc.ListTeamMembersTeamMembers member) => - member.teamHubId == hubId && - member.role is dc.Known && - (member.role as dc.Known).value == - dc.TeamMemberRole.MANAGER, - ) - .toList(); - - dc.ListTeamMembersTeamMembers? selected; - if (preselectedId != null && preselectedId.isNotEmpty) { - for (final dc.ListTeamMembersTeamMembers m in hubManagers) { - if (m.id == preselectedId) { - selected = m; - break; - } - } - } - - if (mounted) { - setState(() { - _managers = hubManagers; - _selectedManager = selected; - }); - } - } catch (_) { - if (mounted) { - setState(() { - _managers = const []; - _selectedManager = null; - }); - } - } - } - - Map _emptyPosition() { - return { - 'shiftId': _shiftId, - 'roleId': '', - 'roleName': '', - 'originalRoleId': null, - 'count': 1, - 'start_time': '09:00', - 'end_time': '17:00', - 'lunch_break': 'NO_BREAK', - 'location': null, - }; - } - - String _formatTimeForField(Timestamp? value) { - if (value == null) return ''; - try { - return DateFormat('HH:mm').format(value.toDateTime().toLocal()); - } catch (_) { - return ''; - } - } - - String _breakValueFromDuration(dc.EnumValue? breakType) { - final dc.BreakDuration? value = breakType is dc.Known - ? breakType.value - : null; - switch (value) { - case dc.BreakDuration.MIN_10: - return 'MIN_10'; - case dc.BreakDuration.MIN_15: - return 'MIN_15'; - case dc.BreakDuration.MIN_30: - return 'MIN_30'; - case dc.BreakDuration.MIN_45: - return 'MIN_45'; - case dc.BreakDuration.MIN_60: - return 'MIN_60'; - case dc.BreakDuration.NO_BREAK: - case null: - return 'NO_BREAK'; - } - } - - dc.BreakDuration _breakDurationFromValue(String value) { - switch (value) { - case 'MIN_10': - return dc.BreakDuration.MIN_10; - case 'MIN_15': - return dc.BreakDuration.MIN_15; - case 'MIN_30': - return dc.BreakDuration.MIN_30; - case 'MIN_45': - return dc.BreakDuration.MIN_45; - case 'MIN_60': - return dc.BreakDuration.MIN_60; - default: - return dc.BreakDuration.NO_BREAK; - } - } - - bool _isBreakPaid(String value) { - return value == 'MIN_10' || value == 'MIN_15'; - } - - _RoleOption? _roleById(String roleId) { - for (final _RoleOption role in _roles) { - if (role.id == roleId) { - return role; - } - } - return null; - } - - double _rateForRole(String roleId) { - return _roleById(roleId)?.costPerHour ?? 0; - } - - DateTime _parseDate(String value) { - try { - return DateFormat('yyyy-MM-dd').parse(value); - } catch (_) { - return DateTime.now(); - } - } - - DateTime _parseTime(DateTime date, String time) { - if (time.trim().isEmpty) { - throw Exception('Shift time is missing.'); - } - - DateTime parsed; - try { - parsed = DateFormat.Hm().parse(time); - } catch (_) { - parsed = DateFormat.jm().parse(time); - } - - return DateTime( - date.year, - date.month, - date.day, - parsed.hour, - parsed.minute, - ); - } - - Timestamp _toTimestamp(DateTime date) { - final DateTime utc = date.toUtc(); - final int millis = utc.millisecondsSinceEpoch; - final int seconds = millis ~/ 1000; - final int nanos = (millis % 1000) * 1000000; - return Timestamp(nanos, seconds); - } - - double _calculateTotalCost() { - double total = 0; - for (final Map pos in _positions) { - final String roleId = pos['roleId']?.toString() ?? ''; - if (roleId.isEmpty) { - continue; - } - final DateTime date = _parseDate(_dateController.text); - final DateTime start = _parseTime(date, pos['start_time'].toString()); - final DateTime end = _parseTime(date, pos['end_time'].toString()); - final DateTime normalizedEnd = end.isBefore(start) - ? end.add(const Duration(days: 1)) - : end; - final double hours = normalizedEnd.difference(start).inMinutes / 60.0; - final double rate = _rateForRole(roleId); - final int count = pos['count'] as int; - total += rate * hours * count; - } - return total; - } + /// Saves the edited order via V2 API. Future _saveOrderChanges() async { - if (_shiftId == null || _shiftId!.isEmpty) { - return; - } + final String hubId = + _selectedHub?['hubId'] as String? ?? _selectedHub?['id'] as String? ?? ''; - final String? businessId = - dc.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - await _firebaseAuth.signOut(); - return; - } + final List> positionsPayload = _positions + .map((Map pos) => { + 'roleName': pos['roleName'] as String? ?? '', + 'workerCount': pos['workerCount'] as int? ?? 1, + 'startTime': pos['startTime'] as String? ?? '09:00', + 'endTime': pos['endTime'] as String? ?? '17:00', + if ((pos['hourlyRateCents'] as int?) != null) + 'billRateCents': pos['hourlyRateCents'] as int, + }) + .toList(); - final DateTime orderDate = _parseDate(_dateController.text); - final dc.ListTeamHubsByOwnerIdTeamHubs? selectedHub = _selectedHub; - if (selectedHub == null) { - return; - } + final Map payload = { + if (_orderNameController.text.isNotEmpty) + 'eventName': _orderNameController.text, + if (hubId.isNotEmpty) 'hubId': hubId, + 'positions': positionsPayload, + }; - int totalWorkers = 0; - double shiftCost = 0; - - final List<_ShiftRoleKey> remainingOriginal = List<_ShiftRoleKey>.from( - _originalShiftRoles, + await _repository.editOrder( + orderId: widget.order.orderId, + payload: payload, ); - - for (final Map pos in _positions) { - final String roleId = pos['roleId']?.toString() ?? ''; - if (roleId.isEmpty) { - continue; - } - - final String shiftId = pos['shiftId']?.toString() ?? _shiftId!; - final int count = pos['count'] as int; - final DateTime start = _parseTime( - orderDate, - pos['start_time'].toString(), - ); - final DateTime end = _parseTime(orderDate, pos['end_time'].toString()); - final DateTime normalizedEnd = end.isBefore(start) - ? end.add(const Duration(days: 1)) - : end; - final double hours = normalizedEnd.difference(start).inMinutes / 60.0; - final double rate = _rateForRole(roleId); - final double totalValue = rate * hours * count; - final String lunchBreak = pos['lunch_break'] as String; - - totalWorkers += count; - shiftCost += totalValue; - - final String? originalRoleId = pos['originalRoleId']?.toString(); - remainingOriginal.removeWhere( - (_ShiftRoleKey key) => - key.shiftId == shiftId && key.roleId == originalRoleId, - ); - - if (originalRoleId != null && originalRoleId.isNotEmpty) { - if (originalRoleId != roleId) { - await _dataConnect - .deleteShiftRole(shiftId: shiftId, roleId: originalRoleId) - .execute(); - await _dataConnect - .createShiftRole(shiftId: shiftId, roleId: roleId, count: count) - .startTime(_toTimestamp(start)) - .endTime(_toTimestamp(normalizedEnd)) - .hours(hours) - .breakType(_breakDurationFromValue(lunchBreak)) - .isBreakPaid(_isBreakPaid(lunchBreak)) - .totalValue(totalValue) - .execute(); - } else { - await _dataConnect - .updateShiftRole(shiftId: shiftId, roleId: roleId) - .count(count) - .startTime(_toTimestamp(start)) - .endTime(_toTimestamp(normalizedEnd)) - .hours(hours) - .breakType(_breakDurationFromValue(lunchBreak)) - .isBreakPaid(_isBreakPaid(lunchBreak)) - .totalValue(totalValue) - .execute(); - } - } else { - await _dataConnect - .createShiftRole(shiftId: shiftId, roleId: roleId, count: count) - .startTime(_toTimestamp(start)) - .endTime(_toTimestamp(normalizedEnd)) - .hours(hours) - .breakType(_breakDurationFromValue(lunchBreak)) - .isBreakPaid(_isBreakPaid(lunchBreak)) - .totalValue(totalValue) - .execute(); - } - } - - for (final _ShiftRoleKey key in remainingOriginal) { - await _dataConnect - .deleteShiftRole(shiftId: key.shiftId, roleId: key.roleId) - .execute(); - } - - final DateTime orderDateOnly = DateTime( - orderDate.year, - orderDate.month, - orderDate.day, - ); - - await _dataConnect - .updateOrder(id: widget.order.orderId, teamHubId: selectedHub.id) - .vendorId(_selectedVendor?.id) - .date(_toTimestamp(orderDateOnly)) - .eventName(_orderNameController.text) - .execute(); - - await _dataConnect - .updateShift(id: _shiftId!) - .title('shift 1 ${DateFormat('yyyy-MM-dd').format(orderDate)}') - .date(_toTimestamp(orderDateOnly)) - .location(selectedHub.hubName) - .locationAddress(selectedHub.address) - .latitude(selectedHub.latitude) - .longitude(selectedHub.longitude) - .placeId(selectedHub.placeId) - .city(selectedHub.city) - .state(selectedHub.state) - .street(selectedHub.street) - .country(selectedHub.country) - .workersNeeded(totalWorkers) - .cost(shiftCost) - .durationDays(1) - .execute(); } void _addPosition() { setState(() { - _positions.add(_emptyPosition()); + _positions.add({ + 'roleName': '', + 'workerCount': 1, + 'startTime': '09:00', + 'endTime': '17:00', + 'hourlyRateCents': 0, + }); }); } @@ -663,15 +171,76 @@ class OrderEditSheetState extends State { setState(() => _positions[index][key] = value); } + double _calculateTotalCost() { + double total = 0; + for (final Map pos in _positions) { + final int rateCents = pos['hourlyRateCents'] as int? ?? 0; + final int count = pos['workerCount'] as int? ?? 1; + final String startTime = pos['startTime'] as String? ?? '09:00'; + final String endTime = pos['endTime'] as String? ?? '17:00'; + final double hours = _computeHours(startTime, endTime); + total += (rateCents / 100.0) * hours * count; + } + return total; + } + + double _computeHours(String startTime, String endTime) { + try { + final List startParts = startTime.split(':'); + final List endParts = endTime.split(':'); + final int startMinutes = + int.parse(startParts[0]) * 60 + int.parse(startParts[1]); + int endMinutes = int.parse(endParts[0]) * 60 + int.parse(endParts[1]); + if (endMinutes <= startMinutes) endMinutes += 24 * 60; + return (endMinutes - startMinutes) / 60.0; + } catch (_) { + return 8.0; + } + } + @override Widget build(BuildContext context) { - if (_isLoading && _showReview) { - return _buildSuccessView(); + if (_isSuccess) return _buildSuccessView(); + if (_isLoading) { + return Container( + height: MediaQuery.of(context).size.height * 0.95, + decoration: const BoxDecoration( + color: UiColors.bgPrimary, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: const Center(child: CircularProgressIndicator()), + ); } - return _showReview ? _buildReviewView() : _buildFormView(); } + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + vertical: UiConstants.space4, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SizedBox(width: 24), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: UiColors.border, + borderRadius: UiConstants.radiusFull, + ), + ), + GestureDetector( + onTap: () => Navigator.pop(context), + child: const Icon(UiIcons.close, size: 24), + ), + ], + ), + ); + } + Widget _buildFormView() { return Container( height: MediaQuery.of(context).size.height * 0.95, @@ -722,7 +291,7 @@ class OrderEditSheetState extends State { return DropdownMenuItem( value: vendor, child: Text( - vendor.name, + vendor.companyName, style: UiTypography.body2m.textPrimary, ), ); @@ -732,20 +301,11 @@ class OrderEditSheetState extends State { ), const SizedBox(height: UiConstants.space4), - _buildSectionHeader('DATE'), - UiTextField( - controller: _dateController, - hintText: 'mm/dd/yyyy', - prefixIcon: UiIcons.calendar, - readOnly: true, - onTap: () {}, - ), - const SizedBox(height: UiConstants.space4), - _buildSectionHeader(t.client_orders_common.order_name), UiTextField( controller: _orderNameController, - hintText: t.client_view_orders.order_edit_sheet.order_name_hint, + hintText: t.client_view_orders.order_edit_sheet + .order_name_hint, prefixIcon: UiIcons.briefcase, ), const SizedBox(height: UiConstants.space4), @@ -762,7 +322,7 @@ class OrderEditSheetState extends State { border: Border.all(color: UiColors.border), ), child: DropdownButtonHideUnderline( - child: DropdownButton( + child: DropdownButton>( isExpanded: true, value: _selectedHub, icon: const Icon( @@ -770,21 +330,18 @@ class OrderEditSheetState extends State { size: 18, color: UiColors.iconSecondary, ), - onChanged: (dc.ListTeamHubsByOwnerIdTeamHubs? hub) { + onChanged: (Map? hub) { if (hub != null) { - setState(() { - _selectedHub = hub; - _globalLocationController.text = hub.address; - }); + setState(() => _selectedHub = hub); } }, - items: _hubs.map((dc.ListTeamHubsByOwnerIdTeamHubs hub) { - return DropdownMenuItem< - dc.ListTeamHubsByOwnerIdTeamHubs - >( + items: _hubs.map((Map hub) { + final String name = + hub['hubName'] as String? ?? hub['name'] as String? ?? ''; + return DropdownMenuItem>( value: hub, child: Text( - hub.hubName, + name, style: UiTypography.body2m.textPrimary, ), ); @@ -792,10 +349,6 @@ class OrderEditSheetState extends State { ), ), ), - const SizedBox(height: UiConstants.space4), - - _buildHubManagerSelector(), - const SizedBox(height: UiConstants.space6), Row( @@ -843,210 +396,25 @@ class OrderEditSheetState extends State { ), ), _buildBottomAction( - label: t.client_view_orders.order_edit_sheet.review_positions(count: _positions.length.toString()), + label: t.client_view_orders.order_edit_sheet + .review_positions(count: _positions.length.toString()), onPressed: () => setState(() => _showReview = true), ), - const Padding( - padding: EdgeInsets.fromLTRB( - UiConstants.space5, - 0, - UiConstants.space5, - 0, - ), - ), ], ), ); } - Widget _buildHubManagerSelector() { - final TranslationsClientViewOrdersOrderEditSheetEn oes = - t.client_view_orders.order_edit_sheet; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSectionHeader(oes.shift_contact_section), - Text(oes.shift_contact_desc, style: UiTypography.body2r.textSecondary), - const SizedBox(height: UiConstants.space2), - InkWell( - onTap: () => _showHubManagerSelector(), - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space4, - vertical: 14, - ), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all( - color: _selectedManager != null ? UiColors.primary : UiColors.border, - width: _selectedManager != null ? 2 : 1, - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon( - UiIcons.user, - color: _selectedManager != null - ? UiColors.primary - : UiColors.iconSecondary, - size: 20, - ), - const SizedBox(width: UiConstants.space3), - Text( - _selectedManager?.user.fullName ?? oes.select_contact, - style: _selectedManager != null - ? UiTypography.body1r.textPrimary - : UiTypography.body2r.textPlaceholder, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - const Icon( - Icons.keyboard_arrow_down, - color: UiColors.iconSecondary, - ), - ], - ), - ), - ), - ], - ); - } - - Future _showHubManagerSelector() async { - final dc.ListTeamMembersTeamMembers? selected = await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - ), - title: Text( - t.client_view_orders.order_edit_sheet.shift_contact_section, - style: UiTypography.headline3m.textPrimary, - ), - contentPadding: const EdgeInsets.symmetric(vertical: 16), - content: SizedBox( - width: double.maxFinite, - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 400), - child: ListView.builder( - shrinkWrap: true, - itemCount: _managers.isEmpty ? 2 : _managers.length + 1, - itemBuilder: (BuildContext context, int index) { - if (_managers.isEmpty) { - if (index == 0) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), - child: Text(t.client_view_orders.order_edit_sheet.no_hub_managers), - ); - } - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - title: Text(t.client_view_orders.order_edit_sheet.none, style: UiTypography.body1m.textSecondary), - onTap: () => Navigator.of(context).pop(null), - ); - } - - if (index == _managers.length) { - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - title: Text(t.client_view_orders.order_edit_sheet.none, style: UiTypography.body1m.textSecondary), - onTap: () => Navigator.of(context).pop(null), - ); - } - final dc.ListTeamMembersTeamMembers manager = _managers[index]; - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), - title: Text(manager.user.fullName ?? 'Unknown', style: UiTypography.body1m.textPrimary), - onTap: () => Navigator.of(context).pop(manager), - ); - }, - ), - ), - ), - ); - }, - ); - - if (mounted) { - if (selected == null && _managers.isEmpty) { - // Tapped outside or selected None - setState(() => _selectedManager = null); - } else { - setState(() => _selectedManager = selected); - } - } - } - - Widget _buildHeader() { - return Container( - padding: const EdgeInsets.fromLTRB(20, 24, 20, 20), - decoration: const BoxDecoration( - color: UiColors.primary, - borderRadius: BorderRadius.vertical( - top: Radius.circular(UiConstants.space6), - ), - ), - child: Row( - children: [ - GestureDetector( - onTap: () => Navigator.pop(context), - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: UiColors.white.withValues(alpha: 0.2), - borderRadius: UiConstants.radiusMd, - ), - child: const Icon( - UiIcons.chevronLeft, - color: UiColors.white, - size: 24, - ), - ), - ), - const SizedBox(width: UiConstants.space3), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - t.client_view_orders.order_edit_sheet.one_time_order_title, - style: UiTypography.headline3m.copyWith(color: UiColors.white), - ), - Text( - t.client_view_orders.order_edit_sheet.refine_subtitle, - style: UiTypography.footnote2r.copyWith( - color: UiColors.white.withValues(alpha: 0.8), - ), - ), - ], - ), - ], - ), - ); - } - - Widget _buildSectionHeader(String title) { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text(title, style: UiTypography.footnote2r.textSecondary), - ); - } - Widget _buildPositionCard(int index, Map pos) { + final String roleName = pos['roleName'] as String? ?? ''; + final int workerCount = pos['workerCount'] as int? ?? 1; + return Container( - margin: const EdgeInsets.only(bottom: UiConstants.space3), + margin: const EdgeInsets.only(bottom: UiConstants.space4), padding: const EdgeInsets.all(UiConstants.space4), decoration: BoxDecoration( color: UiColors.white, - borderRadius: UiConstants.radiusLg, + borderRadius: UiConstants.radiusMd, border: Border.all(color: UiColors.border), ), child: Column( @@ -1056,249 +424,131 @@ class OrderEditSheetState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'POSITION #${index + 1}', - style: UiTypography.footnote1m.textSecondary, + '${t.client_view_orders.order_edit_sheet.position_singular} ${index + 1}', + style: UiTypography.body2b.textPrimary, ), if (_positions.length > 1) GestureDetector( onTap: () => _removePosition(index), - child: Text( - t.client_view_orders.order_edit_sheet.remove, - style: UiTypography.footnote1m.copyWith( - color: UiColors.destructive, - ), + child: const Icon( + UiIcons.close, + size: 16, + color: UiColors.destructive, ), ), ], ), const SizedBox(height: UiConstants.space3), - _buildDropdownField( - hint: t.client_view_orders.order_edit_sheet.select_role_hint, - value: pos['roleId'], - items: [ - ..._roles.map((_RoleOption role) => role.id), - if (pos['roleId'] != null && - pos['roleId'].toString().isNotEmpty && - !_roles.any( - (_RoleOption role) => role.id == pos['roleId'].toString(), - )) - pos['roleId'].toString(), - ], - itemBuilder: (dynamic roleId) { - final _RoleOption? role = _roleById(roleId.toString()); - if (role == null) { - final String fallback = pos['roleName']?.toString() ?? ''; - return fallback.isEmpty ? roleId.toString() : fallback; - } - return '${role.name} - \$${role.costPerHour.toStringAsFixed(0)}/hr'; - }, + // Role selector + _buildSectionHeader('ROLE'), + _buildDropdown( + hint: 'Select role', + value: roleName.isNotEmpty ? roleName : null, + items: _roles + .map((Map r) => r['roleName'] as String? ?? r['name'] as String? ?? '') + .where((String name) => name.isNotEmpty) + .toList(), onChanged: (dynamic val) { - final String roleId = val?.toString() ?? ''; - final _RoleOption? role = _roleById(roleId); - setState(() { - _positions[index]['roleId'] = roleId; - _positions[index]['roleName'] = role?.name ?? ''; - }); + final String selected = val as String; + final Map? matchedRole = _roles.cast?>().firstWhere( + (Map? r) => + r != null && + ((r['roleName'] as String? ?? r['name'] as String? ?? '') == selected), + orElse: () => null, + ); + _updatePosition(index, 'roleName', selected); + if (matchedRole != null) { + final int rateCents = + (matchedRole['billRateCents'] as num?)?.toInt() ?? 0; + _updatePosition(index, 'hourlyRateCents', rateCents); + } }, ), - const SizedBox(height: UiConstants.space3), + // Worker count + Row( + children: [ + Text( + t.client_create_order.one_time.workers_label, + style: UiTypography.footnote2r.textSecondary, + ), + const Spacer(), + IconButton( + icon: const Icon(UiIcons.minus, size: 16), + onPressed: workerCount > 1 + ? () => _updatePosition(index, 'workerCount', workerCount - 1) + : null, + ), + Text('$workerCount', style: UiTypography.body2b), + IconButton( + icon: const Icon(UiIcons.add, size: 16), + onPressed: () => + _updatePosition(index, 'workerCount', workerCount + 1), + ), + ], + ), + + // Time inputs Row( children: [ Expanded( child: _buildInlineTimeInput( - label: t.client_view_orders.order_edit_sheet.start_label, - value: pos['start_time'], + label: 'Start Time', + value: pos['startTime'] as String? ?? '09:00', onTap: () async { final TimeOfDay? picked = await showTimePicker( context: context, - initialTime: TimeOfDay.now(), + initialTime: const TimeOfDay(hour: 9, minute: 0), ); - if (picked != null && mounted) { - _updatePosition( - index, - 'start_time', - picked.format(context), - ); + if (picked != null) { + final String time = + '${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}'; + _updatePosition(index, 'startTime', time); } }, ), ), - const SizedBox(width: UiConstants.space2), + const SizedBox(width: UiConstants.space3), Expanded( child: _buildInlineTimeInput( - label: t.client_view_orders.order_edit_sheet.end_label, - value: pos['end_time'], + label: 'End Time', + value: pos['endTime'] as String? ?? '17:00', onTap: () async { final TimeOfDay? picked = await showTimePicker( context: context, - initialTime: TimeOfDay.now(), + initialTime: const TimeOfDay(hour: 17, minute: 0), ); - if (picked != null && mounted) { - _updatePosition( - index, - 'end_time', - picked.format(context), - ); + if (picked != null) { + final String time = + '${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}'; + _updatePosition(index, 'endTime', time); } }, ), ), - const SizedBox(width: UiConstants.space2), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - t.client_view_orders.order_edit_sheet.workers_label, - style: UiTypography.footnote2r.textSecondary, - ), - const SizedBox(height: UiConstants.space1), - Container( - height: 40, - decoration: BoxDecoration( - color: UiColors.bgSecondary, - borderRadius: UiConstants.radiusSm, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - GestureDetector( - onTap: () { - if ((pos['count'] as int) > 1) { - _updatePosition( - index, - 'count', - (pos['count'] as int) - 1, - ); - } - }, - child: const Icon(UiIcons.minus, size: 12), - ), - Text( - '${pos['count']}', - style: UiTypography.body2b.textPrimary, - ), - GestureDetector( - onTap: () => _updatePosition( - index, - 'count', - (pos['count'] as int) + 1, - ), - child: const Icon(UiIcons.add, size: 12), - ), - ], - ), - ), - ], - ), - ), ], ), - const SizedBox(height: UiConstants.space4), - - if (pos['location'] == null) - GestureDetector( - onTap: () => _updatePosition(index, 'location', ''), - child: Row( - children: [ - const Icon(UiIcons.mapPin, size: 14, color: UiColors.primary), - const SizedBox(width: UiConstants.space1), - Text( - t.client_view_orders.order_edit_sheet.different_location, - style: UiTypography.footnote1m.copyWith( - color: UiColors.primary, - ), - ), - ], - ), - ) - else - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - const Icon( - UiIcons.mapPin, - size: 14, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space1), - Text( - t.client_view_orders.order_edit_sheet.different_location_title, - style: UiTypography.footnote1m.textSecondary, - ), - ], - ), - GestureDetector( - onTap: () => _updatePosition(index, 'location', null), - child: const Icon( - UiIcons.close, - size: 14, - color: UiColors.destructive, - ), - ), - ], - ), - const SizedBox(height: UiConstants.space2), - UiTextField( - controller: TextEditingController(text: pos['location']), - hintText: t.client_view_orders.order_edit_sheet.enter_address_hint, - onChanged: (String val) => - _updatePosition(index, 'location', val), - ), - ], - ), - - const SizedBox(height: UiConstants.space3), - - _buildSectionHeader('LUNCH BREAK'), - _buildDropdownField( - hint: t.client_view_orders.order_edit_sheet.no_break, - value: pos['lunch_break'], - items: [ - 'NO_BREAK', - 'MIN_10', - 'MIN_15', - 'MIN_30', - 'MIN_45', - 'MIN_60', - ], - itemBuilder: (dynamic val) { - switch (val.toString()) { - case 'MIN_10': - return '10 min (Paid)'; - case 'MIN_15': - return '15 min (Paid)'; - case 'MIN_30': - return '30 min (Unpaid)'; - case 'MIN_45': - return '45 min (Unpaid)'; - case 'MIN_60': - return '60 min (Unpaid)'; - default: - return t.client_view_orders.order_edit_sheet.no_break; - } - }, - onChanged: (dynamic val) => - _updatePosition(index, 'lunch_break', val), - ), ], ), ); } - Widget _buildDropdownField({ + Widget _buildSectionHeader(String label) { + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space2), + child: Text( + label.toUpperCase(), + style: UiTypography.titleUppercase4m.textSecondary, + ), + ); + } + + Widget _buildDropdown({ required String hint, - required dynamic value, + dynamic value, required List items, - String Function(dynamic)? itemBuilder, required ValueChanged onChanged, }) { return Container( @@ -1312,7 +562,7 @@ class OrderEditSheetState extends State { child: DropdownButton( isExpanded: true, hint: Text(hint, style: UiTypography.body2r.textPlaceholder), - value: value == '' || value == null ? null : value, + value: (value == '' || value == null) ? null : value, icon: const Icon( UiIcons.chevronDown, size: 18, @@ -1320,11 +570,12 @@ class OrderEditSheetState extends State { ), onChanged: onChanged, items: items.toSet().map((dynamic item) { - String label = item.toString(); - if (itemBuilder != null) label = itemBuilder(item); return DropdownMenuItem( value: item, - child: Text(label, style: UiTypography.body2r.textPrimary), + child: Text( + item.toString(), + style: UiTypography.body2r.textPrimary, + ), ); }).toList(), ), @@ -1346,7 +597,8 @@ class OrderEditSheetState extends State { onTap: onTap, child: Container( height: 40, - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + padding: + const EdgeInsets.symmetric(horizontal: UiConstants.space3), decoration: BoxDecoration( borderRadius: UiConstants.radiusSm, border: Border.all(color: UiColors.border), @@ -1400,7 +652,7 @@ class OrderEditSheetState extends State { Widget _buildReviewView() { final int totalWorkers = _positions.fold( 0, - (int sum, Map p) => sum + (p['count'] as int), + (int sum, Map p) => sum + (p['workerCount'] as int? ?? 1), ); final double totalCost = _calculateTotalCost(); @@ -1441,8 +693,14 @@ class OrderEditSheetState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - _buildSummaryItem('${_positions.length}', t.client_view_orders.order_edit_sheet.positions), - _buildSummaryItem('$totalWorkers', t.client_view_orders.order_edit_sheet.workers), + _buildSummaryItem( + '${_positions.length}', + t.client_view_orders.order_edit_sheet.positions, + ), + _buildSummaryItem( + '$totalWorkers', + t.client_view_orders.order_edit_sheet.workers, + ), _buildSummaryItem( '\$${totalCost.round()}', t.client_view_orders.order_edit_sheet.est_cost, @@ -1450,57 +708,6 @@ class OrderEditSheetState extends State { ], ), ), - const SizedBox(height: 20), - - // Order Details - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: UiColors.separatorPrimary), - ), - child: Column( - children: [ - Row( - children: [ - const Icon( - UiIcons.calendar, - size: 16, - color: UiColors.primary, - ), - const SizedBox(width: 8), - Text( - _dateController.text, - style: UiTypography.body2m.textPrimary, - ), - ], - ), - if (_globalLocationController - .text - .isNotEmpty) ...[ - const SizedBox(height: 12), - Row( - children: [ - const Icon( - UiIcons.mapPin, - size: 16, - color: UiColors.primary, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _globalLocationController.text, - style: UiTypography.body2r.textPrimary, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ], - ], - ), - ), const SizedBox(height: 24), Text( @@ -1510,7 +717,8 @@ class OrderEditSheetState extends State { const SizedBox(height: 12), ..._positions.map( - (Map pos) => _buildReviewPositionCard(pos), + (Map pos) => + _buildReviewPositionCard(pos), ), const SizedBox(height: 40), @@ -1545,10 +753,17 @@ class OrderEditSheetState extends State { text: t.client_view_orders.order_edit_sheet.confirm_save, onPressed: () async { setState(() => _isLoading = true); - await _saveOrderChanges(); - if (mounted) { - widget.onUpdated?.call(); - Navigator.pop(context); + try { + await _saveOrderChanges(); + if (mounted) { + setState(() { + _isLoading = false; + _isSuccess = true; + }); + widget.onUpdated?.call(); + } + } catch (_) { + if (mounted) setState(() => _isLoading = false); } }, ), @@ -1582,9 +797,9 @@ class OrderEditSheetState extends State { } Widget _buildReviewPositionCard(Map pos) { - final String roleId = pos['roleId']?.toString() ?? ''; - final _RoleOption? role = _roleById(roleId); - final double rate = role?.costPerHour ?? 0; + final String roleName = pos['roleName'] as String? ?? ''; + final int rateCents = pos['hourlyRateCents'] as int? ?? 0; + final double rate = rateCents / 100.0; return Container( margin: const EdgeInsets.only(bottom: 12), @@ -1603,13 +818,14 @@ class OrderEditSheetState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - (role?.name ?? pos['roleName']?.toString() ?? '').isEmpty - ? t.client_view_orders.order_edit_sheet.position_singular - : (role?.name ?? pos['roleName']?.toString() ?? ''), + roleName.isEmpty + ? t.client_view_orders.order_edit_sheet + .position_singular + : roleName, style: UiTypography.body2b.textPrimary, ), Text( - '${pos['count']} worker${pos['count'] > 1 ? 's' : ''}', + '${pos['workerCount']} worker${(pos['workerCount'] as int? ?? 1) > 1 ? 's' : ''}', style: UiTypography.footnote2r.textSecondary, ), ], @@ -1630,7 +846,7 @@ class OrderEditSheetState extends State { ), const SizedBox(width: 6), Text( - '${pos['start_time']} - ${pos['end_time']}', + '${pos['startTime']} - ${pos['endTime']}', style: UiTypography.footnote2r.textSecondary, ), ], diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart index ba60a932..fa9fdd1a 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart @@ -3,16 +3,12 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:url_launcher/url_launcher.dart'; - import '../blocs/view_orders_cubit.dart'; - -/// A rich card displaying details of a client order/shift. -/// -/// This widget complies with the KROW Design System by using -/// tokens from `package:design_system`. import 'order_edit_sheet.dart'; +/// A rich card displaying details of a V2 [OrderItem]. +/// +/// Uses DateTime-based fields and [AssignedWorkerSummary] workers list. class ViewOrderCard extends StatefulWidget { /// Creates a [ViewOrderCard] for the given [order]. const ViewOrderCard({required this.order, super.key}); @@ -41,18 +37,18 @@ class _ViewOrderCardState extends State { } /// Returns the semantic color for the given status. - Color _getStatusColor({required String status}) { + Color _getStatusColor({required ShiftStatus status}) { switch (status) { - case 'OPEN': + case ShiftStatus.open: return UiColors.primary; - case 'FILLED': - case 'CONFIRMED': + case ShiftStatus.assigned: + case ShiftStatus.pendingConfirmation: return UiColors.textSuccess; - case 'IN_PROGRESS': + case ShiftStatus.active: return UiColors.textWarning; - case 'COMPLETED': + case ShiftStatus.completed: return UiColors.primary; - case 'CANCELED': + case ShiftStatus.cancelled: return UiColors.destructive; default: return UiColors.textSecondary; @@ -60,41 +56,42 @@ class _ViewOrderCardState extends State { } /// Returns the localized label for the given status. - String _getStatusLabel({required String status}) { + String _getStatusLabel({required ShiftStatus status}) { switch (status) { - case 'OPEN': + case ShiftStatus.open: return t.client_view_orders.card.open; - case 'FILLED': + case ShiftStatus.assigned: return t.client_view_orders.card.filled; - case 'CONFIRMED': + case ShiftStatus.pendingConfirmation: return t.client_view_orders.card.confirmed; - case 'IN_PROGRESS': + case ShiftStatus.active: return t.client_view_orders.card.in_progress; - case 'COMPLETED': + case ShiftStatus.completed: return t.client_view_orders.card.completed; - case 'CANCELED': + case ShiftStatus.cancelled: return t.client_view_orders.card.cancelled; default: - return status.toUpperCase(); + return status.value.toUpperCase(); } } - /// Formats the time string for display. - String _formatTime({required String timeStr}) { - if (timeStr.isEmpty) return ''; - try { - final List parts = timeStr.split(':'); - int hour = int.parse(parts[0]); - final int minute = int.parse(parts[1]); - final String ampm = hour >= 12 ? 'PM' : 'AM'; - hour = hour % 12; - if (hour == 0) hour = 12; - return '$hour:${minute.toString().padLeft(2, '0')} $ampm'; - } catch (_) { - return timeStr; - } + /// Formats a [DateTime] to a display time string (e.g. "9:00 AM"). + String _formatTime({required DateTime dateTime}) { + final DateTime local = dateTime.toLocal(); + final int hour24 = local.hour; + final int minute = local.minute; + final String ampm = hour24 >= 12 ? 'PM' : 'AM'; + int hour = hour24 % 12; + if (hour == 0) hour = 12; + return '$hour:${minute.toString().padLeft(2, '0')} $ampm'; } + /// Computes the duration in hours between start and end. + double _computeHours(OrderItem order) { + return order.endsAt.difference(order.startsAt).inMinutes / 60.0; + } + + /// Returns the order type display label. String _getOrderTypeLabel(OrderType type) { switch (type) { case OrderType.oneTime: @@ -105,44 +102,16 @@ class _ViewOrderCardState extends State { return 'RECURRING'; case OrderType.rapid: return 'RAPID'; + case OrderType.unknown: + return 'ORDER'; } } /// Returns true if the edit icon should be shown. - /// Hidden for completed orders and for past orders (shift has ended). bool _canEditOrder(OrderItem order) { if (order.status == ShiftStatus.completed) return false; - if (order.date.isEmpty) return true; - try { - final DateTime orderDate = DateTime.parse(order.date); - final String endTime = order.endTime.trim(); - final DateTime endDateTime; - if (endTime.isEmpty) { - // No end time: use end of day so orders today remain editable - endDateTime = DateTime( - orderDate.year, - orderDate.month, - orderDate.day, - 23, - 59, - 59, - ); - } else { - final List endParts = endTime.split(':'); - final int hour = endParts.isNotEmpty ? int.parse(endParts[0]) : 0; - final int minute = endParts.length > 1 ? int.parse(endParts[1]) : 0; - endDateTime = DateTime( - orderDate.year, - orderDate.month, - orderDate.day, - hour, - minute, - ); - } - return endDateTime.isAfter(DateTime.now()); - } catch (_) { - return true; - } + if (order.status == ShiftStatus.cancelled) return false; + return order.endsAt.isAfter(DateTime.now()); } @override @@ -150,12 +119,12 @@ class _ViewOrderCardState extends State { final OrderItem order = widget.order; final Color statusColor = _getStatusColor(status: order.status); final String statusLabel = _getStatusLabel(status: order.status); - final int coveragePercent = order.workersNeeded > 0 - ? ((order.filled / order.workersNeeded) * 100).round() + final int coveragePercent = order.requiredWorkerCount > 0 + ? ((order.filledCount / order.requiredWorkerCount) * 100).round() : 0; - final double hours = order.hours; - final double cost = order.totalValue; + final double hours = _computeHours(order); + final double cost = order.totalCostCents / 100.0; return Container( decoration: BoxDecoration( @@ -232,92 +201,28 @@ class _ViewOrderCardState extends State { ], ), const SizedBox(height: UiConstants.space3), - // Title - Text(order.title, style: UiTypography.headline3b), - Row( - spacing: UiConstants.space1, - children: [ - const Icon( - UiIcons.calendarCheck, - size: 14, - color: UiColors.iconSecondary, - ), - Expanded( - child: Text( - order.eventName, - style: UiTypography.headline5m.textSecondary, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - const SizedBox(height: UiConstants.space4), - // Location (Hub name + Address) - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Padding( - padding: EdgeInsets.only(top: 2), - child: Icon( + // Title (role name) + Text(order.roleName, style: UiTypography.headline3b), + if (order.locationName != null && + order.locationName!.isNotEmpty) + Row( + spacing: UiConstants.space1, + children: [ + const Icon( UiIcons.mapPin, size: 14, color: UiColors.iconSecondary, ), - ), - const SizedBox(width: UiConstants.space2), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (order.location.isNotEmpty) - Text( - order.location, - style: UiTypography - .footnote1b - .textSecondary, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (order.locationAddress.isNotEmpty) - Text( - order.locationAddress, - style: UiTypography - .footnote2r - .textSecondary, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - if (order.hubManagerName != null) ...[ - const SizedBox(height: UiConstants.space2), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Padding( - padding: EdgeInsets.only(top: 2), - child: Icon( - UiIcons.user, - size: 14, - color: UiColors.iconSecondary, - ), - ), - const SizedBox(width: UiConstants.space2), Expanded( child: Text( - order.hubManagerName!, + order.locationName!, style: - UiTypography.footnote2r.textSecondary, - maxLines: 1, + UiTypography.headline5m.textSecondary, overflow: TextOverflow.ellipsis, ), ), ], ), - ], ], ), ), @@ -334,7 +239,7 @@ class _ViewOrderCardState extends State { ), if (_canEditOrder(order)) const SizedBox(width: UiConstants.space2), - if (order.confirmedApps.isNotEmpty) + if (order.workers.isNotEmpty) _buildHeaderIconButton( icon: _expanded ? UiIcons.chevronUp @@ -374,7 +279,7 @@ class _ViewOrderCardState extends State { _buildStatDivider(), _buildStatItem( icon: UiIcons.users, - value: '${order.workersNeeded}', + value: '${order.requiredWorkerCount}', label: t.client_create_order.one_time.workers_label, ), ], @@ -389,14 +294,14 @@ class _ViewOrderCardState extends State { Expanded( child: _buildTimeDisplay( label: t.client_view_orders.card.clock_in, - time: _formatTime(timeStr: order.startTime), + time: _formatTime(dateTime: order.startsAt), ), ), const SizedBox(width: UiConstants.space3), Expanded( child: _buildTimeDisplay( label: t.client_view_orders.card.clock_out, - time: _formatTime(timeStr: order.endTime), + time: _formatTime(dateTime: order.endsAt), ), ), ], @@ -405,7 +310,7 @@ class _ViewOrderCardState extends State { const SizedBox(height: UiConstants.space4), // Coverage Section - if (order.status != 'completed') ...[ + if (order.status != ShiftStatus.completed) ...[ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -428,7 +333,7 @@ class _ViewOrderCardState extends State { coveragePercent == 100 ? t.client_view_orders.card.all_confirmed : t.client_view_orders.card.workers_needed( - count: order.workersNeeded, + count: order.requiredWorkerCount, ), style: UiTypography.body2m.textPrimary, ), @@ -456,17 +361,17 @@ class _ViewOrderCardState extends State { ), // Avatar Stack Preview (if not expanded) - if (!_expanded && order.confirmedApps.isNotEmpty) ...[ + if (!_expanded && order.workers.isNotEmpty) ...[ const SizedBox(height: UiConstants.space4), Row( children: [ - _buildAvatarStack(order.confirmedApps), - if (order.confirmedApps.length > 3) + _buildAvatarStack(order.workers), + if (order.workers.length > 3) Padding( padding: const EdgeInsets.only(left: 12), child: Text( t.client_view_orders.card.show_more_workers( - count: order.confirmedApps.length - 3, + count: order.workers.length - 3, ), style: UiTypography.footnote2r.textSecondary, ), @@ -480,7 +385,7 @@ class _ViewOrderCardState extends State { ), // Assigned Workers (Expanded section) - if (_expanded && order.confirmedApps.isNotEmpty) ...[ + if (_expanded && order.workers.isNotEmpty) ...[ Container( decoration: const BoxDecoration( color: UiColors.bgSecondary, @@ -512,10 +417,12 @@ class _ViewOrderCardState extends State { ], ), const SizedBox(height: UiConstants.space4), - ...order.confirmedApps + ...order.workers .take(5) - .map((Map app) => _buildWorkerRow(app)), - if (order.confirmedApps.length > 5) + .map( + (AssignedWorkerSummary w) => _buildWorkerRow(w), + ), + if (order.workers.length > 5) Padding( padding: const EdgeInsets.only(top: 8), child: Center( @@ -523,7 +430,7 @@ class _ViewOrderCardState extends State { onPressed: () {}, child: Text( t.client_view_orders.card.show_more_workers( - count: order.confirmedApps.length - 5, + count: order.workers.length - 5, ), style: UiTypography.body2m.copyWith( color: UiColors.primary, @@ -541,10 +448,12 @@ class _ViewOrderCardState extends State { ); } + /// Builds a stat divider. Widget _buildStatDivider() { return Container(width: 1, height: 24, color: UiColors.border); } + /// Builds a time display box. Widget _buildTimeDisplay({required String label, required String time}) { return Container( padding: const EdgeInsets.all(UiConstants.space3), @@ -565,11 +474,11 @@ class _ViewOrderCardState extends State { ); } - /// Builds a stacked avatar UI for a list of applications. - Widget _buildAvatarStack(List> apps) { + /// Builds a stacked avatar UI for assigned workers. + Widget _buildAvatarStack(List workers) { const double size = 32.0; const double overlap = 22.0; - final int count = apps.length > 3 ? 3 : apps.length; + final int count = workers.length > 3 ? 3 : workers.length; return SizedBox( height: size, @@ -589,7 +498,9 @@ class _ViewOrderCardState extends State { ), child: Center( child: Text( - (apps[i]['worker_name'] as String)[0], + (workers[i].workerName ?? '').isNotEmpty + ? (workers[i].workerName ?? '?')[0] + : '?', style: UiTypography.footnote2b.copyWith( color: UiColors.primary, ), @@ -603,8 +514,7 @@ class _ViewOrderCardState extends State { } /// Builds a detailed row for a worker. - Widget _buildWorkerRow(Map app) { - final String? phone = app['phone'] as String?; + Widget _buildWorkerRow(AssignedWorkerSummary worker) { return Container( margin: const EdgeInsets.only(bottom: UiConstants.space3), padding: const EdgeInsets.all(UiConstants.space3), @@ -618,7 +528,7 @@ class _ViewOrderCardState extends State { CircleAvatar( backgroundColor: UiColors.primary.withValues(alpha: 0.1), child: Text( - (app['worker_name'] as String)[0], + (worker.workerName ?? '').isNotEmpty ? (worker.workerName ?? '?')[0] : '?', style: UiTypography.body1b.copyWith(color: UiColors.primary), ), ), @@ -628,129 +538,35 @@ class _ViewOrderCardState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - app['worker_name'] as String, + worker.workerName ?? '', style: UiTypography.body2m.textPrimary, ), const SizedBox(height: UiConstants.space1 / 2), - Row( - children: [ - if ((app['rating'] as num?) != null && - (app['rating'] as num) > 0) ...[ - const Icon( - UiIcons.star, - size: 10, - color: UiColors.accent, + if (worker.confirmationStatus != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: UiConstants.radiusSm, + ), + child: Text( + worker.confirmationStatus!.value.toUpperCase(), + style: UiTypography.titleUppercase4m.copyWith( + color: UiColors.textSecondary, ), - const SizedBox(width: 2), - Text( - (app['rating'] as num).toStringAsFixed(1), - style: UiTypography.footnote2r.textSecondary, - ), - ], - if (app['check_in_time'] != null) ...[ - const SizedBox(width: UiConstants.space2), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 1, - ), - decoration: BoxDecoration( - color: UiColors.textSuccess.withValues(alpha: 0.1), - borderRadius: UiConstants.radiusSm, - ), - child: Text( - t.client_view_orders.card.checked_in, - style: UiTypography.titleUppercase4m.copyWith( - color: UiColors.textSuccess, - ), - ), - ), - ] else if ((app['status'] as String?)?.isNotEmpty ?? - false) ...[ - const SizedBox(width: UiConstants.space2), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 1, - ), - decoration: BoxDecoration( - color: UiColors.bgSecondary, - borderRadius: UiConstants.radiusSm, - ), - child: Text( - (app['status'] as String).toUpperCase(), - style: UiTypography.titleUppercase4m.copyWith( - color: UiColors.textSecondary, - ), - ), - ), - ], - ], - ), + ), + ), ], ), ), - if (phone != null && phone.isNotEmpty) ...[ - _buildActionIconButton( - icon: UiIcons.phone, - onTap: () => _confirmAndCall(phone), - ), - ], ], ), ); } - Future _confirmAndCall(String phone) async { - final bool? shouldCall = await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: Text(t.client_view_orders.card.call_dialog.title), - content: Text( - t.client_view_orders.card.call_dialog.message(phone: phone), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: Text(t.common.cancel), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: Text(t.client_view_orders.card.call_dialog.title), - ), - ], - ); - }, - ); - - if (shouldCall != true) { - return; - } - - final Uri uri = Uri(scheme: 'tel', path: phone); - await launchUrl(uri); - } - - /// Specialized action button for worker rows. - Widget _buildActionIconButton({ - required IconData icon, - required VoidCallback onTap, - }) { - return GestureDetector( - onTap: onTap, - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: UiColors.bgSecondary, - borderRadius: BorderRadius.circular(UiConstants.space2), - ), - child: Icon(icon, size: 16, color: UiColors.primary), - ), - ); - } - /// Builds a small icon button used in row headers. Widget _buildHeaderIconButton({ required IconData icon, @@ -771,7 +587,7 @@ class _ViewOrderCardState extends State { ); } - /// Builds a single stat item (e.g., Cost, Hours, Workers). + /// Builds a single stat item. Widget _buildStatItem({ required IconData icon, required String value, diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_empty_state.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_empty_state.dart index 24362270..0b6b3e7e 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_empty_state.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_empty_state.dart @@ -2,7 +2,6 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; @@ -49,6 +48,13 @@ class ViewOrdersEmptyState extends StatelessWidget { if (checkDate == today) return 'Today'; if (checkDate == tomorrow) return 'Tomorrow'; - return DateFormat('EEE, MMM d').format(date); + const List weekdays = [ + 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun', + ]; + const List months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + ]; + return '${weekdays[date.weekday - 1]}, ${months[date.month - 1]} ${date.day}'; } } diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_header.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_header.dart index db23e1e8..30eb4378 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_header.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_header.dart @@ -3,7 +3,6 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:intl/intl.dart'; import 'package:core_localization/core_localization.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -32,6 +31,25 @@ class ViewOrdersHeader extends StatelessWidget { /// The list of calendar days to display. final List calendarDays; + static const List _months = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', + ]; + + static const List _weekdays = [ + 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun', + ]; + + /// Formats a date as "Month YYYY". + static String _formatMonthYear(DateTime date) { + return '${_months[date.month - 1]} ${date.year}'; + } + + /// Returns the abbreviated weekday name. + static String _weekdayAbbr(int weekday) { + return _weekdays[weekday - 1]; + } + @override Widget build(BuildContext context) { return ClipRect( @@ -133,7 +151,7 @@ class ViewOrdersHeader extends StatelessWidget { splashRadius: UiConstants.iconMd, ), Text( - DateFormat('MMMM yyyy').format(calendarDays.first), + _formatMonthYear(calendarDays.first), style: UiTypography.body2m.copyWith( color: UiColors.textSecondary, ), @@ -175,11 +193,11 @@ class ViewOrdersHeader extends StatelessWidget { date.day == state.selectedDate!.day; // Check if this date has any shifts - final String dateStr = DateFormat( - 'yyyy-MM-dd', - ).format(date); final bool hasShifts = state.orders.any( - (OrderItem s) => s.date == dateStr, + (OrderItem s) => + s.date.year == date.year && + s.date.month == date.month && + s.date.day == date.day, ); // Check if date is in the past @@ -221,7 +239,7 @@ class ViewOrdersHeader extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - DateFormat('dd').format(date), + date.day.toString().padLeft(2, '0'), style: UiTypography.title1m.copyWith( fontWeight: FontWeight.bold, color: isSelected @@ -230,7 +248,7 @@ class ViewOrdersHeader extends StatelessWidget { ), ), Text( - DateFormat('E').format(date), + _weekdayAbbr(date.weekday), style: UiTypography.footnote2m.copyWith( color: isSelected ? UiColors.white.withValues(alpha: 0.8) diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/view_orders_module.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/view_orders_module.dart index ec20567d..7d56d1c2 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/view_orders_module.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/view_orders_module.dart @@ -1,31 +1,33 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_domain/krow_domain.dart'; import 'data/repositories/view_orders_repository_impl.dart'; import 'domain/repositories/i_view_orders_repository.dart'; -import 'domain/usecases/get_accepted_applications_for_day_use_case.dart'; import 'domain/usecases/get_orders_use_case.dart'; import 'presentation/blocs/view_orders_cubit.dart'; import 'presentation/pages/view_orders_page.dart'; /// Module for the View Orders feature. /// -/// This module sets up Dependency Injection for repositories, use cases, -/// and BLoCs, and defines the feature's navigation routes. +/// Sets up DI for repositories, use cases, and BLoCs, and defines routes. +/// Uses [CoreModule] for [BaseApiService] injection (V2 API). class ViewOrdersModule extends Module { @override - List get imports => [DataConnectModule()]; + List get imports => [CoreModule()]; @override void binds(Injector i) { // Repositories - i.add(ViewOrdersRepositoryImpl.new); + i.add( + () => ViewOrdersRepositoryImpl( + apiService: i.get(), + ), + ); // UseCases i.add(GetOrdersUseCase.new); - i.add(GetAcceptedApplicationsForDayUseCase.new); // BLoCs i.addLazySingleton(ViewOrdersCubit.new); @@ -38,11 +40,11 @@ class ViewOrdersModule extends Module { child: (BuildContext context) { final Object? args = Modular.args.data; DateTime? initialDate; - - // Try parsing from args.data first + if (args is DateTime) { initialDate = args; - } else if (args is Map && args['initialDate'] != null) { + } else if (args is Map && + args['initialDate'] != null) { final Object? rawDate = args['initialDate']; if (rawDate is DateTime) { initialDate = rawDate; @@ -50,15 +52,14 @@ class ViewOrdersModule extends Module { initialDate = DateTime.tryParse(rawDate); } } - - // Fallback to query params + if (initialDate == null) { final String? queryDate = Modular.args.queryParams['initialDate']; if (queryDate != null && queryDate.isNotEmpty) { initialDate = DateTime.tryParse(queryDate); } } - + return ViewOrdersPage(initialDate: initialDate); }, ); diff --git a/apps/mobile/packages/features/client/orders/view_orders/pubspec.yaml b/apps/mobile/packages/features/client/orders/view_orders/pubspec.yaml index 0628bce9..6ad07f88 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/pubspec.yaml +++ b/apps/mobile/packages/features/client/orders/view_orders/pubspec.yaml @@ -25,13 +25,9 @@ dependencies: path: ../../../../domain krow_core: path: ../../../../core - krow_data_connect: - path: ../../../../data_connect + # UI - intl: ^0.20.1 url_launcher: ^6.3.1 - firebase_data_connect: ^0.2.2+2 - firebase_auth: ^6.1.4 dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart b/apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart index b7e61451..13cf5a2d 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart @@ -1,89 +1,119 @@ -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/repositories/reports_repository.dart'; -/// Implementation of [ReportsRepository] that delegates to [ReportsConnectorRepository]. +import 'package:client_reports/src/domain/repositories/reports_repository.dart'; + +/// V2 API implementation of [ReportsRepository]. /// -/// This implementation follows the "Buffer Layer" pattern by using a dedicated -/// connector repository from the data_connect package. +/// Each method hits its corresponding `V2ApiEndpoints.clientReports*` endpoint, +/// passing date-range query parameters, and deserialises the JSON response +/// into the relevant domain entity. class ReportsRepositoryImpl implements ReportsRepository { + /// Creates a [ReportsRepositoryImpl]. + ReportsRepositoryImpl({required BaseApiService apiService}) + : _apiService = apiService; - ReportsRepositoryImpl({ReportsConnectorRepository? connectorRepository}) - : _connectorRepository = connectorRepository ?? DataConnectService.instance.getReportsRepository(); - final ReportsConnectorRepository _connectorRepository; + /// The API service used for network requests. + final BaseApiService _apiService; + + // ── Helpers ────────────────────────────────────────────────────────────── + + /// Converts a [DateTime] to an ISO-8601 date string (yyyy-MM-dd). + String _iso(DateTime dt) => dt.toIso8601String().split('T').first; + + /// Standard date-range query parameters. + Map _rangeParams(DateTime start, DateTime end) => + {'startDate': _iso(start), 'endDate': _iso(end)}; + + // ── Reports ────────────────────────────────────────────────────────────── @override Future getDailyOpsReport({ - String? businessId, required DateTime date, - }) => _connectorRepository.getDailyOpsReport( - businessId: businessId, - date: date, - ); + }) async { + final ApiResponse response = await _apiService.get( + V2ApiEndpoints.clientReportsDailyOps, + params: {'date': _iso(date)}, + ); + final Map data = response.data as Map; + return DailyOpsReport.fromJson(data); + } @override Future getSpendReport({ - String? businessId, required DateTime startDate, required DateTime endDate, - }) => _connectorRepository.getSpendReport( - businessId: businessId, - startDate: startDate, - endDate: endDate, - ); + }) async { + final ApiResponse response = await _apiService.get( + V2ApiEndpoints.clientReportsSpend, + params: _rangeParams(startDate, endDate), + ); + final Map data = response.data as Map; + return SpendReport.fromJson(data); + } @override Future getCoverageReport({ - String? businessId, required DateTime startDate, required DateTime endDate, - }) => _connectorRepository.getCoverageReport( - businessId: businessId, - startDate: startDate, - endDate: endDate, - ); + }) async { + final ApiResponse response = await _apiService.get( + V2ApiEndpoints.clientReportsCoverage, + params: _rangeParams(startDate, endDate), + ); + final Map data = response.data as Map; + return CoverageReport.fromJson(data); + } @override Future getForecastReport({ - String? businessId, required DateTime startDate, required DateTime endDate, - }) => _connectorRepository.getForecastReport( - businessId: businessId, - startDate: startDate, - endDate: endDate, - ); + }) async { + final ApiResponse response = await _apiService.get( + V2ApiEndpoints.clientReportsForecast, + params: _rangeParams(startDate, endDate), + ); + final Map data = response.data as Map; + return ForecastReport.fromJson(data); + } @override Future getPerformanceReport({ - String? businessId, required DateTime startDate, required DateTime endDate, - }) => _connectorRepository.getPerformanceReport( - businessId: businessId, - startDate: startDate, - endDate: endDate, - ); + }) async { + final ApiResponse response = await _apiService.get( + V2ApiEndpoints.clientReportsPerformance, + params: _rangeParams(startDate, endDate), + ); + final Map data = response.data as Map; + return PerformanceReport.fromJson(data); + } @override Future getNoShowReport({ - String? businessId, required DateTime startDate, required DateTime endDate, - }) => _connectorRepository.getNoShowReport( - businessId: businessId, - startDate: startDate, - endDate: endDate, - ); + }) async { + final ApiResponse response = await _apiService.get( + V2ApiEndpoints.clientReportsNoShow, + params: _rangeParams(startDate, endDate), + ); + final Map data = response.data as Map; + return NoShowReport.fromJson(data); + } @override - Future getReportsSummary({ - String? businessId, + Future getReportsSummary({ required DateTime startDate, required DateTime endDate, - }) => _connectorRepository.getReportsSummary( - businessId: businessId, - startDate: startDate, - endDate: endDate, - ); + }) async { + final ApiResponse response = await _apiService.get( + V2ApiEndpoints.clientReportsSummary, + params: _rangeParams(startDate, endDate), + ); + final Map data = response.data as Map; + return ReportSummary.fromJson(data); + } } diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/repositories/reports_repository.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/repositories/reports_repository.dart index 36ff5d47..aa096c67 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/domain/repositories/reports_repository.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/repositories/reports_repository.dart @@ -1,43 +1,44 @@ import 'package:krow_domain/krow_domain.dart'; +/// Contract for fetching report data from the V2 API. abstract class ReportsRepository { + /// Fetches the daily operations report for a given [date]. Future getDailyOpsReport({ - String? businessId, required DateTime date, }); + /// Fetches the spend report for a date range. Future getSpendReport({ - String? businessId, required DateTime startDate, required DateTime endDate, }); + /// Fetches the coverage report for a date range. Future getCoverageReport({ - String? businessId, required DateTime startDate, required DateTime endDate, }); + /// Fetches the forecast report for a date range. Future getForecastReport({ - String? businessId, required DateTime startDate, required DateTime endDate, }); + /// Fetches the performance report for a date range. Future getPerformanceReport({ - String? businessId, required DateTime startDate, required DateTime endDate, }); + /// Fetches the no-show report for a date range. Future getNoShowReport({ - String? businessId, required DateTime startDate, required DateTime endDate, }); - Future getReportsSummary({ - String? businessId, + /// Fetches the high-level report summary for a date range. + Future getReportsSummary({ required DateTime startDate, required DateTime endDate, }); diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_bloc.dart index 5722ed44..7745e970 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_bloc.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_bloc.dart @@ -1,34 +1,39 @@ -// 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:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/presentation/blocs/coverage/coverage_event.dart'; +import 'package:client_reports/src/presentation/blocs/coverage/coverage_state.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:krow_domain/src/entities/reports/coverage_report.dart'; -import '../../../domain/repositories/reports_repository.dart'; -import 'coverage_event.dart'; -import 'coverage_state.dart'; - -class CoverageBloc extends Bloc { +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +/// BLoC that loads the [CoverageReport]. +class CoverageBloc extends Bloc + with BlocErrorHandler { + /// Creates a [CoverageBloc]. CoverageBloc({required ReportsRepository reportsRepository}) : _reportsRepository = reportsRepository, super(CoverageInitial()) { on(_onLoadCoverageReport); } + + /// The repository used to fetch report data. final ReportsRepository _reportsRepository; Future _onLoadCoverageReport( LoadCoverageReport event, Emitter emit, ) async { - emit(CoverageLoading()); - try { - final CoverageReport report = await _reportsRepository.getCoverageReport( - businessId: event.businessId, - startDate: event.startDate, - endDate: event.endDate, - ); - emit(CoverageLoaded(report)); - } catch (e) { - emit(CoverageError(e.toString())); - } + await handleError( + emit: emit, + action: () async { + emit(CoverageLoading()); + final CoverageReport report = + await _reportsRepository.getCoverageReport( + startDate: event.startDate, + endDate: event.endDate, + ); + emit(CoverageLoaded(report)); + }, + onError: (String errorKey) => CoverageError(errorKey), + ); } } - diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_event.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_event.dart index 546e648d..a1de131a 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_event.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_event.dart @@ -1,25 +1,28 @@ -// 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:equatable/equatable.dart'; +/// Base event for the coverage report BLoC. abstract class CoverageEvent extends Equatable { + /// Creates a [CoverageEvent]. const CoverageEvent(); @override List get props => []; } +/// Triggers loading of the coverage report for a date range. class LoadCoverageReport extends CoverageEvent { - + /// Creates a [LoadCoverageReport] event. const LoadCoverageReport({ - this.businessId, required this.startDate, required this.endDate, }); - final String? businessId; + + /// Start of the reporting period. final DateTime startDate; + + /// End of the reporting period. final DateTime endDate; @override - List get props => [businessId, startDate, endDate]; + List get props => [startDate, endDate]; } - diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_bloc.dart index 06f54dcb..511a2344 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_bloc.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_bloc.dart @@ -1,32 +1,38 @@ +import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_event.dart'; +import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_state.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../../domain/repositories/reports_repository.dart'; -import 'daily_ops_event.dart'; -import 'daily_ops_state.dart'; - -class DailyOpsBloc extends Bloc { - +/// BLoC that loads the [DailyOpsReport]. +class DailyOpsBloc extends Bloc + with BlocErrorHandler { + /// Creates a [DailyOpsBloc]. DailyOpsBloc({required ReportsRepository reportsRepository}) : _reportsRepository = reportsRepository, super(DailyOpsInitial()) { on(_onLoadDailyOpsReport); } + + /// The repository used to fetch report data. final ReportsRepository _reportsRepository; Future _onLoadDailyOpsReport( LoadDailyOpsReport event, Emitter emit, ) async { - emit(DailyOpsLoading()); - try { - final DailyOpsReport report = await _reportsRepository.getDailyOpsReport( - businessId: event.businessId, - date: event.date, - ); - emit(DailyOpsLoaded(report)); - } catch (e) { - emit(DailyOpsError(e.toString())); - } + await handleError( + emit: emit, + action: () async { + emit(DailyOpsLoading()); + final DailyOpsReport report = + await _reportsRepository.getDailyOpsReport( + date: event.date, + ); + emit(DailyOpsLoaded(report)); + }, + onError: (String errorKey) => DailyOpsError(errorKey), + ); } } diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_event.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_event.dart index 081d00bc..d8679b98 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_event.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_event.dart @@ -1,21 +1,22 @@ import 'package:equatable/equatable.dart'; +/// Base event for the daily ops BLoC. abstract class DailyOpsEvent extends Equatable { + /// Creates a [DailyOpsEvent]. const DailyOpsEvent(); @override List get props => []; } +/// Triggers loading of the daily operations report for a given [date]. class LoadDailyOpsReport extends DailyOpsEvent { + /// Creates a [LoadDailyOpsReport] event. + const LoadDailyOpsReport({required this.date}); - const LoadDailyOpsReport({ - this.businessId, - required this.date, - }); - final String? businessId; + /// The date to fetch the report for. final DateTime date; @override - List get props => [businessId, date]; + List get props => [date]; } diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_bloc.dart index 23df8973..cc985817 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_bloc.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_bloc.dart @@ -1,32 +1,39 @@ +import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/presentation/blocs/forecast/forecast_event.dart'; +import 'package:client_reports/src/presentation/blocs/forecast/forecast_state.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:krow_domain/src/entities/reports/forecast_report.dart'; -import '../../../domain/repositories/reports_repository.dart'; -import 'forecast_event.dart'; -import 'forecast_state.dart'; - -class ForecastBloc extends Bloc { +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +/// BLoC that loads the [ForecastReport]. +class ForecastBloc extends Bloc + with BlocErrorHandler { + /// Creates a [ForecastBloc]. ForecastBloc({required ReportsRepository reportsRepository}) : _reportsRepository = reportsRepository, super(ForecastInitial()) { on(_onLoadForecastReport); } + + /// The repository used to fetch report data. final ReportsRepository _reportsRepository; Future _onLoadForecastReport( LoadForecastReport event, Emitter emit, ) async { - emit(ForecastLoading()); - try { - final ForecastReport report = await _reportsRepository.getForecastReport( - businessId: event.businessId, - startDate: event.startDate, - endDate: event.endDate, - ); - emit(ForecastLoaded(report)); - } catch (e) { - emit(ForecastError(e.toString())); - } + await handleError( + emit: emit, + action: () async { + emit(ForecastLoading()); + final ForecastReport report = + await _reportsRepository.getForecastReport( + startDate: event.startDate, + endDate: event.endDate, + ); + emit(ForecastLoaded(report)); + }, + onError: (String errorKey) => ForecastError(errorKey), + ); } } diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_event.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_event.dart index 0f68ecf1..88347311 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_event.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_event.dart @@ -7,17 +7,20 @@ abstract class ForecastEvent extends Equatable { List get props => []; } +/// Triggers loading of the forecast report for a date range. class LoadForecastReport extends ForecastEvent { - + /// Creates a [LoadForecastReport] event. const LoadForecastReport({ - this.businessId, required this.startDate, required this.endDate, }); - final String? businessId; + + /// Start of the reporting period. final DateTime startDate; + + /// End of the reporting period. final DateTime endDate; @override - List get props => [businessId, startDate, endDate]; + List get props => [startDate, endDate]; } diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_bloc.dart index d8bd103e..000ada91 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_bloc.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_bloc.dart @@ -1,32 +1,38 @@ +import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/presentation/blocs/no_show/no_show_event.dart'; +import 'package:client_reports/src/presentation/blocs/no_show/no_show_state.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:krow_domain/src/entities/reports/no_show_report.dart'; -import '../../../domain/repositories/reports_repository.dart'; -import 'no_show_event.dart'; -import 'no_show_state.dart'; - -class NoShowBloc extends Bloc { +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +/// BLoC that loads the [NoShowReport]. +class NoShowBloc extends Bloc + with BlocErrorHandler { + /// Creates a [NoShowBloc]. NoShowBloc({required ReportsRepository reportsRepository}) : _reportsRepository = reportsRepository, super(NoShowInitial()) { on(_onLoadNoShowReport); } + + /// The repository used to fetch report data. final ReportsRepository _reportsRepository; Future _onLoadNoShowReport( LoadNoShowReport event, Emitter emit, ) async { - emit(NoShowLoading()); - try { - final NoShowReport report = await _reportsRepository.getNoShowReport( - businessId: event.businessId, - startDate: event.startDate, - endDate: event.endDate, - ); - emit(NoShowLoaded(report)); - } catch (e) { - emit(NoShowError(e.toString())); - } + await handleError( + emit: emit, + action: () async { + emit(NoShowLoading()); + final NoShowReport report = await _reportsRepository.getNoShowReport( + startDate: event.startDate, + endDate: event.endDate, + ); + emit(NoShowLoaded(report)); + }, + onError: (String errorKey) => NoShowError(errorKey), + ); } } diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_event.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_event.dart index a09a53dc..b40a0886 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_event.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_event.dart @@ -7,17 +7,20 @@ abstract class NoShowEvent extends Equatable { List get props => []; } +/// Triggers loading of the no-show report for a date range. class LoadNoShowReport extends NoShowEvent { - + /// Creates a [LoadNoShowReport] event. const LoadNoShowReport({ - this.businessId, required this.startDate, required this.endDate, }); - final String? businessId; + + /// Start of the reporting period. final DateTime startDate; + + /// End of the reporting period. final DateTime endDate; @override - List get props => [businessId, startDate, endDate]; + List get props => [startDate, endDate]; } diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_bloc.dart index b9978bd9..b64f09ef 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_bloc.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_bloc.dart @@ -1,32 +1,39 @@ +import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/presentation/blocs/performance/performance_event.dart'; +import 'package:client_reports/src/presentation/blocs/performance/performance_state.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:krow_domain/src/entities/reports/performance_report.dart'; -import '../../../domain/repositories/reports_repository.dart'; -import 'performance_event.dart'; -import 'performance_state.dart'; - -class PerformanceBloc extends Bloc { +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +/// BLoC that loads the [PerformanceReport]. +class PerformanceBloc extends Bloc + with BlocErrorHandler { + /// Creates a [PerformanceBloc]. PerformanceBloc({required ReportsRepository reportsRepository}) : _reportsRepository = reportsRepository, super(PerformanceInitial()) { on(_onLoadPerformanceReport); } + + /// The repository used to fetch report data. final ReportsRepository _reportsRepository; Future _onLoadPerformanceReport( LoadPerformanceReport event, Emitter emit, ) async { - emit(PerformanceLoading()); - try { - final PerformanceReport report = await _reportsRepository.getPerformanceReport( - businessId: event.businessId, - startDate: event.startDate, - endDate: event.endDate, - ); - emit(PerformanceLoaded(report)); - } catch (e) { - emit(PerformanceError(e.toString())); - } + await handleError( + emit: emit, + action: () async { + emit(PerformanceLoading()); + final PerformanceReport report = + await _reportsRepository.getPerformanceReport( + startDate: event.startDate, + endDate: event.endDate, + ); + emit(PerformanceLoaded(report)); + }, + onError: (String errorKey) => PerformanceError(errorKey), + ); } } diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_event.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_event.dart index d203b7e7..45f16af1 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_event.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_event.dart @@ -7,17 +7,20 @@ abstract class PerformanceEvent extends Equatable { List get props => []; } +/// Triggers loading of the performance report for a date range. class LoadPerformanceReport extends PerformanceEvent { - + /// Creates a [LoadPerformanceReport] event. const LoadPerformanceReport({ - this.businessId, required this.startDate, required this.endDate, }); - final String? businessId; + + /// Start of the reporting period. final DateTime startDate; + + /// End of the reporting period. final DateTime endDate; @override - List get props => [businessId, startDate, endDate]; + List get props => [startDate, endDate]; } diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_bloc.dart index c2e5f8ce..e64c04cf 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_bloc.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_bloc.dart @@ -1,32 +1,38 @@ +import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/presentation/blocs/spend/spend_event.dart'; +import 'package:client_reports/src/presentation/blocs/spend/spend_state.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:krow_domain/src/entities/reports/spend_report.dart'; -import '../../../domain/repositories/reports_repository.dart'; -import 'spend_event.dart'; -import 'spend_state.dart'; - -class SpendBloc extends Bloc { +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +/// BLoC that loads the [SpendReport]. +class SpendBloc extends Bloc + with BlocErrorHandler { + /// Creates a [SpendBloc]. SpendBloc({required ReportsRepository reportsRepository}) : _reportsRepository = reportsRepository, super(SpendInitial()) { on(_onLoadSpendReport); } + + /// The repository used to fetch report data. final ReportsRepository _reportsRepository; Future _onLoadSpendReport( LoadSpendReport event, Emitter emit, ) async { - emit(SpendLoading()); - try { - final SpendReport report = await _reportsRepository.getSpendReport( - businessId: event.businessId, - startDate: event.startDate, - endDate: event.endDate, - ); - emit(SpendLoaded(report)); - } catch (e) { - emit(SpendError(e.toString())); - } + await handleError( + emit: emit, + action: () async { + emit(SpendLoading()); + final SpendReport report = await _reportsRepository.getSpendReport( + startDate: event.startDate, + endDate: event.endDate, + ); + emit(SpendLoaded(report)); + }, + onError: (String errorKey) => SpendError(errorKey), + ); } } diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_event.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_event.dart index 9802a0eb..8a402c88 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_event.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_event.dart @@ -7,17 +7,20 @@ abstract class SpendEvent extends Equatable { List get props => []; } +/// Triggers loading of the spend report for a date range. class LoadSpendReport extends SpendEvent { - + /// Creates a [LoadSpendReport] event. const LoadSpendReport({ - this.businessId, required this.startDate, required this.endDate, }); - final String? businessId; + + /// Start of the reporting period. final DateTime startDate; + + /// End of the reporting period. final DateTime endDate; @override - List get props => [businessId, startDate, endDate]; + List get props => [startDate, endDate]; } diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_bloc.dart index 25c408ae..4456877f 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_bloc.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_bloc.dart @@ -1,32 +1,40 @@ +import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/presentation/blocs/summary/reports_summary_event.dart'; +import 'package:client_reports/src/presentation/blocs/summary/reports_summary_state.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:krow_domain/src/entities/reports/reports_summary.dart'; -import '../../../domain/repositories/reports_repository.dart'; -import 'reports_summary_event.dart'; -import 'reports_summary_state.dart'; - -class ReportsSummaryBloc extends Bloc { +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +/// BLoC that loads the high-level [ReportSummary] for the reports dashboard. +class ReportsSummaryBloc + extends Bloc + with BlocErrorHandler { + /// Creates a [ReportsSummaryBloc]. ReportsSummaryBloc({required ReportsRepository reportsRepository}) : _reportsRepository = reportsRepository, super(ReportsSummaryInitial()) { on(_onLoadReportsSummary); } + + /// The repository used to fetch summary data. final ReportsRepository _reportsRepository; Future _onLoadReportsSummary( LoadReportsSummary event, Emitter emit, ) async { - emit(ReportsSummaryLoading()); - try { - final ReportsSummary summary = await _reportsRepository.getReportsSummary( - businessId: event.businessId, - startDate: event.startDate, - endDate: event.endDate, - ); - emit(ReportsSummaryLoaded(summary)); - } catch (e) { - emit(ReportsSummaryError(e.toString())); - } + await handleError( + emit: emit, + action: () async { + emit(ReportsSummaryLoading()); + final ReportSummary summary = + await _reportsRepository.getReportsSummary( + startDate: event.startDate, + endDate: event.endDate, + ); + emit(ReportsSummaryLoaded(summary)); + }, + onError: (String errorKey) => ReportsSummaryError(errorKey), + ); } } diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_event.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_event.dart index 8753d5d0..d00c10e6 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_event.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_event.dart @@ -1,23 +1,28 @@ import 'package:equatable/equatable.dart'; +/// Base event for the reports summary BLoC. abstract class ReportsSummaryEvent extends Equatable { + /// Creates a [ReportsSummaryEvent]. const ReportsSummaryEvent(); @override List get props => []; } +/// Triggers loading of the report summary for a date range. class LoadReportsSummary extends ReportsSummaryEvent { - + /// Creates a [LoadReportsSummary] event. const LoadReportsSummary({ - this.businessId, required this.startDate, required this.endDate, }); - final String? businessId; + + /// Start of the reporting period. final DateTime startDate; + + /// End of the reporting period. final DateTime endDate; @override - List get props => [businessId, startDate, endDate]; + List get props => [startDate, endDate]; } diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_state.dart index 2772e415..5fdccc9b 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_state.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_state.dart @@ -1,33 +1,41 @@ -// 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:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +/// Base state for the reports summary BLoC. abstract class ReportsSummaryState extends Equatable { + /// Creates a [ReportsSummaryState]. const ReportsSummaryState(); @override List get props => []; } +/// Initial state before any data is loaded. class ReportsSummaryInitial extends ReportsSummaryState {} +/// Summary data is being fetched. class ReportsSummaryLoading extends ReportsSummaryState {} +/// Summary data loaded successfully. class ReportsSummaryLoaded extends ReportsSummaryState { - + /// Creates a [ReportsSummaryLoaded] state. const ReportsSummaryLoaded(this.summary); - final ReportsSummary summary; + + /// The loaded report summary. + final ReportSummary summary; @override List get props => [summary]; } +/// An error occurred while fetching the summary. class ReportsSummaryError extends ReportsSummaryState { - + /// Creates a [ReportsSummaryError] state. const ReportsSummaryError(this.message); + + /// Human-readable error description. final String message; @override List get props => [message]; } - diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart index a6f3cdaf..35b7784f 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart @@ -10,7 +10,7 @@ import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../widgets/report_detail_skeleton.dart'; +import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart'; class CoverageReportPage extends StatefulWidget { const CoverageReportPage({super.key}); @@ -122,7 +122,7 @@ class _CoverageReportPageState extends State { Expanded( child: _CoverageSummaryCard( label: context.t.client_reports.coverage_report.metrics.avg_coverage, - value: '${report.overallCoverage.toStringAsFixed(1)}%', + value: '${report.averageCoveragePercentage}%', icon: UiIcons.chart, color: UiColors.primary, ), @@ -131,7 +131,7 @@ class _CoverageReportPageState extends State { Expanded( child: _CoverageSummaryCard( label: context.t.client_reports.coverage_report.metrics.full, - value: '${report.totalFilled}/${report.totalNeeded}', + value: '${report.filledWorkers}/${report.neededWorkers}', icon: UiIcons.users, color: UiColors.success, ), @@ -151,14 +151,14 @@ class _CoverageReportPageState extends State { ), ), const SizedBox(height: 16), - if (report.dailyCoverage.isEmpty) + if (report.chart.isEmpty) Center(child: Text(context.t.client_reports.coverage_report.empty_state)) else - ...report.dailyCoverage.map((CoverageDay day) => _CoverageListItem( - date: DateFormat('EEE, MMM dd').format(day.date), + ...report.chart.map((CoverageDayPoint day) => _CoverageListItem( + date: DateFormat('EEE, MMM dd').format(day.day), needed: day.needed, filled: day.filled, - percentage: day.percentage, + percentage: day.coveragePercentage, )), const SizedBox(height: 100), ], diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart index 03de178c..062e03ee 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart @@ -10,7 +10,7 @@ import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../widgets/report_detail_skeleton.dart'; +import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart'; class DailyOpsReportPage extends StatefulWidget { const DailyOpsReportPage({super.key}); @@ -254,7 +254,7 @@ class _DailyOpsReportPageState extends State { _OpsStatCard( label: context.t.client_reports .daily_ops_report.metrics.scheduled.label, - value: report.scheduledShifts.toString(), + value: report.totalShifts.toString(), subValue: context .t .client_reports @@ -268,7 +268,7 @@ class _DailyOpsReportPageState extends State { _OpsStatCard( label: context.t.client_reports .daily_ops_report.metrics.workers.label, - value: report.workersConfirmed.toString(), + value: report.totalWorkersDeployed.toString(), subValue: context .t .client_reports @@ -276,7 +276,7 @@ class _DailyOpsReportPageState extends State { .metrics .workers .sub_value, - color: UiColors.primary, + color: UiColors.primary, icon: UiIcons.users, ), _OpsStatCard( @@ -287,7 +287,7 @@ class _DailyOpsReportPageState extends State { .metrics .in_progress .label, - value: report.inProgressShifts.toString(), + value: report.totalHoursWorked.toString(), subValue: context .t .client_reports @@ -295,7 +295,7 @@ class _DailyOpsReportPageState extends State { .metrics .in_progress .sub_value, - color: UiColors.textWarning, + color: UiColors.textWarning, icon: UiIcons.clock, ), _OpsStatCard( @@ -306,7 +306,7 @@ class _DailyOpsReportPageState extends State { .metrics .completed .label, - value: report.completedShifts.toString(), + value: '${report.onTimeArrivalPercentage}%', subValue: context .t .client_reports @@ -343,22 +343,20 @@ class _DailyOpsReportPageState extends State { ), ) else - ...report.shifts.map((DailyOpsShift shift) => _ShiftListItem( - title: shift.title, - location: shift.location, + ...report.shifts.map((ShiftWithWorkers shift) => _ShiftListItem( + title: shift.roleName, + location: shift.shiftId, time: - '${DateFormat('HH:mm').format(shift.startTime)} - ${DateFormat('HH:mm').format(shift.endTime)}', + '${DateFormat('HH:mm').format(shift.timeRange.startsAt)} - ${DateFormat('HH:mm').format(shift.timeRange.endsAt)}', workers: - '${shift.filled}/${shift.workersNeeded}', - rate: shift.hourlyRate != null - ? '\$${shift.hourlyRate!.toStringAsFixed(0)}/hr' - : '-', - status: shift.status.replaceAll('_', ' '), - statusColor: shift.status == 'COMPLETED' + '${shift.assignedWorkerCount}/${shift.requiredWorkerCount}', + rate: '-', + status: shift.assignedWorkerCount >= shift.requiredWorkerCount + ? 'FILLED' + : 'OPEN', + statusColor: shift.assignedWorkerCount >= shift.requiredWorkerCount ? UiColors.success - : shift.status == 'IN_PROGRESS' - ? UiColors.textWarning - : UiColors.primary, + : UiColors.textWarning, )), const SizedBox(height: 100), diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart index cd6ef84b..5856b82e 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart @@ -1,9 +1,7 @@ -// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs import 'package:client_reports/src/presentation/blocs/forecast/forecast_bloc.dart'; import 'package:client_reports/src/presentation/blocs/forecast/forecast_event.dart'; import 'package:client_reports/src/presentation/blocs/forecast/forecast_state.dart'; -import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; +import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart'; import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:fl_chart/fl_chart.dart'; @@ -11,10 +9,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:intl/intl.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; -import '../widgets/report_detail_skeleton.dart'; - +/// Page displaying the staffing and spend forecast report. class ForecastReportPage extends StatefulWidget { + /// Creates a [ForecastReportPage]. const ForecastReportPage({super.key}); @override @@ -23,11 +23,11 @@ class ForecastReportPage extends StatefulWidget { class _ForecastReportPageState extends State { final DateTime _startDate = DateTime.now(); - final DateTime _endDate = DateTime.now().add(const Duration(days: 28)); // 4 weeks + final DateTime _endDate = DateTime.now().add(const Duration(days: 28)); @override Widget build(BuildContext context) { - return BlocProvider( + return BlocProvider( create: (BuildContext context) => Modular.get() ..add(LoadForecastReport(startDate: _startDate, endDate: _endDate)), child: Scaffold( @@ -46,10 +46,7 @@ class _ForecastReportPageState extends State { return SingleChildScrollView( child: Column( children: [ - // Header _buildHeader(context), - - // Content Transform.translate( offset: const Offset(0, -20), child: Padding( @@ -57,37 +54,36 @@ class _ForecastReportPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Metrics Grid _buildMetricsGrid(context, report), const SizedBox(height: 16), - - // Chart Section _buildChartSection(context, report), const SizedBox(height: 24), - - // Weekly Breakdown Title Text( - context.t.client_reports.forecast_report.weekly_breakdown.title, - style: UiTypography.titleUppercase2m.textSecondary, + context.t.client_reports.forecast_report + .weekly_breakdown.title, + style: + UiTypography.titleUppercase2m.textSecondary, ), const SizedBox(height: 12), - - // Weekly Breakdown List - if (report.weeklyBreakdown.isEmpty) + if (report.weeks.isEmpty) Center( child: Padding( padding: const EdgeInsets.all(32.0), child: Text( - context.t.client_reports.forecast_report.empty_state, + context.t.client_reports.forecast_report + .empty_state, style: UiTypography.body2r.textSecondary, ), ), ) else - ...report.weeklyBreakdown.map( - (ForecastWeek week) => _WeeklyBreakdownItem(week: week), - ), - + ...report.weeks.asMap().entries.map( + (MapEntry entry) => + _WeeklyBreakdownItem( + week: entry.value, + weekIndex: entry.key + 1, + ), + ), const SizedBox(height: UiConstants.space24), ], ), @@ -106,84 +102,60 @@ class _ForecastReportPageState extends State { Widget _buildHeader(BuildContext context) { return Container( - padding: const EdgeInsets.only( - top: 60, - left: 20, - right: 20, - bottom: 40, - ), + padding: const EdgeInsets.only(top: 60, left: 20, right: 20, bottom: 40), decoration: const BoxDecoration( - color: UiColors.primary, gradient: LinearGradient( - colors: [UiColors.primary, Color(0xFF0020A0)], // Deep blue gradient + colors: [UiColors.primary, Color(0xFF0020A0)], begin: Alignment.topLeft, end: Alignment.bottomRight, ), ), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - children: [ - GestureDetector( - onTap: () => Modular.to.popSafe(), - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: UiColors.white.withOpacity(0.2), - shape: BoxShape.circle, - ), - child: const Icon( - UiIcons.arrowLeft, - color: UiColors.white, - size: 20, - ), - ), + GestureDetector( + onTap: () => Modular.to.popSafe(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withOpacity(0.2), + shape: BoxShape.circle, ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.t.client_reports.forecast_report.title, - style: UiTypography.headline3m.copyWith(color: UiColors.white), - ), - Text( - context.t.client_reports.forecast_report.subtitle, - style: UiTypography.body2m.copyWith( - color: UiColors.white.withOpacity(0.7), - ), - ), - ], + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_reports.forecast_report.title, + style: + UiTypography.headline3m.copyWith(color: UiColors.white), + ), + Text( + context.t.client_reports.forecast_report.subtitle, + style: UiTypography.body2m.copyWith( + color: UiColors.white.withOpacity(0.7), + ), ), ], ), -/* - UiButton.secondary( - text: context.t.client_reports.forecast_report.buttons.export, - leadingIcon: UiIcons.download, - onPressed: () { - // Placeholder export action - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.t.client_reports.forecast_report.placeholders.export_message), - ), - ); - }, - - // If button variants are limited, we might need a custom button or adjust design system usage - // Since I can't easily see UiButton implementation details beyond exports, I'll stick to a standard usage. - // If UiButton doesn't look right on blue bg, I count rely on it being white/transparent based on tokens. - ), -*/ ], ), ); } Widget _buildMetricsGrid(BuildContext context, ForecastReport report) { - final TranslationsClientReportsForecastReportEn t = context.t.client_reports.forecast_report; + final TranslationsClientReportsForecastReportEn t = + context.t.client_reports.forecast_report; + final NumberFormat currFmt = + NumberFormat.currency(symbol: r'$', decimalDigits: 0); + return GridView.count( crossAxisCount: 2, padding: EdgeInsets.zero, @@ -196,31 +168,31 @@ class _ForecastReportPageState extends State { _MetricCard( icon: UiIcons.dollar, label: t.metrics.four_week_forecast, - value: NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(report.projectedSpend), + value: currFmt.format(report.forecastSpendCents / 100), badgeText: t.badges.total_projected, iconColor: UiColors.textWarning, - badgeColor: UiColors.tagPending, // Yellow-ish + badgeColor: UiColors.tagPending, ), _MetricCard( icon: UiIcons.trendingUp, label: t.metrics.avg_weekly, - value: NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(report.avgWeeklySpend), + value: currFmt.format(report.averageWeeklySpendCents / 100), badgeText: t.badges.per_week, iconColor: UiColors.primary, - badgeColor: UiColors.tagInProgress, // Blue-ish + badgeColor: UiColors.tagInProgress, ), _MetricCard( icon: UiIcons.calendar, label: t.metrics.total_shifts, value: report.totalShifts.toString(), badgeText: t.badges.scheduled, - iconColor: const Color(0xFF9333EA), // Purple - badgeColor: const Color(0xFFF3E8FF), // Purple light + iconColor: const Color(0xFF9333EA), + badgeColor: const Color(0xFFF3E8FF), ), _MetricCard( icon: UiIcons.users, label: t.metrics.total_hours, - value: report.totalHours.toStringAsFixed(0), + value: report.totalWorkerHours.toStringAsFixed(0), badgeText: t.badges.worker_hours, iconColor: UiColors.success, badgeColor: UiColors.tagSuccess, @@ -248,29 +220,25 @@ class _ForecastReportPageState extends State { children: [ Text( context.t.client_reports.forecast_report.chart_title, - style: UiTypography.headline4m, + style: UiTypography.headline4m, ), - const SizedBox(height: 8), - Text( - r'$15k', // Example Y-axis label placeholder or dynamic max - style: UiTypography.footnote1r.textSecondary, - ), - const SizedBox(height: 24), + const SizedBox(height: 32), Expanded( - child: _ForecastChart(points: report.chartData), + child: _ForecastChart(weeks: report.weeks), ), const SizedBox(height: 8), - // X Axis labels manually if chart doesn't handle them perfectly or for custom look - const Row( + Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('W1', style: TextStyle(color: UiColors.textSecondary, fontSize: 12)), - Text('W1', style: TextStyle(color: UiColors.transparent, fontSize: 12)), // Spacer - Text('W2', style: TextStyle(color: UiColors.textSecondary, fontSize: 12)), - Text('W2', style: TextStyle(color: UiColors.transparent, fontSize: 12)), // Spacer - Text('W3', style: TextStyle(color: UiColors.textSecondary, fontSize: 12)), - Text('W3', style: TextStyle(color: UiColors.transparent, fontSize: 12)), // Spacer - Text('W4', style: TextStyle(color: UiColors.textSecondary, fontSize: 12)), + for (int i = 0; i < report.weeks.length; i++) ...[ + Text('W${i + 1}', + style: const TextStyle( + color: UiColors.textSecondary, fontSize: 12)), + if (i < report.weeks.length - 1) + const Text('', + style: TextStyle( + color: UiColors.transparent, fontSize: 12)), + ], ], ), ], @@ -280,7 +248,6 @@ class _ForecastReportPageState extends State { } class _MetricCard extends StatelessWidget { - const _MetricCard({ required this.icon, required this.label, @@ -289,6 +256,7 @@ class _MetricCard extends StatelessWidget { required this.iconColor, required this.badgeColor, }); + final IconData icon; final String label; final String value; @@ -329,7 +297,8 @@ class _MetricCard extends StatelessWidget { ), Text( value, - style: UiTypography.headline3m.copyWith(fontWeight: FontWeight.bold), + style: + UiTypography.headline3m.copyWith(fontWeight: FontWeight.bold), ), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), @@ -340,7 +309,7 @@ class _MetricCard extends StatelessWidget { child: Text( badgeText, style: UiTypography.footnote1r.copyWith( - color: UiColors.textPrimary, // Or specific text color + color: UiColors.textPrimary, fontSize: 10, fontWeight: FontWeight.w600, ), @@ -352,15 +321,23 @@ class _MetricCard extends StatelessWidget { } } +/// Weekly breakdown item using V2 [ForecastWeek] fields. class _WeeklyBreakdownItem extends StatelessWidget { + const _WeeklyBreakdownItem({ + required this.week, + required this.weekIndex, + }); - const _WeeklyBreakdownItem({required this.week}); final ForecastWeek week; + final int weekIndex; @override Widget build(BuildContext context) { - final TranslationsClientReportsForecastReportWeeklyBreakdownEn t = context.t.client_reports.forecast_report.weekly_breakdown; - + final TranslationsClientReportsForecastReportWeeklyBreakdownEn t = + context.t.client_reports.forecast_report.weekly_breakdown; + final NumberFormat currFmt = + NumberFormat.currency(symbol: r'$', decimalDigits: 0); + return Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(16), @@ -374,17 +351,18 @@ class _WeeklyBreakdownItem extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - t.week(index: week.weekNumber), + t.week(index: weekIndex), style: UiTypography.headline4m, ), Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), decoration: BoxDecoration( color: UiColors.tagPending, borderRadius: BorderRadius.circular(8), ), child: Text( - NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(week.totalCost), + currFmt.format(week.forecastSpendCents / 100), style: UiTypography.body2b.copyWith( color: UiColors.textWarning, ), @@ -396,9 +374,11 @@ class _WeeklyBreakdownItem extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _buildStat(t.shifts, week.shiftsCount.toString()), - _buildStat(t.hours, week.hoursCount.toStringAsFixed(0)), - _buildStat(t.avg_shift, NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(week.avgCostPerShift)), + _buildStat(t.shifts, week.shiftCount.toString()), + _buildStat(t.hours, week.workerHours.toStringAsFixed(0)), + _buildStat( + t.avg_shift, + currFmt.format(week.averageShiftCostCents / 100)), ], ), ], @@ -418,24 +398,24 @@ class _WeeklyBreakdownItem extends StatelessWidget { } } +/// Line chart using [ForecastWeek] data (dollars from cents). class _ForecastChart extends StatelessWidget { + const _ForecastChart({required this.weeks}); - const _ForecastChart({required this.points}); - final List points; + final List weeks; @override Widget build(BuildContext context) { - // If no data, show empty or default line? - if (points.isEmpty) return const SizedBox(); + if (weeks.isEmpty) return const SizedBox(); return LineChart( LineChartData( gridData: FlGridData( show: true, drawVerticalLine: false, - horizontalInterval: 5000, // Dynamic? + horizontalInterval: 5000, getDrawingHorizontalLine: (double value) { - return const FlLine( + return const FlLine( color: UiColors.borderInactive, strokeWidth: 1, dashArray: [5, 5], @@ -445,31 +425,34 @@ class _ForecastChart extends StatelessWidget { titlesData: const FlTitlesData(show: false), borderData: FlBorderData(show: false), minX: 0, - maxX: points.length.toDouble() - 1, - // minY: 0, // Let it scale automatically + maxX: weeks.length.toDouble() - 1, lineBarsData: [ LineChartBarData( - spots: points.asMap().entries.map((MapEntry e) { - return FlSpot(e.key.toDouble(), e.value.projectedCost); - }).toList(), + spots: weeks + .asMap() + .entries + .map((MapEntry e) => FlSpot( + e.key.toDouble(), e.value.forecastSpendCents / 100)) + .toList(), isCurved: true, - color: UiColors.textWarning, // Orange-ish + color: UiColors.textWarning, barWidth: 4, isStrokeCapRound: true, dotData: FlDotData( show: true, - getDotPainter: (FlSpot spot, double percent, LineChartBarData barData, int index) { - return FlDotCirclePainter( - radius: 4, - color: UiColors.textWarning, - strokeWidth: 2, - strokeColor: UiColors.white, - ); + getDotPainter: (FlSpot spot, double percent, + LineChartBarData barData, int index) { + return FlDotCirclePainter( + radius: 4, + color: UiColors.textWarning, + strokeWidth: 2, + strokeColor: UiColors.white, + ); }, ), belowBarData: BarAreaData( show: true, - color: UiColors.tagPending.withOpacity(0.5), // Light orange fill + color: UiColors.tagPending.withOpacity(0.5), ), ), ], @@ -477,4 +460,3 @@ class _ForecastChart extends StatelessWidget { ); } } - diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart index 6ba6a336..0f731caf 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart @@ -11,7 +11,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:intl/intl.dart'; -import '../widgets/report_detail_skeleton.dart'; +import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart'; class NoShowReportPage extends StatefulWidget { const NoShowReportPage({super.key}); @@ -42,7 +42,7 @@ class _NoShowReportPageState extends State { if (state is NoShowLoaded) { final NoShowReport report = state.report; - final int uniqueWorkers = report.flaggedWorkers.length; + final int uniqueWorkers = report.workersWhoNoShowed; return SingleChildScrollView( child: Column( children: [ @@ -167,7 +167,7 @@ class _NoShowReportPageState extends State { icon: UiIcons.warning, iconColor: UiColors.error, label: context.t.client_reports.no_show_report.metrics.no_shows, - value: report.totalNoShows.toString(), + value: report.totalNoShowCount.toString(), ), ), const SizedBox(width: 12), @@ -177,7 +177,7 @@ class _NoShowReportPageState extends State { iconColor: UiColors.textWarning, label: context.t.client_reports.no_show_report.metrics.rate, value: - '${report.noShowRate.toStringAsFixed(1)}%', + '${report.noShowRatePercentage}%', ), ), const SizedBox(width: 12), @@ -208,7 +208,7 @@ class _NoShowReportPageState extends State { const SizedBox(height: 16), // Worker cards with risk badges - if (report.flaggedWorkers.isEmpty) + if (report.items.isEmpty) Container( padding: const EdgeInsets.all(40), alignment: Alignment.center, @@ -220,8 +220,8 @@ class _NoShowReportPageState extends State { ), ) else - ...report.flaggedWorkers.map( - (NoShowWorker worker) => _WorkerCard(worker: worker), + ...report.items.map( + (NoShowWorkerItem worker) => _WorkerCard(worker: worker), ), const SizedBox(height: 40), @@ -309,31 +309,31 @@ class _SummaryChip extends StatelessWidget { class _WorkerCard extends StatelessWidget { const _WorkerCard({required this.worker}); - final NoShowWorker worker; + final NoShowWorkerItem worker; - String _riskLabel(BuildContext context, int count) { - if (count >= 3) return context.t.client_reports.no_show_report.risks.high; - if (count == 2) return context.t.client_reports.no_show_report.risks.medium; + String _riskLabel(BuildContext context, String riskStatus) { + if (riskStatus == 'HIGH') return context.t.client_reports.no_show_report.risks.high; + if (riskStatus == 'MEDIUM') return context.t.client_reports.no_show_report.risks.medium; return context.t.client_reports.no_show_report.risks.low; } - Color _riskColor(int count) { - if (count >= 3) return UiColors.error; - if (count == 2) return UiColors.textWarning; + Color _riskColor(String riskStatus) { + if (riskStatus == 'HIGH') return UiColors.error; + if (riskStatus == 'MEDIUM') return UiColors.textWarning; return UiColors.success; } - Color _riskBg(int count) { - if (count >= 3) return UiColors.tagError; - if (count == 2) return UiColors.tagPending; + Color _riskBg(String riskStatus) { + if (riskStatus == 'HIGH') return UiColors.tagError; + if (riskStatus == 'MEDIUM') return UiColors.tagPending; return UiColors.tagSuccess; } @override Widget build(BuildContext context) { - final String riskLabel = _riskLabel(context, worker.noShowCount); - final Color riskColor = _riskColor(worker.noShowCount); - final Color riskBg = _riskBg(worker.noShowCount); + final String riskLabel = _riskLabel(context, worker.riskStatus); + final Color riskColor = _riskColor(worker.riskStatus); + final Color riskBg = _riskBg(worker.riskStatus); return Container( margin: const EdgeInsets.only(bottom: 12), @@ -373,7 +373,7 @@ class _WorkerCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - worker.fullName, + worker.staffName, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 14, @@ -381,7 +381,7 @@ class _WorkerCard extends StatelessWidget { ), ), Text( - context.t.client_reports.no_show_report.no_show_count(count: worker.noShowCount.toString()), + context.t.client_reports.no_show_report.no_show_count(count: worker.incidentCount.toString()), style: const TextStyle( fontSize: 12, color: UiColors.textSecondary, @@ -426,14 +426,10 @@ class _WorkerCard extends StatelessWidget { ), ), Text( - // Use reliabilityScore as a proxy for last incident date offset - DateFormat('MMM dd, yyyy').format( - DateTime.now().subtract( - Duration( - days: ((1.0 - worker.reliabilityScore) * 60).round(), - ), - ), - ), + worker.incidents.isNotEmpty + ? DateFormat('MMM dd, yyyy') + .format(worker.incidents.first.date) + : '-', style: const TextStyle( fontSize: 11, color: UiColors.textSecondary, diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart index eb6f3a90..9c1c02e8 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart @@ -9,7 +9,7 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../widgets/report_detail_skeleton.dart'; +import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart'; class PerformanceReportPage extends StatefulWidget { const PerformanceReportPage({super.key}); @@ -41,14 +41,22 @@ class _PerformanceReportPageState extends State { if (state is PerformanceLoaded) { final PerformanceReport report = state.report; - // Compute overall score (0 - 100) from the 4 KPIs - final double overallScore = ((report.fillRate * 0.3) + - (report.completionRate * 0.3) + - (report.onTimeRate * 0.25) + - // avg fill time: 3h target invert to score - ((report.avgFillTimeHours <= 3 + // Convert V2 fields to local doubles for scoring. + final double fillRate = report.fillRatePercentage.toDouble(); + final double completionRate = + report.completionRatePercentage.toDouble(); + final double onTimeRate = + report.onTimeRatePercentage.toDouble(); + final double avgFillTimeHours = + report.averageFillTimeMinutes / 60; + + // Compute overall score (0 - 100) from the 4 KPIs. + final double overallScore = ((fillRate * 0.3) + + (completionRate * 0.3) + + (onTimeRate * 0.25) + + ((avgFillTimeHours <= 3 ? 100 - : (3 / report.avgFillTimeHours) * 100) * + : (3 / avgFillTimeHours) * 100) * 0.15)) .clamp(0.0, 100.0); @@ -75,48 +83,47 @@ class _PerformanceReportPageState extends State { iconColor: UiColors.primary, label: context.t.client_reports.performance_report.kpis.fill_rate, target: context.t.client_reports.performance_report.kpis.target_percent(percent: '95'), - value: report.fillRate, - displayValue: '${report.fillRate.toStringAsFixed(0)}%', + value: fillRate, + displayValue: '${fillRate.toStringAsFixed(0)}%', barColor: UiColors.primary, - met: report.fillRate >= 95, - close: report.fillRate >= 90, + met: fillRate >= 95, + close: fillRate >= 90, ), _KpiData( icon: UiIcons.checkCircle, iconColor: UiColors.success, label: context.t.client_reports.performance_report.kpis.completion_rate, target: context.t.client_reports.performance_report.kpis.target_percent(percent: '98'), - value: report.completionRate, - displayValue: '${report.completionRate.toStringAsFixed(0)}%', + value: completionRate, + displayValue: '${completionRate.toStringAsFixed(0)}%', barColor: UiColors.success, - met: report.completionRate >= 98, - close: report.completionRate >= 93, + met: completionRate >= 98, + close: completionRate >= 93, ), _KpiData( icon: UiIcons.clock, iconColor: const Color(0xFF9B59B6), label: context.t.client_reports.performance_report.kpis.on_time_rate, target: context.t.client_reports.performance_report.kpis.target_percent(percent: '97'), - value: report.onTimeRate, - displayValue: '${report.onTimeRate.toStringAsFixed(0)}%', + value: onTimeRate, + displayValue: '${onTimeRate.toStringAsFixed(0)}%', barColor: const Color(0xFF9B59B6), - met: report.onTimeRate >= 97, - close: report.onTimeRate >= 92, + met: onTimeRate >= 97, + close: onTimeRate >= 92, ), _KpiData( icon: UiIcons.trendingUp, iconColor: const Color(0xFFF39C12), label: context.t.client_reports.performance_report.kpis.avg_fill_time, target: context.t.client_reports.performance_report.kpis.target_hours(hours: '3'), - // invert: lower is better show as % of target met - value: report.avgFillTimeHours == 0 + value: avgFillTimeHours == 0 ? 100 - : (3 / report.avgFillTimeHours * 100).clamp(0, 100), + : (3 / avgFillTimeHours * 100).clamp(0, 100), displayValue: - '${report.avgFillTimeHours.toStringAsFixed(1)} hrs', + '${avgFillTimeHours.toStringAsFixed(1)} hrs', barColor: const Color(0xFFF39C12), - met: report.avgFillTimeHours <= 3, - close: report.avgFillTimeHours <= 4, + met: avgFillTimeHours <= 3, + close: avgFillTimeHours <= 4, ), ]; diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/reports_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/reports_page.dart index 79212649..405666dc 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/reports_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/reports_page.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import '../widgets/reports_page/index.dart'; +import 'package:client_reports/src/presentation/widgets/reports_page/index.dart'; /// The main Reports page for the client application. /// diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart index af3265e2..fbcd3c38 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart @@ -1,6 +1,7 @@ -import 'package:client_reports/src/presentation/blocs/spend/spend_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/spend/spend_bloc.dart'; import 'package:client_reports/src/presentation/blocs/spend/spend_event.dart'; import 'package:client_reports/src/presentation/blocs/spend/spend_state.dart'; +import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart'; import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:fl_chart/fl_chart.dart'; @@ -11,9 +12,9 @@ import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../widgets/report_detail_skeleton.dart'; - +/// Page displaying the spend report with chart and category breakdown. class SpendReportPage extends StatefulWidget { + /// Creates a [SpendReportPage]. const SpendReportPage({super.key}); @override @@ -28,11 +29,11 @@ class _SpendReportPageState extends State { void initState() { super.initState(); final DateTime now = DateTime.now(); - // Monday alignment logic final int diff = now.weekday - DateTime.monday; final DateTime monday = now.subtract(Duration(days: diff)); _startDate = DateTime(monday.year, monday.month, monday.day); - _endDate = _startDate.add(const Duration(days: 6, hours: 23, minutes: 59, seconds: 59)); + _endDate = _startDate + .add(const Duration(days: 6, hours: 23, minutes: 59, seconds: 59)); } @override @@ -53,6 +54,11 @@ class _SpendReportPageState extends State { if (state is SpendLoaded) { final SpendReport report = state.report; + final double totalSpendDollars = report.totalSpendCents / 100; + final int dayCount = + report.chart.isNotEmpty ? report.chart.length : 1; + final double avgDailyDollars = totalSpendDollars / dayCount; + return SingleChildScrollView( child: Column( children: [ @@ -62,124 +68,72 @@ class _SpendReportPageState extends State { top: 60, left: 20, right: 20, - bottom: 80, // Overlap space + bottom: 80, ), decoration: const BoxDecoration( - color: UiColors.primary, // Blue background per prototype + color: UiColors.primary, ), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - children: [ - GestureDetector( - onTap: () => Modular.to.popSafe(), - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: UiColors.white.withOpacity(0.2), - shape: BoxShape.circle, - ), - child: const Icon( - UiIcons.arrowLeft, - color: UiColors.white, - size: 20, - ), - ), - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.t.client_reports.spend_report.title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: UiColors.white, - ), - ), - Text( - context.t.client_reports.spend_report - .subtitle, - style: TextStyle( - fontSize: 12, - color: UiColors.white.withOpacity(0.7), - ), - ), - ], - ), - ], - ), -/* GestureDetector( - onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.t.client_reports.spend_report - .placeholders.export_message, - ), - duration: const Duration(seconds: 2), - ), - ); - }, + onTap: () => Modular.to.popSafe(), child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), + width: 40, + height: 40, decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(8), + color: UiColors.white.withOpacity(0.2), + shape: BoxShape.circle, ), - child: Row( - children: [ - const Icon( - UiIcons.download, - size: 14, - color: UiColors.primary, - ), - const SizedBox(width: 6), - Text( - context.t.client_reports.quick_reports - .export_all - .split(' ') - .first, - style: const TextStyle( - color: UiColors.primary, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ], + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 20, ), ), ), -*/ + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_reports.spend_report.title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: UiColors.white, + ), + ), + Text( + context.t.client_reports.spend_report.subtitle, + style: TextStyle( + fontSize: 12, + color: UiColors.white.withOpacity(0.7), + ), + ), + ], + ), ], ), ), // Content Transform.translate( - offset: const Offset(0, -60), // Pull up to overlap + offset: const Offset(0, -60), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Summary Cards (New Style) + // Summary Cards Row( children: [ Expanded( child: _SpendStatCard( - label: context.t.client_reports.spend_report - .summary.total_spend, + label: context.t.client_reports + .spend_report.summary.total_spend, value: NumberFormat.currency( symbol: r'$', decimalDigits: 0) - .format(report.totalSpend), + .format(totalSpendDollars), pillText: context.t.client_reports .spend_report.summary.this_week, themeColor: UiColors.success, @@ -189,11 +143,11 @@ class _SpendReportPageState extends State { const SizedBox(width: 12), Expanded( child: _SpendStatCard( - label: context.t.client_reports.spend_report - .summary.avg_daily, + label: context.t.client_reports + .spend_report.summary.avg_daily, value: NumberFormat.currency( symbol: r'$', decimalDigits: 0) - .format(report.averageCost), + .format(avgDailyDollars), pillText: context.t.client_reports .spend_report.summary.per_day, themeColor: UiColors.primary, @@ -223,7 +177,8 @@ class _SpendReportPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - context.t.client_reports.spend_report.chart_title, + context.t.client_reports.spend_report + .chart_title, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.bold, @@ -233,7 +188,7 @@ class _SpendReportPageState extends State { const SizedBox(height: 32), Expanded( child: _SpendBarChart( - chartData: report.chartData), + chartData: report.chart), ), ], ), @@ -241,9 +196,9 @@ class _SpendReportPageState extends State { const SizedBox(height: 24), - // Spend by Industry - _SpendByIndustryCard( - industries: report.industryBreakdown, + // Spend by Category + _SpendByCategoryCard( + categories: report.breakdown, ), const SizedBox(height: 100), @@ -263,25 +218,31 @@ class _SpendReportPageState extends State { } } +/// Bar chart rendering [SpendDataPoint] entries (cents converted to dollars). class _SpendBarChart extends StatelessWidget { - const _SpendBarChart({required this.chartData}); - final List chartData; + + final List chartData; @override Widget build(BuildContext context) { + if (chartData.isEmpty) return const SizedBox(); + + final double maxDollars = chartData.fold( + 0, + (double prev, SpendDataPoint p) => + (p.amountCents / 100) > prev ? p.amountCents / 100 : prev) * + 1.2; + return BarChart( BarChartData( alignment: BarChartAlignment.spaceAround, - maxY: (chartData.fold(0, - (double prev, element) => - element.amount > prev ? element.amount : prev) * - 1.2) - .ceilToDouble(), + maxY: maxDollars.ceilToDouble(), barTouchData: BarTouchData( touchTooltipData: BarTouchTooltipData( tooltipPadding: const EdgeInsets.all(8), - getTooltipItem: (BarChartGroupData group, int groupIndex, BarChartRodData rod, int rodIndex) { + getTooltipItem: (BarChartGroupData group, int groupIndex, + BarChartRodData rod, int rodIndex) { return BarTooltipItem( '\$${rod.toY.round()}', const TextStyle( @@ -299,8 +260,10 @@ class _SpendBarChart extends StatelessWidget { showTitles: true, reservedSize: 30, getTitlesWidget: (double value, TitleMeta meta) { - if (value.toInt() >= chartData.length) return const SizedBox(); - final date = chartData[value.toInt()].date; + if (value.toInt() >= chartData.length) { + return const SizedBox(); + } + final DateTime date = chartData[value.toInt()].bucket; return SideTitleWidget( axisSide: meta.axisSide, space: 8, @@ -334,12 +297,10 @@ class _SpendBarChart extends StatelessWidget { }, ), ), - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - rightTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), + topTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), ), gridData: FlGridData( show: true, @@ -351,13 +312,13 @@ class _SpendBarChart extends StatelessWidget { ), ), borderData: FlBorderData(show: false), - barGroups: List.generate( + barGroups: List.generate( chartData.length, (int index) => BarChartGroupData( x: index, barRods: [ BarChartRodData( - toY: chartData[index].amount, + toY: chartData[index].amountCents / 100, color: UiColors.success, width: 12, borderRadius: const BorderRadius.vertical( @@ -373,7 +334,6 @@ class _SpendBarChart extends StatelessWidget { } class _SpendStatCard extends StatelessWidget { - const _SpendStatCard({ required this.label, required this.value, @@ -381,6 +341,7 @@ class _SpendStatCard extends StatelessWidget { required this.themeColor, required this.icon, }); + final String label; final String value; final String pillText; @@ -454,10 +415,11 @@ class _SpendStatCard extends StatelessWidget { } } -class _SpendByIndustryCard extends StatelessWidget { +/// Card showing spend breakdown by category using [SpendItem]. +class _SpendByCategoryCard extends StatelessWidget { + const _SpendByCategoryCard({required this.categories}); - const _SpendByIndustryCard({required this.industries}); - final List industries; + final List categories; @override Widget build(BuildContext context) { @@ -486,7 +448,7 @@ class _SpendByIndustryCard extends StatelessWidget { ), ), const SizedBox(height: 24), - if (industries.isEmpty) + if (categories.isEmpty) Center( child: Padding( padding: const EdgeInsets.all(16.0), @@ -497,7 +459,7 @@ class _SpendByIndustryCard extends StatelessWidget { ), ) else - ...industries.map((SpendIndustryCategory ind) => Padding( + ...categories.map((SpendItem item) => Padding( padding: const EdgeInsets.only(bottom: 24.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -506,15 +468,16 @@ class _SpendByIndustryCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - ind.name, + item.category, style: const TextStyle( fontSize: 13, color: UiColors.textSecondary, ), ), Text( - NumberFormat.currency(symbol: r'$', decimalDigits: 0) - .format(ind.amount), + NumberFormat.currency( + symbol: r'$', decimalDigits: 0) + .format(item.amountCents / 100), style: const TextStyle( fontSize: 13, fontWeight: FontWeight.bold, @@ -527,7 +490,7 @@ class _SpendByIndustryCard extends StatelessWidget { ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( - value: ind.percentage / 100, + value: item.percentage / 100, backgroundColor: UiColors.bgSecondary, color: UiColors.success, minHeight: 6, @@ -535,7 +498,8 @@ class _SpendByIndustryCard extends StatelessWidget { ), const SizedBox(height: 6), Text( - context.t.client_reports.spend_report.percent_total(percent: ind.percentage.toStringAsFixed(1)), + context.t.client_reports.spend_report.percent_total( + percent: item.percentage.toStringAsFixed(1)), style: const TextStyle( fontSize: 10, color: UiColors.textDescription, @@ -549,4 +513,3 @@ class _SpendByIndustryCard extends StatelessWidget { ); } } - diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid.dart index 91566e93..4436b5c6 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid.dart @@ -7,21 +7,22 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'metric_card.dart'; -import 'metrics_grid_skeleton.dart'; +import 'package:client_reports/src/presentation/widgets/reports_page/metric_card.dart'; +import 'package:client_reports/src/presentation/widgets/reports_page/metrics_grid_skeleton.dart'; -/// A grid of key metrics driven by the ReportsSummaryBloc. +/// A grid of key metrics driven by the [ReportsSummaryBloc]. /// /// Displays 6 metrics in a 2-column grid: -/// - Total Hours -/// - OT Hours -/// - Total Spend -/// - Fill Rate -/// - Average Fill Time -/// - No-Show Rate +/// - Total Shifts +/// - Total Spend (from cents) +/// - Avg Coverage % +/// - Performance Score +/// - No-Show Count +/// - Forecast Accuracy % /// /// Handles loading, error, and success states. class MetricsGrid extends StatelessWidget { + /// Creates a [MetricsGrid]. const MetricsGrid({super.key}); @override @@ -48,7 +49,8 @@ class MetricsGrid extends StatelessWidget { Expanded( child: Text( state.message, - style: const TextStyle(color: UiColors.error, fontSize: 12), + style: + const TextStyle(color: UiColors.error, fontSize: 12), ), ), ], @@ -57,9 +59,11 @@ class MetricsGrid extends StatelessWidget { } // Loaded State - final ReportsSummary summary = (state as ReportsSummaryLoaded).summary; + final ReportSummary summary = + (state as ReportsSummaryLoaded).summary; final NumberFormat currencyFmt = - NumberFormat.currency(symbol: '\$', decimalDigits: 0); + NumberFormat.currency(symbol: r'$', decimalDigits: 0); + final double totalSpendDollars = summary.totalSpendCents / 100; return GridView.count( padding: const EdgeInsets.symmetric( @@ -72,70 +76,70 @@ class MetricsGrid extends StatelessWidget { crossAxisSpacing: 12, childAspectRatio: 1.32, children: [ - // Total Hour + // Total Shifts MetricCard( icon: UiIcons.clock, label: context.t.client_reports.metrics.total_hrs.label, - value: summary.totalHours >= 1000 - ? '${(summary.totalHours / 1000).toStringAsFixed(1)}k' - : summary.totalHours.toStringAsFixed(0), + value: summary.totalShifts >= 1000 + ? '${(summary.totalShifts / 1000).toStringAsFixed(1)}k' + : summary.totalShifts.toString(), badgeText: context.t.client_reports.metrics.total_hrs.badge, badgeColor: UiColors.tagRefunded, badgeTextColor: UiColors.primary, iconColor: UiColors.primary, ), - // OT Hours + // Coverage % MetricCard( icon: UiIcons.trendingUp, label: context.t.client_reports.metrics.ot_hours.label, - value: summary.otHours.toStringAsFixed(0), + value: '${summary.averageCoveragePercentage}%', badgeText: context.t.client_reports.metrics.ot_hours.badge, badgeColor: UiColors.tagValue, badgeTextColor: UiColors.textSecondary, iconColor: UiColors.textWarning, ), - // Total Spend + // Total Spend (from cents) MetricCard( icon: UiIcons.dollar, label: context.t.client_reports.metrics.total_spend.label, - value: summary.totalSpend >= 1000 - ? '\$${(summary.totalSpend / 1000).toStringAsFixed(1)}k' - : currencyFmt.format(summary.totalSpend), + value: totalSpendDollars >= 1000 + ? '\$${(totalSpendDollars / 1000).toStringAsFixed(1)}k' + : currencyFmt.format(totalSpendDollars), badgeText: context.t.client_reports.metrics.total_spend.badge, badgeColor: UiColors.tagSuccess, badgeTextColor: UiColors.textSuccess, iconColor: UiColors.success, ), - // Fill Rate + // Performance Score MetricCard( icon: UiIcons.trendingUp, label: context.t.client_reports.metrics.fill_rate.label, - value: '${summary.fillRate.toStringAsFixed(0)}%', + value: summary.averagePerformanceScore.toStringAsFixed(1), badgeText: context.t.client_reports.metrics.fill_rate.badge, badgeColor: UiColors.tagInProgress, badgeTextColor: UiColors.textLink, iconColor: UiColors.iconActive, ), - // Average Fill Time + // Forecast Accuracy % MetricCard( icon: UiIcons.clock, label: context.t.client_reports.metrics.avg_fill_time.label, - value: '${summary.avgFillTimeHours.toStringAsFixed(1)} hrs', + value: '${summary.forecastAccuracyPercentage}%', badgeText: context.t.client_reports.metrics.avg_fill_time.badge, badgeColor: UiColors.tagInProgress, badgeTextColor: UiColors.textLink, iconColor: UiColors.iconActive, ), - // No-Show Rate + // No-Show Count MetricCard( icon: UiIcons.warning, label: context.t.client_reports.metrics.no_show_rate.label, - value: '${summary.noShowRate.toStringAsFixed(1)}%', + value: summary.noShowCount.toString(), badgeText: context.t.client_reports.metrics.no_show_rate.badge, - badgeColor: summary.noShowRate < 5 + badgeColor: summary.noShowCount < 5 ? UiColors.tagSuccess : UiColors.tagError, - badgeTextColor: summary.noShowRate < 5 + badgeTextColor: summary.noShowCount < 5 ? UiColors.textSuccess : UiColors.error, iconColor: UiColors.destructive, diff --git a/apps/mobile/packages/features/client/reports/lib/src/reports_module.dart b/apps/mobile/packages/features/client/reports/lib/src/reports_module.dart index 9042127e..9bdc8fb6 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/reports_module.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/reports_module.dart @@ -1,30 +1,33 @@ -// 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:client_reports/src/data/repositories_impl/reports_repository_impl.dart'; import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.dart'; import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_bloc.dart'; import 'package:client_reports/src/presentation/blocs/forecast/forecast_bloc.dart'; import 'package:client_reports/src/presentation/blocs/no_show/no_show_bloc.dart'; import 'package:client_reports/src/presentation/blocs/performance/performance_bloc.dart'; import 'package:client_reports/src/presentation/blocs/spend/spend_bloc.dart'; import 'package:client_reports/src/presentation/blocs/summary/reports_summary_bloc.dart'; +import 'package:client_reports/src/presentation/pages/coverage_report_page.dart'; import 'package:client_reports/src/presentation/pages/daily_ops_report_page.dart'; import 'package:client_reports/src/presentation/pages/forecast_report_page.dart'; import 'package:client_reports/src/presentation/pages/no_show_report_page.dart'; import 'package:client_reports/src/presentation/pages/performance_report_page.dart'; import 'package:client_reports/src/presentation/pages/reports_page.dart'; import 'package:client_reports/src/presentation/pages/spend_report_page.dart'; -import 'package:client_reports/src/presentation/pages/coverage_report_page.dart'; -import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +/// Feature module for the client reports section. class ReportsModule extends Module { @override - List get imports => [DataConnectModule()]; + List get imports => [CoreModule()]; @override void binds(Injector i) { - i.addLazySingleton(ReportsRepositoryImpl.new); + i.addLazySingleton( + () => ReportsRepositoryImpl(apiService: i.get()), + ); i.add(DailyOpsBloc.new); i.add(SpendBloc.new); i.add(CoverageBloc.new); @@ -45,4 +48,3 @@ class ReportsModule extends Module { r.child('/no-show', child: (_) => const NoShowReportPage()); } } - diff --git a/apps/mobile/packages/features/client/reports/pubspec.yaml b/apps/mobile/packages/features/client/reports/pubspec.yaml index f4807bd9..79c9b380 100644 --- a/apps/mobile/packages/features/client/reports/pubspec.yaml +++ b/apps/mobile/packages/features/client/reports/pubspec.yaml @@ -24,8 +24,6 @@ dependencies: path: ../../../core core_localization: path: ../../../core_localization - krow_data_connect: - path: ../../../data_connect # External packages flutter_modular: ^6.3.4 diff --git a/apps/mobile/packages/features/client/settings/lib/client_settings.dart b/apps/mobile/packages/features/client/settings/lib/client_settings.dart index 05a38348..770d4216 100644 --- a/apps/mobile/packages/features/client/settings/lib/client_settings.dart +++ b/apps/mobile/packages/features/client/settings/lib/client_settings.dart @@ -1,6 +1,7 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_domain/krow_domain.dart'; + import 'src/data/repositories_impl/settings_repository_impl.dart'; import 'src/domain/repositories/settings_repository_interface.dart'; import 'src/domain/usecases/sign_out_usecase.dart'; @@ -9,14 +10,19 @@ import 'src/presentation/pages/client_settings_page.dart'; import 'src/presentation/pages/edit_profile_page.dart'; /// A [Module] for the client settings feature. +/// +/// Imports [CoreModule] for [BaseApiService] and registers repositories, +/// use cases, and BLoCs for the client settings flow. class ClientSettingsModule extends Module { @override - List get imports => [DataConnectModule()]; + List get imports => [CoreModule()]; @override void binds(Injector i) { // Repositories - i.addLazySingleton(SettingsRepositoryImpl.new); + i.addLazySingleton( + () => SettingsRepositoryImpl(apiService: i.get()), + ); // UseCases i.addLazySingleton(SignOutUseCase.new); diff --git a/apps/mobile/packages/features/client/settings/lib/src/data/repositories_impl/settings_repository_impl.dart b/apps/mobile/packages/features/client/settings/lib/src/data/repositories_impl/settings_repository_impl.dart index 7acb21ad..e620bf94 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/data/repositories_impl/settings_repository_impl.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/data/repositories_impl/settings_repository_impl.dart @@ -1,21 +1,40 @@ -import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'dart:developer' as developer; -import '../../domain/repositories/settings_repository_interface.dart'; +import 'package:firebase_auth/firebase_auth.dart' as firebase; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:client_settings/src/domain/repositories/settings_repository_interface.dart'; /// Implementation of [SettingsRepositoryInterface]. /// -/// This implementation delegates authentication operations to [DataConnectService]. +/// Uses V2 API for server-side token revocation and Firebase Auth for local +/// sign-out. Clears the [ClientSessionStore] on sign-out. class SettingsRepositoryImpl implements SettingsRepositoryInterface { - /// Creates a [SettingsRepositoryImpl] with the required [_service]. - const SettingsRepositoryImpl({required dc.DataConnectService service}) : _service = service; + /// Creates a [SettingsRepositoryImpl] with the required [BaseApiService]. + const SettingsRepositoryImpl({required BaseApiService apiService}) + : _apiService = apiService; - /// The Data Connect service. - final dc.DataConnectService _service; + /// The V2 API service for backend calls. + final BaseApiService _apiService; @override Future signOut() async { - return _service.run(() async { - await _service.signOut(); - }); + try { + // Step 1: Call V2 sign-out endpoint for server-side token revocation. + await _apiService.post(V2ApiEndpoints.clientSignOut); + } catch (e) { + developer.log( + 'V2 sign-out request failed: $e', + name: 'SettingsRepository', + ); + // Continue with local sign-out even if server-side fails. + } + + // Step 2: Sign out from local Firebase Auth. + await firebase.FirebaseAuth.instance.signOut(); + + // Step 3: Clear the client session store. + ClientSessionStore.instance.clear(); } } diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart index dd746425..f1f27f5b 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart @@ -3,7 +3,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_domain/krow_domain.dart' show ClientSession; /// A widget that displays the profile header with avatar and company info. class SettingsProfileHeader extends StatelessWidget { @@ -14,11 +14,11 @@ class SettingsProfileHeader extends StatelessWidget { Widget build(BuildContext context) { final TranslationsClientSettingsProfileEn labels = t.client_settings.profile; - final dc.ClientSession? session = dc.ClientSessionStore.instance.session; - final String businessName = - session?.business?.businessName ?? 'Your Company'; - final String email = session?.business?.email ?? 'client@example.com'; - final String? photoUrl = session?.business?.companyLogoUrl; + final ClientSession? session = ClientSessionStore.instance.session; + final String businessName = session?.businessName ?? 'Your Company'; + final String email = session?.email ?? 'client@example.com'; + // V2 session does not include a photo URL; show letter avatar. + final String? photoUrl = null; final String avatarLetter = businessName.trim().isNotEmpty ? businessName.trim()[0].toUpperCase() : 'C'; diff --git a/apps/mobile/packages/features/client/settings/pubspec.yaml b/apps/mobile/packages/features/client/settings/pubspec.yaml index 527e0e0e..c052e2ee 100644 --- a/apps/mobile/packages/features/client/settings/pubspec.yaml +++ b/apps/mobile/packages/features/client/settings/pubspec.yaml @@ -25,8 +25,6 @@ dependencies: path: ../../../core krow_domain: path: ../../../domain - krow_data_connect: - path: ../../../data_connect dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index e9e7f1c7..06a6dbd6 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -1,59 +1,99 @@ import 'dart:async'; import 'package:firebase_auth/firebase_auth.dart'; -import 'package:firebase_data_connect/firebase_data_connect.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart' as domain; -import '../../utils/test_phone_numbers.dart'; -import '../../domain/ui_entities/auth_mode.dart'; -import '../../domain/repositories/auth_repository_interface.dart'; +import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart'; +import 'package:staff_authentication/src/domain/ui_entities/auth_mode.dart'; +import 'package:staff_authentication/src/utils/test_phone_numbers.dart'; -/// Implementation of [AuthRepositoryInterface]. +/// V2 API implementation of [AuthRepositoryInterface]. +/// +/// Uses the Firebase Auth SDK for client-side phone verification, +/// then calls the V2 unified API to hydrate the session context. +/// All Data Connect dependencies have been removed. class AuthRepositoryImpl implements AuthRepositoryInterface { - AuthRepositoryImpl() : _service = DataConnectService.instance; + /// Creates an [AuthRepositoryImpl]. + /// + /// Requires a [domain.BaseApiService] for V2 API calls. + AuthRepositoryImpl({required domain.BaseApiService apiService}) + : _apiService = apiService; - final DataConnectService _service; + /// The V2 API service for backend calls. + final domain.BaseApiService _apiService; + + /// Firebase Auth instance for client-side phone verification. + final FirebaseAuth _auth = FirebaseAuth.instance; + + /// Completer for the pending phone verification request. Completer? _pendingVerification; @override Stream get currentUser => - _service.auth.authStateChanges().map((User? firebaseUser) { + _auth.authStateChanges().map((User? firebaseUser) { if (firebaseUser == null) { return null; } return domain.User( id: firebaseUser.uid, - email: firebaseUser.email ?? '', + email: firebaseUser.email, + displayName: firebaseUser.displayName, phone: firebaseUser.phoneNumber, - role: 'staff', + status: domain.UserStatus.active, ); }); - /// Signs in with a phone number and returns a verification ID. + /// Initiates phone verification via the V2 API. + /// + /// Calls `POST /auth/staff/phone/start` first. The server decides the + /// verification mode: + /// - `CLIENT_FIREBASE_SDK` — mobile must do Firebase phone auth client-side + /// - `IDENTITY_TOOLKIT_SMS` — server sent the SMS, returns `sessionInfo` + /// + /// For mobile without recaptcha tokens, the server returns + /// `CLIENT_FIREBASE_SDK` and we fall back to the Firebase Auth SDK. @override Future signInWithPhone({required String phoneNumber}) async { + // Step 1: Try V2 to let the server decide the auth mode. + // Falls back to CLIENT_FIREBASE_SDK if the API call fails (e.g. server + // down, 500, or non-JSON response). + String mode = 'CLIENT_FIREBASE_SDK'; + String? sessionInfo; + + try { + final domain.ApiResponse startResponse = await _apiService.post( + V2ApiEndpoints.staffPhoneStart, + data: { + 'phoneNumber': phoneNumber, + }, + ); + + final Map startData = + startResponse.data as Map; + mode = startData['mode'] as String? ?? 'CLIENT_FIREBASE_SDK'; + sessionInfo = startData['sessionInfo'] as String?; + } catch (_) { + // V2 start call failed — fall back to client-side Firebase SDK. + } + + // Step 2: If server sent the SMS, return the sessionInfo for verify step. + if (mode == 'IDENTITY_TOOLKIT_SMS') { + return sessionInfo; + } + + // Step 3: CLIENT_FIREBASE_SDK mode — do Firebase phone auth client-side. final Completer completer = Completer(); _pendingVerification = completer; - await _service.auth.verifyPhoneNumber( + await _auth.verifyPhoneNumber( phoneNumber: phoneNumber, verificationCompleted: (PhoneAuthCredential credential) { - // Skip auto-verification for test numbers to allow manual code entry - if (TestPhoneNumbers.isTestNumber(phoneNumber)) { - return; - } - - // For real numbers, we can support auto-verification if desired. - // But since this method returns a verificationId for manual OTP entry, - // we might not handle direct sign-in here unless the architecture changes. - // Currently, we just ignore it for the completer flow, - // or we could sign in directly if the credential is provided. + if (TestPhoneNumbers.isTestNumber(phoneNumber)) return; }, verificationFailed: (FirebaseAuthException e) { if (!completer.isCompleted) { - // Map Firebase network errors to NetworkException if (e.code == 'network-request-failed' || e.message?.contains('Unable to resolve host') == true) { completer.completeError( @@ -94,35 +134,36 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { _pendingVerification = null; } - /// Signs out the current user. - @override - Future signOut() async { - return await _service.signOut(); - } - - /// Verifies an OTP code and returns the authenticated user. + /// Verifies the OTP and completes authentication via the V2 API. + /// + /// 1. Signs in with the Firebase credential (client-side). + /// 2. Gets the Firebase ID token. + /// 3. Calls `POST /auth/staff/phone/verify` with the ID token and mode. + /// 4. Parses the V2 auth envelope and populates the session. @override Future verifyOtp({ required String verificationId, required String smsCode, required AuthMode mode, }) async { + // Step 1: Sign in with Firebase credential (client-side). final PhoneAuthCredential credential = PhoneAuthProvider.credential( verificationId: verificationId, smsCode: smsCode, ); - final UserCredential userCredential = await _service.run(() async { - try { - return await _service.auth.signInWithCredential(credential); - } on FirebaseAuthException catch (e) { - if (e.code == 'invalid-verification-code') { - throw const domain.InvalidCredentialsException( - technicalMessage: 'Invalid OTP code entered.', - ); - } - rethrow; + + final UserCredential userCredential; + try { + userCredential = await _auth.signInWithCredential(credential); + } on FirebaseAuthException catch (e) { + if (e.code == 'invalid-verification-code') { + throw const domain.InvalidCredentialsException( + technicalMessage: 'Invalid OTP code entered.', + ); } - }, requiresAuthentication: false); + rethrow; + } + final User? firebaseUser = userCredential.user; if (firebaseUser == null) { throw const domain.SignInFailedException( @@ -131,115 +172,68 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { ); } - final QueryResult response = - await _service.run( - () => _service.connector.getUserById(id: firebaseUser.uid).execute(), - requiresAuthentication: false, - ); - final GetUserByIdUser? user = response.data.user; - - GetStaffByUserIdStaffs? staffRecord; - - if (mode == AuthMode.signup) { - if (user == null) { - await _service.run( - () => _service.connector - .createUser(id: firebaseUser.uid, role: UserBaseRole.USER) - .userRole('STAFF') - .execute(), - requiresAuthentication: false, - ); - } else { - // User exists in PostgreSQL. Check if they have a STAFF profile. - final QueryResult - staffResponse = await _service.run( - () => _service.connector - .getStaffByUserId(userId: firebaseUser.uid) - .execute(), - requiresAuthentication: false, - ); - - if (staffResponse.data.staffs.isNotEmpty) { - // If profile exists, they should use Login mode. - await _service.signOut(); - throw const domain.AccountExistsException( - technicalMessage: - 'This user already has a staff profile. Please log in.', - ); - } - - // If they don't have a staff profile but they exist as BUSINESS, - // they are allowed to "Sign Up" for Staff. - // We update their userRole to 'BOTH'. - if (user.userRole == 'BUSINESS') { - await _service.run( - () => _service.connector - .updateUser(id: firebaseUser.uid) - .userRole('BOTH') - .execute(), - requiresAuthentication: false, - ); - } - } - } else { - if (user == null) { - await _service.signOut(); - throw const domain.UserNotFoundException( - technicalMessage: 'Authenticated user profile not found in database.', - ); - } - // Allow STAFF or BOTH roles to log in to the Staff App - if (user.userRole != 'STAFF' && user.userRole != 'BOTH') { - await _service.signOut(); - throw const domain.UnauthorizedAppException( - technicalMessage: 'User is not authorized for this app.', - ); - } - - final QueryResult - staffResponse = await _service.run( - () => _service.connector - .getStaffByUserId(userId: firebaseUser.uid) - .execute(), - requiresAuthentication: false, + // Step 2: Get the Firebase ID token. + final String? idToken = await firebaseUser.getIdToken(); + if (idToken == null) { + throw const domain.SignInFailedException( + technicalMessage: 'Failed to obtain Firebase ID token.', ); - if (staffResponse.data.staffs.isEmpty) { - await _service.signOut(); + } + + // Step 3: Call V2 verify endpoint with the Firebase ID token. + final String v2Mode = mode == AuthMode.signup ? 'sign-up' : 'sign-in'; + final domain.ApiResponse response = await _apiService.post( + V2ApiEndpoints.staffPhoneVerify, + data: { + 'idToken': idToken, + 'mode': v2Mode, + }, + ); + + final Map data = response.data as Map; + + // Step 4: Check for business logic errors from the V2 API. + final bool requiresProfileSetup = + data['requiresProfileSetup'] as bool? ?? false; + final Map? staffData = + data['staff'] as Map?; + final Map? userData = + data['user'] as Map?; + + // Handle mode-specific logic: + // - Sign-up: staff may be null (requiresProfileSetup=true) + // - Sign-in: staff must exist + if (mode == AuthMode.login) { + if (staffData == null) { + await _auth.signOut(); throw const domain.UserNotFoundException( technicalMessage: 'Your account is not registered yet. Please register first.', ); } - staffRecord = staffResponse.data.staffs.first; } - //TO-DO: create(registration) user and staff account - //TO-DO: save user data locally + // Build the domain user from the V2 response. final domain.User domainUser = domain.User( - id: firebaseUser.uid, - email: user?.email ?? '', - phone: user?.phone, - role: user?.role.stringValue ?? 'USER', - ); - final domain.Staff? domainStaff = staffRecord == null - ? null - : domain.Staff( - id: staffRecord.id, - authProviderId: staffRecord.userId, - name: staffRecord.fullName, - email: staffRecord.email ?? '', - phone: staffRecord.phone, - status: domain.StaffStatus.completedProfile, - address: staffRecord.addres, - avatar: staffRecord.photoUrl, - ); - StaffSessionStore.instance.setSession( - StaffSession( - staff: domainStaff, - ownerId: staffRecord?.ownerId, - ), + id: userData?['id'] as String? ?? firebaseUser.uid, + email: userData?['email'] as String?, + displayName: userData?['displayName'] as String?, + phone: userData?['phone'] as String? ?? firebaseUser.phoneNumber, + status: domain.UserStatus.active, ); + return domainUser; } + /// Signs out via the V2 API and locally. + @override + Future signOut() async { + try { + await _apiService.post(V2ApiEndpoints.signOut); + } catch (_) { + // Sign-out should not fail even if the API call fails. + // The local sign-out below will clear the session regardless. + } + await _auth.signOut(); + } } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/place_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/place_repository_impl.dart index 0155114a..e2d054b0 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/place_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/place_repository_impl.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:krow_core/core.dart'; -import '../../domain/repositories/place_repository.dart'; +import 'package:staff_authentication/src/domain/repositories/place_repository.dart'; class PlaceRepositoryImpl implements PlaceRepository { diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/profile_setup_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/profile_setup_repository_impl.dart index d3dd4a65..5b27ec68 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/profile_setup_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/profile_setup_repository_impl.dart @@ -1,13 +1,21 @@ -import 'package:krow_data_connect/krow_data_connect.dart'; -import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:firebase_auth/firebase_auth.dart' as auth; -import '../../domain/repositories/profile_setup_repository.dart'; +import 'package:staff_authentication/src/domain/repositories/profile_setup_repository.dart'; + +/// V2 API implementation of [ProfileSetupRepository]. +/// +/// Submits the staff profile setup data to the V2 unified API +/// endpoint `POST /staff/profile/setup`. class ProfileSetupRepositoryImpl implements ProfileSetupRepository { + /// Creates a [ProfileSetupRepositoryImpl]. + /// + /// Requires a [BaseApiService] for V2 API calls. + ProfileSetupRepositoryImpl({required BaseApiService apiService}) + : _apiService = apiService; - ProfileSetupRepositoryImpl() : _service = DataConnectService.instance; - final DataConnectService _service; + /// The V2 API service for backend calls. + final BaseApiService _apiService; @override Future submitProfile({ @@ -18,46 +26,27 @@ class ProfileSetupRepositoryImpl implements ProfileSetupRepository { required List industries, required List skills, }) async { - return _service.run(() async { - final auth.User? firebaseUser = _service.auth.currentUser; - if (firebaseUser == null) { - throw const NotAuthenticatedException( - technicalMessage: 'User not authenticated.', - ); - } + final ApiResponse response = await _apiService.post( + V2ApiEndpoints.staffProfileSetup, + data: { + 'fullName': fullName, + if (bio != null && bio.isNotEmpty) 'bio': bio, + 'preferredLocations': preferredLocations, + 'maxDistanceMiles': maxDistanceMiles.toInt(), + 'industries': industries, + 'skills': skills, + }, + ); - final StaffSession? session = StaffSessionStore.instance.session; - final String email = session?.staff?.email ?? ''; - final String? phone = firebaseUser.phoneNumber; - - final fdc.OperationResult result = - await _service.connector - .createStaff(userId: firebaseUser.uid, fullName: fullName) - .bio(bio) - .preferredLocations(preferredLocations) - .maxDistanceMiles(maxDistanceMiles.toInt()) - .industries(industries) - .skills(skills) - .email(email.isEmpty ? null : email) - .phone(phone) - .execute(); - - final String staffId = result.data.staff_insert.id; - - final Staff staff = Staff( - id: staffId, - authProviderId: firebaseUser.uid, - name: fullName, - email: email, - phone: phone, - status: StaffStatus.completedProfile, + // Check for API-level errors. + final Map data = response.data as Map; + if (data['code'] != null && + data['code'].toString() != '200' && + data['code'].toString() != '201') { + throw SignInFailedException( + technicalMessage: + data['message']?.toString() ?? 'Profile setup failed.', ); - - if (session != null) { - StaffSessionStore.instance.setSession( - StaffSession(staff: staff, ownerId: session.ownerId), - ); - } - }); + } } } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/arguments/verify_otp_arguments.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/arguments/verify_otp_arguments.dart index 7b7eefe6..b286aa29 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/arguments/verify_otp_arguments.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/arguments/verify_otp_arguments.dart @@ -1,5 +1,5 @@ import 'package:krow_core/core.dart'; -import '../ui_entities/auth_mode.dart'; +import 'package:staff_authentication/src/domain/ui_entities/auth_mode.dart'; /// Represents the arguments required for the [VerifyOtpUseCase]. /// diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart index bbdc1e63..8112fee6 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart @@ -1,24 +1,34 @@ import 'package:krow_domain/krow_domain.dart'; -import '../ui_entities/auth_mode.dart'; + +import 'package:staff_authentication/src/domain/ui_entities/auth_mode.dart'; /// Interface for authentication repository. +/// +/// Defines the contract for staff phone-based authentication, +/// OTP verification, and sign-out operations. abstract interface class AuthRepositoryInterface { + /// Stream of the current Firebase Auth user mapped to a domain [User]. Stream get currentUser; - /// Signs in with a phone number and returns a verification ID. + /// Initiates phone verification and returns a verification ID. + /// + /// Uses the Firebase Auth SDK client-side to send an SMS code. Future signInWithPhone({required String phoneNumber}); /// Cancels any pending phone verification request (if possible). void cancelPendingPhoneVerification(); - /// Verifies the OTP code and returns the authenticated user. + /// Verifies the OTP code and completes authentication via the V2 API. + /// + /// After Firebase credential sign-in, calls the V2 verify endpoint + /// to hydrate the session context. Returns the authenticated [User] + /// or `null` if verification fails. Future verifyOtp({ required String verificationId, required String smsCode, required AuthMode mode, }); - /// Signs out the current user. + /// Signs out the current user via the V2 API and locally. Future signOut(); - } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/search_cities_usecase.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/search_cities_usecase.dart index 0648c16c..4790f58f 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/search_cities_usecase.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/search_cities_usecase.dart @@ -1,10 +1,16 @@ -import '../repositories/place_repository.dart'; +import 'package:staff_authentication/src/domain/repositories/place_repository.dart'; +/// Use case for searching cities via the Places API. +/// +/// Delegates to [PlaceRepository] for autocomplete results. class SearchCitiesUseCase { - + /// Creates a [SearchCitiesUseCase]. SearchCitiesUseCase(this._repository); + + /// The repository for place search operations. final PlaceRepository _repository; + /// Searches for cities matching the given [query]. Future> call(String query) { return _repository.searchCities(query); } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/sign_in_with_phone_usecase.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/sign_in_with_phone_usecase.dart index 7331127b..cfbcdd19 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/sign_in_with_phone_usecase.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/sign_in_with_phone_usecase.dart @@ -1,17 +1,16 @@ import 'package:krow_core/core.dart'; -import '../arguments/sign_in_with_phone_arguments.dart'; -import '../repositories/auth_repository_interface.dart'; +import 'package:staff_authentication/src/domain/arguments/sign_in_with_phone_arguments.dart'; +import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart'; /// Use case for signing in with a phone number. /// -/// This use case delegates the sign-in logic to the [AuthRepositoryInterface]. +/// Delegates the sign-in logic to the [AuthRepositoryInterface]. class SignInWithPhoneUseCase implements UseCase { - /// Creates a [SignInWithPhoneUseCase]. - /// - /// Requires an [AuthRepositoryInterface] to interact with the authentication data source. SignInWithPhoneUseCase(this._repository); + + /// The repository for authentication operations. final AuthRepositoryInterface _repository; @override @@ -19,6 +18,7 @@ class SignInWithPhoneUseCase return _repository.signInWithPhone(phoneNumber: arguments.phoneNumber); } + /// Cancels any pending phone verification request. void cancelPending() { _repository.cancelPendingPhoneVerification(); } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/submit_profile_setup_usecase.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/submit_profile_setup_usecase.dart index 78d39066..f3a944ad 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/submit_profile_setup_usecase.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/submit_profile_setup_usecase.dart @@ -1,10 +1,16 @@ -import '../repositories/profile_setup_repository.dart'; +import 'package:staff_authentication/src/domain/repositories/profile_setup_repository.dart'; +/// Use case for submitting the staff profile setup. +/// +/// Delegates to [ProfileSetupRepository] to persist the profile data. class SubmitProfileSetup { - + /// Creates a [SubmitProfileSetup]. SubmitProfileSetup(this.repository); + + /// The repository for profile setup operations. final ProfileSetupRepository repository; + /// Submits the profile setup with the given data. Future call({ required String fullName, String? bio, diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/verify_otp_usecase.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/verify_otp_usecase.dart index 33b8eb70..bc75f206 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/verify_otp_usecase.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/verify_otp_usecase.dart @@ -1,17 +1,16 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../arguments/verify_otp_arguments.dart'; -import '../repositories/auth_repository_interface.dart'; +import 'package:staff_authentication/src/domain/arguments/verify_otp_arguments.dart'; +import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart'; /// Use case for verifying an OTP code. /// -/// This use case delegates the OTP verification logic to the [AuthRepositoryInterface]. +/// Delegates the OTP verification logic to the [AuthRepositoryInterface]. class VerifyOtpUseCase implements UseCase { - /// Creates a [VerifyOtpUseCase]. - /// - /// Requires an [AuthRepositoryInterface] to interact with the authentication data source. VerifyOtpUseCase(this._repository); + + /// The repository for authentication operations. final AuthRepositoryInterface _repository; @override diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_bloc.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_bloc.dart index 4b43622e..a5b745ab 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_bloc.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_bloc.dart @@ -1,27 +1,29 @@ import 'dart:async'; -import 'package:flutter_modular/flutter_modular.dart'; + import 'package:bloc/bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/arguments/sign_in_with_phone_arguments.dart'; -import '../../domain/arguments/verify_otp_arguments.dart'; -import '../../domain/usecases/sign_in_with_phone_usecase.dart'; -import '../../domain/usecases/verify_otp_usecase.dart'; -import 'auth_event.dart'; -import 'auth_state.dart'; +import 'package:staff_authentication/src/domain/arguments/sign_in_with_phone_arguments.dart'; +import 'package:staff_authentication/src/domain/arguments/verify_otp_arguments.dart'; +import 'package:staff_authentication/src/domain/usecases/sign_in_with_phone_usecase.dart'; +import 'package:staff_authentication/src/domain/usecases/verify_otp_usecase.dart'; +import 'package:staff_authentication/src/presentation/blocs/auth_event.dart'; +import 'package:staff_authentication/src/presentation/blocs/auth_state.dart'; /// BLoC responsible for handling authentication logic. +/// +/// Coordinates phone verification and OTP submission via use cases. class AuthBloc extends Bloc with BlocErrorHandler implements Disposable { - /// Creates an [AuthBloc]. AuthBloc({ required SignInWithPhoneUseCase signInUseCase, required VerifyOtpUseCase verifyOtpUseCase, - }) : _signInUseCase = signInUseCase, - _verifyOtpUseCase = verifyOtpUseCase, - super(const AuthState()) { + }) : _signInUseCase = signInUseCase, + _verifyOtpUseCase = verifyOtpUseCase, + super(const AuthState()) { on(_onSignInRequested); on(_onOtpSubmitted); on(_onErrorCleared); @@ -30,15 +32,26 @@ class AuthBloc extends Bloc on(_onResetRequested); on(_onCooldownTicked); } + /// The use case for signing in with a phone number. final SignInWithPhoneUseCase _signInUseCase; /// The use case for verifying an OTP. final VerifyOtpUseCase _verifyOtpUseCase; + + /// Token to track the latest request and ignore stale completions. int _requestToken = 0; + + /// Timestamp of the last code request for cooldown enforcement. DateTime? _lastCodeRequestAt; + + /// When the cooldown expires. DateTime? _cooldownUntil; + + /// Duration users must wait between code requests. static const Duration _resendCooldown = Duration(seconds: 31); + + /// Timer for ticking down the cooldown. Timer? _cooldownTimer; /// Clears any authentication error from the state. @@ -138,6 +151,7 @@ class AuthBloc extends Bloc ); } + /// Handles cooldown tick events. void _onCooldownTicked( AuthCooldownTicked event, Emitter emit, @@ -165,22 +179,27 @@ class AuthBloc extends Bloc ); } + /// Starts the cooldown timer with the given remaining seconds. void _startCooldown(int secondsRemaining) { _cancelCooldownTimer(); int remaining = secondsRemaining; add(AuthCooldownTicked(remaining)); - _cooldownTimer = Timer.periodic(const Duration(seconds: 1), (Timer timer) { - remaining -= 1; - if (remaining <= 0) { - timer.cancel(); - _cooldownTimer = null; - add(const AuthCooldownTicked(0)); - return; - } - add(AuthCooldownTicked(remaining)); - }); + _cooldownTimer = Timer.periodic( + const Duration(seconds: 1), + (Timer timer) { + remaining -= 1; + if (remaining <= 0) { + timer.cancel(); + _cooldownTimer = null; + add(const AuthCooldownTicked(0)); + return; + } + add(AuthCooldownTicked(remaining)); + }, + ); } + /// Cancels the cooldown timer if active. void _cancelCooldownTimer() { _cooldownTimer?.cancel(); _cooldownTimer = null; @@ -218,4 +237,3 @@ class AuthBloc extends Bloc close(); } } - diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/profile_setup/profile_setup_bloc.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/profile_setup/profile_setup_bloc.dart index 2b645824..c35bb6e4 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/profile_setup/profile_setup_bloc.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/profile_setup/profile_setup_bloc.dart @@ -1,24 +1,23 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; -import '../../../domain/usecases/submit_profile_setup_usecase.dart'; +import 'package:staff_authentication/src/domain/usecases/submit_profile_setup_usecase.dart'; +import 'package:staff_authentication/src/domain/usecases/search_cities_usecase.dart'; +import 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_event.dart'; +import 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_state.dart'; -import '../../../domain/usecases/search_cities_usecase.dart'; - -import 'profile_setup_event.dart'; -import 'profile_setup_state.dart'; - -export 'profile_setup_event.dart'; -export 'profile_setup_state.dart'; +export 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_event.dart'; +export 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_state.dart'; /// BLoC responsible for managing the profile setup state and logic. class ProfileSetupBloc extends Bloc with BlocErrorHandler { + /// Creates a [ProfileSetupBloc]. ProfileSetupBloc({ required SubmitProfileSetup submitProfileSetup, required SearchCitiesUseCase searchCities, - }) : _submitProfileSetup = submitProfileSetup, - _searchCities = searchCities, - super(const ProfileSetupState()) { + }) : _submitProfileSetup = submitProfileSetup, + _searchCities = searchCities, + super(const ProfileSetupState()) { on(_onFullNameChanged); on(_onBioChanged); on(_onLocationsChanged); @@ -30,7 +29,10 @@ class ProfileSetupBloc extends Bloc on(_onClearLocationSuggestions); } + /// The use case for submitting the profile setup. final SubmitProfileSetup _submitProfileSetup; + + /// The use case for searching cities. final SearchCitiesUseCase _searchCities; /// Handles the [ProfileSetupFullNameChanged] event. @@ -109,6 +111,7 @@ class ProfileSetupBloc extends Bloc ); } + /// Handles location query changes for autocomplete search. Future _onLocationQueryChanged( ProfileSetupLocationQueryChanged event, Emitter emit, @@ -118,17 +121,16 @@ class ProfileSetupBloc extends Bloc return; } - // For search, we might want to handle errors silently or distinctively - // Using simple try-catch here as it's a search-as-you-type feature where error dialogs are intrusive try { final List results = await _searchCities(event.query); emit(state.copyWith(locationSuggestions: results)); } catch (e) { - // Quietly fail or clear + // Quietly fail for search-as-you-type. emit(state.copyWith(locationSuggestions: [])); } } + /// Clears the location suggestions list. void _onClearLocationSuggestions( ProfileSetupClearLocationSuggestions event, Emitter emit, @@ -136,4 +138,3 @@ class ProfileSetupBloc extends Bloc emit(state.copyWith(locationSuggestions: [])); } } - diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/get_started_page.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/get_started_page.dart index fd65b050..a4feb1fc 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/get_started_page.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/get_started_page.dart @@ -3,9 +3,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import '../widgets/get_started_page/get_started_actions.dart'; -import '../widgets/get_started_page/get_started_background.dart'; -import '../widgets/get_started_page/get_started_header.dart'; +import 'package:staff_authentication/src/presentation/widgets/get_started_page/get_started_actions.dart'; +import 'package:staff_authentication/src/presentation/widgets/get_started_page/get_started_background.dart'; +import 'package:staff_authentication/src/presentation/widgets/get_started_page/get_started_header.dart'; /// The entry point page for staff authentication. /// diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart index d4c3b652..93bf4e9f 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart @@ -9,8 +9,8 @@ import 'package:staff_authentication/src/presentation/blocs/auth_state.dart'; import 'package:staff_authentication/staff_authentication.dart'; import 'package:krow_core/core.dart'; -import '../widgets/phone_verification_page/otp_verification.dart'; -import '../widgets/phone_verification_page/phone_input.dart'; +import 'package:staff_authentication/src/presentation/widgets/phone_verification_page/otp_verification.dart'; +import 'package:staff_authentication/src/presentation/widgets/phone_verification_page/phone_input.dart'; /// A combined page for phone number entry and OTP verification. /// diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/profile_setup_page.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/profile_setup_page.dart index d7707c58..130be709 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/profile_setup_page.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/profile_setup_page.dart @@ -6,11 +6,11 @@ import 'package:flutter_modular/flutter_modular.dart' hide ModularWatchExtension; import 'package:krow_core/core.dart'; -import '../blocs/profile_setup/profile_setup_bloc.dart'; -import '../widgets/profile_setup_page/profile_setup_basic_info.dart'; -import '../widgets/profile_setup_page/profile_setup_experience.dart'; -import '../widgets/profile_setup_page/profile_setup_header.dart'; -import '../widgets/profile_setup_page/profile_setup_location.dart'; +import 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_bloc.dart'; +import 'package:staff_authentication/src/presentation/widgets/profile_setup_page/profile_setup_basic_info.dart'; +import 'package:staff_authentication/src/presentation/widgets/profile_setup_page/profile_setup_experience.dart'; +import 'package:staff_authentication/src/presentation/widgets/profile_setup_page/profile_setup_header.dart'; +import 'package:staff_authentication/src/presentation/widgets/profile_setup_page/profile_setup_location.dart'; /// Page for setting up the user profile after authentication. class ProfileSetupPage extends StatefulWidget { diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_input_field.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_input_field.dart index 1d757a3f..96439f08 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_input_field.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_input_field.dart @@ -4,8 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:core_localization/core_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../blocs/auth_event.dart'; -import '../../../blocs/auth_bloc.dart'; +import 'package:staff_authentication/src/presentation/blocs/auth_event.dart'; +import 'package:staff_authentication/src/presentation/blocs/auth_bloc.dart'; /// A widget that displays a 6-digit OTP input field. /// diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_verification_actions.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_verification_actions.dart index 360d8b06..ca9436d7 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_verification_actions.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_verification_actions.dart @@ -2,7 +2,7 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import '../../common/auth_trouble_link.dart'; +import 'package:staff_authentication/src/presentation/widgets/common/auth_trouble_link.dart'; /// A widget that displays the primary action button and trouble link for OTP verification. class OtpVerificationActions extends StatelessWidget { diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_experience.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_experience.dart index ef0cd840..9ba18d93 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_experience.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_experience.dart @@ -1,7 +1,6 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'package:krow_domain/krow_domain.dart'; import 'package:staff_authentication/src/presentation/widgets/common/section_title_subtitle.dart'; /// A widget for setting up skills and preferred industries. @@ -27,6 +26,36 @@ class ProfileSetupExperience extends StatelessWidget { /// Callback for when industries change. final ValueChanged> onIndustriesChanged; + /// Available skill options with their API values and labels. + static const List _skillValues = [ + 'food_service', + 'bartending', + 'event_setup', + 'hospitality', + 'warehouse', + 'customer_service', + 'cleaning', + 'security', + 'retail', + 'cooking', + 'cashier', + 'server', + 'barista', + 'host_hostess', + 'busser', + 'driving', + ]; + + /// Available industry options with their API values. + static const List _industryValues = [ + 'hospitality', + 'food_service', + 'warehouse', + 'events', + 'retail', + 'healthcare', + ]; + /// Toggles a skill. void _toggleSkill({required String skill}) { final List updatedList = List.from(skills); @@ -71,15 +100,14 @@ class ProfileSetupExperience extends StatelessWidget { Wrap( spacing: UiConstants.space2, runSpacing: UiConstants.space2, - children: ExperienceSkill.values.map((ExperienceSkill skill) { - final bool isSelected = skills.contains(skill.value); - // Dynamic translation access + children: _skillValues.map((String skill) { + final bool isSelected = skills.contains(skill); final String label = _getSkillLabel(skill); return UiChip( label: label, isSelected: isSelected, - onTap: () => _toggleSkill(skill: skill.value), + onTap: () => _toggleSkill(skill: skill), leadingIcon: isSelected ? UiIcons.check : null, variant: UiChipVariant.primary, ); @@ -97,14 +125,14 @@ class ProfileSetupExperience extends StatelessWidget { Wrap( spacing: UiConstants.space2, runSpacing: UiConstants.space2, - children: Industry.values.map((Industry industry) { - final bool isSelected = industries.contains(industry.value); + children: _industryValues.map((String industry) { + final bool isSelected = industries.contains(industry); final String label = _getIndustryLabel(industry); return UiChip( label: label, isSelected: isSelected, - onTap: () => _toggleIndustry(industry: industry.value), + onTap: () => _toggleIndustry(industry: industry), leadingIcon: isSelected ? UiIcons.check : null, variant: isSelected ? UiChipVariant.accent @@ -116,131 +144,71 @@ class ProfileSetupExperience extends StatelessWidget { ); } - String _getSkillLabel(ExperienceSkill skill) { + String _getSkillLabel(String skill) { + final TranslationsStaffAuthenticationProfileSetupPageExperienceSkillsEn + skillsI18n = t + .staff_authentication + .profile_setup_page + .experience + .skills; switch (skill) { - case ExperienceSkill.foodService: - return t - .staff_authentication - .profile_setup_page - .experience - .skills - .food_service; - case ExperienceSkill.bartending: - return t - .staff_authentication - .profile_setup_page - .experience - .skills - .bartending; - case ExperienceSkill.warehouse: - return t - .staff_authentication - .profile_setup_page - .experience - .skills - .warehouse; - case ExperienceSkill.retail: - return t - .staff_authentication - .profile_setup_page - .experience - .skills - .retail; - // Note: 'events' was removed from enum in favor of 'event_setup' or industry. - // Using 'events' translation for eventSetup if available or fallback. - case ExperienceSkill.eventSetup: - return t - .staff_authentication - .profile_setup_page - .experience - .skills - .events; - case ExperienceSkill.customerService: - return t - .staff_authentication - .profile_setup_page - .experience - .skills - .customer_service; - case ExperienceSkill.cleaning: - return t - .staff_authentication - .profile_setup_page - .experience - .skills - .cleaning; - case ExperienceSkill.security: - return t - .staff_authentication - .profile_setup_page - .experience - .skills - .security; - case ExperienceSkill.driving: - return t - .staff_authentication - .profile_setup_page - .experience - .skills - .driving; - case ExperienceSkill.cooking: - return t - .staff_authentication - .profile_setup_page - .experience - .skills - .cooking; + case 'food_service': + return skillsI18n.food_service; + case 'bartending': + return skillsI18n.bartending; + case 'warehouse': + return skillsI18n.warehouse; + case 'retail': + return skillsI18n.retail; + case 'event_setup': + return skillsI18n.events; + case 'customer_service': + return skillsI18n.customer_service; + case 'cleaning': + return skillsI18n.cleaning; + case 'security': + return skillsI18n.security; + case 'driving': + return skillsI18n.driving; + case 'cooking': + return skillsI18n.cooking; + case 'cashier': + return skillsI18n.cashier; + case 'server': + return skillsI18n.server; + case 'barista': + return skillsI18n.barista; + case 'host_hostess': + return skillsI18n.host_hostess; + case 'busser': + return skillsI18n.busser; default: - return skill.value; + return skill; } } - String _getIndustryLabel(Industry industry) { + String _getIndustryLabel(String industry) { + final TranslationsStaffAuthenticationProfileSetupPageExperienceIndustriesEn + industriesI18n = t + .staff_authentication + .profile_setup_page + .experience + .industries; switch (industry) { - case Industry.hospitality: - return t - .staff_authentication - .profile_setup_page - .experience - .industries - .hospitality; - case Industry.foodService: - return t - .staff_authentication - .profile_setup_page - .experience - .industries - .food_service; - case Industry.warehouse: - return t - .staff_authentication - .profile_setup_page - .experience - .industries - .warehouse; - case Industry.events: - return t - .staff_authentication - .profile_setup_page - .experience - .industries - .events; - case Industry.retail: - return t - .staff_authentication - .profile_setup_page - .experience - .industries - .retail; - case Industry.healthcare: - return t - .staff_authentication - .profile_setup_page - .experience - .industries - .healthcare; + case 'hospitality': + return industriesI18n.hospitality; + case 'food_service': + return industriesI18n.food_service; + case 'warehouse': + return industriesI18n.warehouse; + case 'events': + return industriesI18n.events; + case 'retail': + return industriesI18n.retail; + case 'healthcare': + return industriesI18n.healthcare; default: - return industry.value; + return industry; } } } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart b/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart index b9721c85..0f0fabd4 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_domain/krow_domain.dart'; import 'package:staff_authentication/src/data/repositories_impl/auth_repository_impl.dart'; import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart'; import 'package:staff_authentication/src/domain/usecases/sign_in_with_phone_usecase.dart'; @@ -20,17 +20,24 @@ import 'package:staff_authentication/src/presentation/pages/phone_verification_p import 'package:staff_authentication/src/presentation/pages/profile_setup_page.dart'; import 'package:staff_authentication/src/domain/ui_entities/auth_mode.dart'; -/// A [Module] for the staff authentication feature. +/// A [Module] for the staff authentication feature. +/// +/// Provides repositories, use cases, and BLoCs for phone-based +/// authentication and profile setup. Uses V2 API via [BaseApiService]. class StaffAuthenticationModule extends Module { @override - List get imports => [DataConnectModule()]; + List get imports => [CoreModule()]; @override void binds(Injector i) { // Repositories - i.addLazySingleton(ProfileSetupRepositoryImpl.new); + i.addLazySingleton( + () => AuthRepositoryImpl(apiService: i.get()), + ); + i.addLazySingleton( + () => ProfileSetupRepositoryImpl(apiService: i.get()), + ); i.addLazySingleton(PlaceRepositoryImpl.new); - i.addLazySingleton(AuthRepositoryImpl.new); // UseCases i.addLazySingleton(SignInWithPhoneUseCase.new); @@ -53,7 +60,6 @@ class StaffAuthenticationModule extends Module { ); } - @override void routes(RouteManager r) { r.child(StaffPaths.root, child: (_) => const IntroPage()); diff --git a/apps/mobile/packages/features/staff/authentication/pubspec.yaml b/apps/mobile/packages/features/staff/authentication/pubspec.yaml index 966934ef..6342811c 100644 --- a/apps/mobile/packages/features/staff/authentication/pubspec.yaml +++ b/apps/mobile/packages/features/staff/authentication/pubspec.yaml @@ -14,16 +14,12 @@ dependencies: flutter_bloc: ^8.1.0 flutter_modular: ^6.3.0 equatable: ^2.0.5 - firebase_core: ^4.2.1 firebase_auth: ^6.1.2 - firebase_data_connect: ^0.2.2+1 http: ^1.2.0 - + # Architecture Packages krow_domain: path: ../../../domain - krow_data_connect: - path: ../../../data_connect krow_core: path: ../../../core design_system: diff --git a/apps/mobile/packages/features/staff/availability/lib/src/data/repositories_impl/availability_repository_impl.dart b/apps/mobile/packages/features/staff/availability/lib/src/data/repositories_impl/availability_repository_impl.dart index 4c7a1afe..6857561b 100644 --- a/apps/mobile/packages/features/staff/availability/lib/src/data/repositories_impl/availability_repository_impl.dart +++ b/apps/mobile/packages/features/staff/availability/lib/src/data/repositories_impl/availability_repository_impl.dart @@ -1,235 +1,108 @@ -import 'package:firebase_data_connect/firebase_data_connect.dart'; -import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/repositories/availability_repository.dart'; +import 'package:staff_availability/src/domain/repositories/availability_repository.dart'; -/// Implementation of [AvailabilityRepository] using Firebase Data Connect. +/// V2 API implementation of [AvailabilityRepository]. /// -/// Note: The backend schema supports recurring availablity (Weekly/DayOfWeek), -/// not specific date availability. Therefore, updating availability for a specific -/// date will update the availability for that Day of Week globally (Recurring). -class AvailabilityRepositoryImpl - implements AvailabilityRepository { - final dc.DataConnectService _service; +/// Uses the unified REST API for all read/write operations. +/// - `GET /staff/availability` to list availability for a date range. +/// - `PUT /staff/availability` to update a single day. +/// - `POST /staff/availability/quick-set` to apply a preset. +class AvailabilityRepositoryImpl implements AvailabilityRepository { + /// Creates an [AvailabilityRepositoryImpl]. + AvailabilityRepositoryImpl({required BaseApiService apiService}) + : _apiService = apiService; - AvailabilityRepositoryImpl() : _service = dc.DataConnectService.instance; + /// The API service used for network requests. + final BaseApiService _apiService; @override - Future> getAvailability(DateTime start, DateTime end) async { - return _service.run(() async { - final String staffId = await _service.getStaffId(); - - // 1. Fetch Weekly recurring availability - final QueryResult result = - await _service.connector.listStaffAvailabilitiesByStaffId(staffId: staffId).limit(100).execute(); + Future> getAvailability( + DateTime start, + DateTime end, + ) async { + final String startDate = _toIsoDate(start); + final String endDate = _toIsoDate(end); - final List items = result.data.staffAvailabilities; + final ApiResponse response = await _apiService.get( + V2ApiEndpoints.staffAvailability, + params: { + 'startDate': startDate, + 'endDate': endDate, + }, + ); - // 2. Map to lookup: DayOfWeek -> Map - final Map> weeklyMap = {}; - - for (final item in items) { - dc.DayOfWeek day; - try { - day = dc.DayOfWeek.values.byName(item.day.stringValue); - } catch (_) { - continue; - } + final Map body = response.data as Map; + final List items = body['items'] as List; - dc.AvailabilitySlot slot; - try { - slot = dc.AvailabilitySlot.values.byName(item.slot.stringValue); - } catch (_) { - continue; - } - - bool isAvailable = false; - try { - final dc.AvailabilityStatus status = dc.AvailabilityStatus.values.byName(item.status.stringValue); - isAvailable = _statusToBool(status); - } catch (_) { - isAvailable = false; - } - - if (!weeklyMap.containsKey(day)) { - weeklyMap[day] = {}; - } - weeklyMap[day]![slot] = isAvailable; - } - - // 3. Generate DayAvailability for requested range - final List days = []; - final int dayCount = end.difference(start).inDays; - - for (int i = 0; i <= dayCount; i++) { - final DateTime date = start.add(Duration(days: i)); - final dc.DayOfWeek dow = _toBackendDay(date.weekday); - - final Map daySlots = weeklyMap[dow] ?? {}; - - // We define 3 standard slots for every day - final List slots = [ - _createSlot(date, dow, daySlots, dc.AvailabilitySlot.MORNING), - _createSlot(date, dow, daySlots, dc.AvailabilitySlot.AFTERNOON), - _createSlot(date, dow, daySlots, dc.AvailabilitySlot.EVENING), - ]; - - final bool isDayAvailable = slots.any((s) => s.isAvailable); - - days.add(DayAvailability( - date: date, - isAvailable: isDayAvailable, - slots: slots, - )); - } - return days; - }); - } - - AvailabilitySlot _createSlot( - DateTime date, - dc.DayOfWeek dow, - Map existingSlots, - dc.AvailabilitySlot slotEnum, - ) { - final bool isAvailable = existingSlots[slotEnum] ?? false; - return AvailabilityAdapter.fromPrimitive(slotEnum.name, isAvailable: isAvailable); + return items + .map((dynamic e) => + AvailabilityDay.fromJson(e as Map)) + .toList(); } @override - Future updateDayAvailability(DayAvailability availability) async { - return _service.run(() async { - final String staffId = await _service.getStaffId(); - final dc.DayOfWeek dow = _toBackendDay(availability.date.weekday); + Future updateDayAvailability({ + required int dayOfWeek, + required AvailabilityStatus status, + required List slots, + }) async { + final ApiResponse response = await _apiService.put( + V2ApiEndpoints.staffAvailability, + data: { + 'dayOfWeek': dayOfWeek, + 'availabilityStatus': status.toJson(), + 'slots': slots.map((TimeSlot s) => s.toJson()).toList(), + }, + ); - // Update each slot in the backend. - // This updates the recurring rule for this DayOfWeek. - for (final AvailabilitySlot slot in availability.slots) { - final dc.AvailabilitySlot slotEnum = _toBackendSlot(slot.id); - final dc.AvailabilityStatus status = _boolToStatus(slot.isAvailable); + final Map body = response.data as Map; - await _upsertSlot(staffId, dow, slotEnum, status); - } - - return availability; - }); + // The PUT response returns the updated day info. + return AvailabilityDay( + date: '', + dayOfWeek: body['dayOfWeek'] as int, + availabilityStatus: + AvailabilityStatus.fromJson(body['availabilityStatus'] as String?), + slots: _parseSlotsFromResponse(body['slots']), + ); } @override - Future> applyQuickSet(DateTime start, DateTime end, String type) async { - return _service.run(() async { - final String staffId = await _service.getStaffId(); - - // QuickSet updates the Recurring schedule for all days involved. - // However, if the user selects a range that covers e.g. Mon-Fri, we update Mon-Fri. - - final int dayCount = end.difference(start).inDays; - final Set processedDays = {}; - final List resultDays = []; + Future applyQuickSet({ + required String quickSetType, + required DateTime start, + required DateTime end, + List? slots, + }) async { + final Map data = { + 'quickSetType': quickSetType, + 'startDate': start.toUtc().toIso8601String(), + 'endDate': end.toUtc().toIso8601String(), + }; - final List> futures = []; - - for (int i = 0; i <= dayCount; i++) { - final DateTime date = start.add(Duration(days: i)); - final dc.DayOfWeek dow = _toBackendDay(date.weekday); - - // Logic to determine if enabled based on type - bool enableDay = false; - if (type == 'all') { - enableDay = true; - } else if (type == 'clear') { - enableDay = false; - } else if (type == 'weekdays') { - enableDay = (dow != dc.DayOfWeek.SATURDAY && dow != dc.DayOfWeek.SUNDAY); - } else if (type == 'weekends') { - enableDay = (dow == dc.DayOfWeek.SATURDAY || dow == dc.DayOfWeek.SUNDAY); - } - - // Only update backend once per DayOfWeek (since it's recurring) - if (!processedDays.contains(dow)) { - processedDays.add(dow); - final dc.AvailabilityStatus status = _boolToStatus(enableDay); - - futures.add(_upsertSlot(staffId, dow, dc.AvailabilitySlot.MORNING, status)); - futures.add(_upsertSlot(staffId, dow, dc.AvailabilitySlot.AFTERNOON, status)); - futures.add(_upsertSlot(staffId, dow, dc.AvailabilitySlot.EVENING, status)); - } - - // Prepare return object - final slots = [ - AvailabilityAdapter.fromPrimitive('MORNING', isAvailable: enableDay), - AvailabilityAdapter.fromPrimitive('AFTERNOON', isAvailable: enableDay), - AvailabilityAdapter.fromPrimitive('EVENING', isAvailable: enableDay), - ]; - - resultDays.add(DayAvailability( - date: date, - isAvailable: enableDay, - slots: slots, - )); - } - - // Execute all updates in parallel - await Future.wait(futures); - - return resultDays; - }); - } - - Future _upsertSlot(String staffId, dc.DayOfWeek day, dc.AvailabilitySlot slot, dc.AvailabilityStatus status) async { - // Check if exists - final result = await _service.connector.getStaffAvailabilityByKey( - staffId: staffId, - day: day, - slot: slot, - ).execute(); - - if (result.data.staffAvailability != null) { - // Update - await _service.connector.updateStaffAvailability( - staffId: staffId, - day: day, - slot: slot, - ).status(status).execute(); - } else { - // Create - await _service.connector.createStaffAvailability( - staffId: staffId, - day: day, - slot: slot, - ).status(status).execute(); + if (slots != null && slots.isNotEmpty) { + data['slots'] = slots.map((TimeSlot s) => s.toJson()).toList(); } + + await _apiService.post( + V2ApiEndpoints.staffAvailabilityQuickSet, + data: data, + ); } - // --- Private Helpers --- - - dc.DayOfWeek _toBackendDay(int weekday) { - switch (weekday) { - case DateTime.monday: return dc.DayOfWeek.MONDAY; - case DateTime.tuesday: return dc.DayOfWeek.TUESDAY; - case DateTime.wednesday: return dc.DayOfWeek.WEDNESDAY; - case DateTime.thursday: return dc.DayOfWeek.THURSDAY; - case DateTime.friday: return dc.DayOfWeek.FRIDAY; - case DateTime.saturday: return dc.DayOfWeek.SATURDAY; - case DateTime.sunday: return dc.DayOfWeek.SUNDAY; - default: return dc.DayOfWeek.MONDAY; - } + /// Formats a [DateTime] as `YYYY-MM-DD`. + String _toIsoDate(DateTime date) { + return '${date.year.toString().padLeft(4, '0')}-' + '${date.month.toString().padLeft(2, '0')}-' + '${date.day.toString().padLeft(2, '0')}'; } - dc.AvailabilitySlot _toBackendSlot(String id) { - switch (id.toLowerCase()) { - case 'morning': return dc.AvailabilitySlot.MORNING; - case 'afternoon': return dc.AvailabilitySlot.AFTERNOON; - case 'evening': return dc.AvailabilitySlot.EVENING; - default: return dc.AvailabilitySlot.MORNING; - } - } - - bool _statusToBool(dc.AvailabilityStatus status) { - return status == dc.AvailabilityStatus.CONFIRMED_AVAILABLE; - } - - dc.AvailabilityStatus _boolToStatus(bool isAvailable) { - return isAvailable ? dc.AvailabilityStatus.CONFIRMED_AVAILABLE : dc.AvailabilityStatus.BLOCKED; + /// Safely parses a dynamic slots value into [TimeSlot] list. + List _parseSlotsFromResponse(dynamic rawSlots) { + if (rawSlots is! List) return []; + return rawSlots + .map((dynamic e) => TimeSlot.fromJson(e as Map)) + .toList(); } } diff --git a/apps/mobile/packages/features/staff/availability/lib/src/domain/repositories/availability_repository.dart b/apps/mobile/packages/features/staff/availability/lib/src/domain/repositories/availability_repository.dart index 3678be8d..9039b943 100644 --- a/apps/mobile/packages/features/staff/availability/lib/src/domain/repositories/availability_repository.dart +++ b/apps/mobile/packages/features/staff/availability/lib/src/domain/repositories/availability_repository.dart @@ -1,12 +1,25 @@ import 'package:krow_domain/krow_domain.dart'; +/// Contract for fetching and updating staff availability. abstract class AvailabilityRepository { /// Fetches availability for a given date range (usually a week). - Future> getAvailability(DateTime start, DateTime end); + Future> getAvailability( + DateTime start, + DateTime end, + ); - /// Updates the availability for a specific day. - Future updateDayAvailability(DayAvailability availability); - - /// Applies a preset configuration (e.g. All Week, Weekdays only) to a range. - Future> applyQuickSet(DateTime start, DateTime end, String type); + /// Updates the availability for a specific day of the week. + Future updateDayAvailability({ + required int dayOfWeek, + required AvailabilityStatus status, + required List slots, + }); + + /// Applies a preset configuration (e.g. "all", "weekdays") to the week. + Future applyQuickSet({ + required String quickSetType, + required DateTime start, + required DateTime end, + List slots, + }); } diff --git a/apps/mobile/packages/features/staff/availability/lib/src/domain/usecases/apply_quick_set_usecase.dart b/apps/mobile/packages/features/staff/availability/lib/src/domain/usecases/apply_quick_set_usecase.dart index 6ff4735e..b3d37ba3 100644 --- a/apps/mobile/packages/features/staff/availability/lib/src/domain/usecases/apply_quick_set_usecase.dart +++ b/apps/mobile/packages/features/staff/availability/lib/src/domain/usecases/apply_quick_set_usecase.dart @@ -1,28 +1,38 @@ import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; -import '../repositories/availability_repository.dart'; - -/// Use case to apply a quick-set availability pattern (e.g., "Weekdays", "All Week") to a week. -class ApplyQuickSetUseCase extends UseCase> { - final AvailabilityRepository repository; +import 'package:staff_availability/src/domain/repositories/availability_repository.dart'; +/// Use case to apply a quick-set availability pattern to the current week. +/// +/// Supported types: `all`, `weekdays`, `weekends`, `clear`. +class ApplyQuickSetUseCase extends UseCase { + /// Creates an [ApplyQuickSetUseCase]. ApplyQuickSetUseCase(this.repository); - /// [type] can be 'all', 'weekdays', 'weekends', 'clear' + /// The availability repository. + final AvailabilityRepository repository; + @override - Future> call(ApplyQuickSetParams params) { - final end = params.start.add(const Duration(days: 6)); - return repository.applyQuickSet(params.start, end, params.type); + Future call(ApplyQuickSetParams params) { + final DateTime end = params.start.add(const Duration(days: 6)); + return repository.applyQuickSet( + quickSetType: params.type, + start: params.start, + end: end, + ); } } /// Parameters for [ApplyQuickSetUseCase]. class ApplyQuickSetParams extends UseCaseArgument { - final DateTime start; - final String type; - + /// Creates [ApplyQuickSetParams]. const ApplyQuickSetParams(this.start, this.type); + /// The Monday of the target week. + final DateTime start; + + /// Quick-set type: `all`, `weekdays`, `weekends`, or `clear`. + final String type; + @override - List get props => [start, type]; + List get props => [start, type]; } diff --git a/apps/mobile/packages/features/staff/availability/lib/src/domain/usecases/get_weekly_availability_usecase.dart b/apps/mobile/packages/features/staff/availability/lib/src/domain/usecases/get_weekly_availability_usecase.dart index b9b03a28..f49c6192 100644 --- a/apps/mobile/packages/features/staff/availability/lib/src/domain/usecases/get_weekly_availability_usecase.dart +++ b/apps/mobile/packages/features/staff/availability/lib/src/domain/usecases/get_weekly_availability_usecase.dart @@ -1,30 +1,36 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../repositories/availability_repository.dart'; +import 'package:staff_availability/src/domain/repositories/availability_repository.dart'; /// Use case to fetch availability for a specific week. -/// -/// This encapsulates the logic of calculating the week range and fetching data -/// from the repository. -class GetWeeklyAvailabilityUseCase extends UseCase> { - final AvailabilityRepository repository; - +/// +/// Calculates the week range from the given start date and delegates +/// to the repository. +class GetWeeklyAvailabilityUseCase + extends UseCase> { + /// Creates a [GetWeeklyAvailabilityUseCase]. GetWeeklyAvailabilityUseCase(this.repository); + /// The availability repository. + final AvailabilityRepository repository; + @override - Future> call(GetWeeklyAvailabilityParams params) async { - // Calculate end of week (assuming start is start of week) - final end = params.start.add(const Duration(days: 6)); + Future> call( + GetWeeklyAvailabilityParams params, + ) async { + final DateTime end = params.start.add(const Duration(days: 6)); return repository.getAvailability(params.start, end); } } /// Parameters for [GetWeeklyAvailabilityUseCase]. class GetWeeklyAvailabilityParams extends UseCaseArgument { - final DateTime start; - + /// Creates [GetWeeklyAvailabilityParams]. const GetWeeklyAvailabilityParams(this.start); + /// The Monday of the target week. + final DateTime start; + @override - List get props => [start]; + List get props => [start]; } diff --git a/apps/mobile/packages/features/staff/availability/lib/src/domain/usecases/update_day_availability_usecase.dart b/apps/mobile/packages/features/staff/availability/lib/src/domain/usecases/update_day_availability_usecase.dart index a3e32543..93ce87ac 100644 --- a/apps/mobile/packages/features/staff/availability/lib/src/domain/usecases/update_day_availability_usecase.dart +++ b/apps/mobile/packages/features/staff/availability/lib/src/domain/usecases/update_day_availability_usecase.dart @@ -1,25 +1,44 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../repositories/availability_repository.dart'; +import 'package:staff_availability/src/domain/repositories/availability_repository.dart'; /// Use case to update the availability configuration for a specific day. -class UpdateDayAvailabilityUseCase extends UseCase { - final AvailabilityRepository repository; - +class UpdateDayAvailabilityUseCase + extends UseCase { + /// Creates an [UpdateDayAvailabilityUseCase]. UpdateDayAvailabilityUseCase(this.repository); + /// The availability repository. + final AvailabilityRepository repository; + @override - Future call(UpdateDayAvailabilityParams params) { - return repository.updateDayAvailability(params.availability); + Future call(UpdateDayAvailabilityParams params) { + return repository.updateDayAvailability( + dayOfWeek: params.dayOfWeek, + status: params.status, + slots: params.slots, + ); } } /// Parameters for [UpdateDayAvailabilityUseCase]. class UpdateDayAvailabilityParams extends UseCaseArgument { - final DayAvailability availability; + /// Creates [UpdateDayAvailabilityParams]. + const UpdateDayAvailabilityParams({ + required this.dayOfWeek, + required this.status, + required this.slots, + }); - const UpdateDayAvailabilityParams(this.availability); + /// Day of week (0 = Sunday, 6 = Saturday). + final int dayOfWeek; + + /// New availability status. + final AvailabilityStatus status; + + /// Time slots for this day. + final List slots; @override - List get props => [availability]; + List get props => [dayOfWeek, status, slots]; } diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_bloc.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_bloc.dart index 6ccd905d..431b20da 100644 --- a/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_bloc.dart +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_bloc.dart @@ -1,17 +1,19 @@ import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../domain/usecases/apply_quick_set_usecase.dart'; -import '../../domain/usecases/get_weekly_availability_usecase.dart'; -import '../../domain/usecases/update_day_availability_usecase.dart'; import 'package:krow_core/core.dart'; -import 'availability_event.dart'; -import 'availability_state.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_availability/src/domain/usecases/apply_quick_set_usecase.dart'; +import 'package:staff_availability/src/domain/usecases/get_weekly_availability_usecase.dart'; +import 'package:staff_availability/src/domain/usecases/update_day_availability_usecase.dart'; +import 'package:staff_availability/src/presentation/blocs/availability_event.dart'; +import 'package:staff_availability/src/presentation/blocs/availability_state.dart'; +/// Manages availability state for the staff availability page. +/// +/// Coordinates loading, toggling, and quick-set operations through +/// domain use cases. class AvailabilityBloc extends Bloc with BlocErrorHandler { - final GetWeeklyAvailabilityUseCase getWeeklyAvailability; - final UpdateDayAvailabilityUseCase updateDayAvailability; - final ApplyQuickSetUseCase applyQuickSet; - + /// Creates an [AvailabilityBloc]. AvailabilityBloc({ required this.getWeeklyAvailability, required this.updateDayAvailability, @@ -25,6 +27,15 @@ class AvailabilityBloc extends Bloc on(_onPerformQuickSet); } + /// Use case for loading weekly availability. + final GetWeeklyAvailabilityUseCase getWeeklyAvailability; + + /// Use case for updating a single day. + final UpdateDayAvailabilityUseCase updateDayAvailability; + + /// Use case for applying a quick-set preset. + final ApplyQuickSetUseCase applyQuickSet; + Future _onLoadAvailability( LoadAvailability event, Emitter emit, @@ -33,15 +44,18 @@ class AvailabilityBloc extends Bloc await handleError( emit: emit.call, action: () async { - final days = await getWeeklyAvailability( + final List days = await getWeeklyAvailability( GetWeeklyAvailabilityParams(event.weekStart), ); + + // Determine selected date: preselected, or first day of the week. + final DateTime selectedDate = event.preselectedDate ?? event.weekStart; + emit( AvailabilityLoaded( days: days, currentWeekStart: event.weekStart, - selectedDate: event.preselectedDate ?? - (days.isNotEmpty ? days.first.date : DateTime.now()), + selectedDate: selectedDate, ), ); }, @@ -51,7 +65,6 @@ class AvailabilityBloc extends Bloc void _onSelectDate(SelectDate event, Emitter emit) { if (state is AvailabilityLoaded) { - // Clear success message on navigation emit( (state as AvailabilityLoaded).copyWith( selectedDate: event.date, @@ -66,19 +79,18 @@ class AvailabilityBloc extends Bloc Emitter emit, ) async { if (state is AvailabilityLoaded) { - final currentState = state as AvailabilityLoaded; - - // Clear message + final AvailabilityLoaded currentState = state as AvailabilityLoaded; emit(currentState.copyWith(clearSuccessMessage: true)); - final newWeekStart = currentState.currentWeekStart.add( + final DateTime newWeekStart = currentState.currentWeekStart.add( Duration(days: event.direction * 7), ); - final diff = currentState.selectedDate + // Preserve the relative day offset when navigating. + final int diff = currentState.selectedDate .difference(currentState.currentWeekStart) .inDays; - final newSelectedDate = newWeekStart.add(Duration(days: diff)); + final DateTime newSelectedDate = newWeekStart.add(Duration(days: diff)); add(LoadAvailability(newWeekStart, preselectedDate: newSelectedDate)); } @@ -89,14 +101,22 @@ class AvailabilityBloc extends Bloc Emitter emit, ) async { if (state is AvailabilityLoaded) { - final currentState = state as AvailabilityLoaded; + final AvailabilityLoaded currentState = state as AvailabilityLoaded; - final newDay = event.day.copyWith(isAvailable: !event.day.isAvailable); - final updatedDays = currentState.days.map((d) { - return d.date == event.day.date ? newDay : d; - }).toList(); + // Toggle: available -> unavailable, anything else -> available. + final AvailabilityStatus newStatus = event.day.isAvailable + ? AvailabilityStatus.unavailable + : AvailabilityStatus.available; + + final AvailabilityDay newDay = event.day.copyWith( + availabilityStatus: newStatus, + ); + + // Optimistic update. + final List updatedDays = currentState.days + .map((AvailabilityDay d) => d.date == event.day.date ? newDay : d) + .toList(); - // Optimistic update emit(currentState.copyWith( days: updatedDays, clearSuccessMessage: true, @@ -105,8 +125,13 @@ class AvailabilityBloc extends Bloc await handleError( emit: emit.call, action: () async { - await updateDayAvailability(UpdateDayAvailabilityParams(newDay)); - // Success feedback + await updateDayAvailability( + UpdateDayAvailabilityParams( + dayOfWeek: newDay.dayOfWeek, + status: newStatus, + slots: newDay.slots, + ), + ); if (state is AvailabilityLoaded) { emit( (state as AvailabilityLoaded).copyWith( @@ -116,7 +141,7 @@ class AvailabilityBloc extends Bloc } }, onError: (String errorKey) { - // Revert + // Revert on failure. if (state is AvailabilityLoaded) { return (state as AvailabilityLoaded).copyWith( days: currentState.days, @@ -133,22 +158,41 @@ class AvailabilityBloc extends Bloc Emitter emit, ) async { if (state is AvailabilityLoaded) { - final currentState = state as AvailabilityLoaded; + final AvailabilityLoaded currentState = state as AvailabilityLoaded; - final updatedSlots = event.day.slots.map((s) { - if (s.id == event.slotId) { - return s.copyWith(isAvailable: !s.isAvailable); - } - return s; - }).toList(); + // Remove the slot at the given index to toggle it off, + // or re-add if already removed. For V2, toggling a slot means + // removing it from the list (unavailable) or the day remains + // with the remaining slots. + // For simplicity, we toggle the overall day status instead of + // individual slot removal since the V2 API sends full slot arrays. - final newDay = event.day.copyWith(slots: updatedSlots); + // Build a new slots list by removing or keeping the target slot. + final List currentSlots = + List.from(event.day.slots); - final updatedDays = currentState.days.map((d) { - return d.date == event.day.date ? newDay : d; - }).toList(); + // If there's only one slot and we remove it, day becomes unavailable. + // If there are multiple, remove the indexed one. + if (event.slotIndex >= 0 && event.slotIndex < currentSlots.length) { + currentSlots.removeAt(event.slotIndex); + } - // Optimistic update + final AvailabilityStatus newStatus = currentSlots.isEmpty + ? AvailabilityStatus.unavailable + : (currentSlots.length < event.day.slots.length + ? AvailabilityStatus.partial + : event.day.availabilityStatus); + + final AvailabilityDay newDay = event.day.copyWith( + availabilityStatus: newStatus, + slots: currentSlots, + ); + + final List updatedDays = currentState.days + .map((AvailabilityDay d) => d.date == event.day.date ? newDay : d) + .toList(); + + // Optimistic update. emit(currentState.copyWith( days: updatedDays, clearSuccessMessage: true, @@ -157,8 +201,13 @@ class AvailabilityBloc extends Bloc await handleError( emit: emit.call, action: () async { - await updateDayAvailability(UpdateDayAvailabilityParams(newDay)); - // Success feedback + await updateDayAvailability( + UpdateDayAvailabilityParams( + dayOfWeek: newDay.dayOfWeek, + status: newStatus, + slots: currentSlots, + ), + ); if (state is AvailabilityLoaded) { emit( (state as AvailabilityLoaded).copyWith( @@ -168,7 +217,7 @@ class AvailabilityBloc extends Bloc } }, onError: (String errorKey) { - // Revert + // Revert on failure. if (state is AvailabilityLoaded) { return (state as AvailabilityLoaded).copyWith( days: currentState.days, @@ -185,7 +234,7 @@ class AvailabilityBloc extends Bloc Emitter emit, ) async { if (state is AvailabilityLoaded) { - final currentState = state as AvailabilityLoaded; + final AvailabilityLoaded currentState = state as AvailabilityLoaded; emit( currentState.copyWith( @@ -197,13 +246,18 @@ class AvailabilityBloc extends Bloc await handleError( emit: emit.call, action: () async { - final newDays = await applyQuickSet( + await applyQuickSet( ApplyQuickSetParams(currentState.currentWeekStart, event.type), ); + // Reload the week to get updated data from the server. + final List refreshed = await getWeeklyAvailability( + GetWeeklyAvailabilityParams(currentState.currentWeekStart), + ); + emit( currentState.copyWith( - days: newDays, + days: refreshed, isActionInProgress: false, successMessage: 'Availability updated', ), @@ -221,4 +275,3 @@ class AvailabilityBloc extends Bloc } } } - diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_cubit.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_cubit.dart deleted file mode 100644 index 2175a7e1..00000000 --- a/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_cubit.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:equatable/equatable.dart'; - -// --- State --- -class AvailabilityState extends Equatable { - final DateTime currentWeekStart; - final DateTime selectedDate; - final Map dayAvailability; - final Map> timeSlotAvailability; - - const AvailabilityState({ - required this.currentWeekStart, - required this.selectedDate, - required this.dayAvailability, - required this.timeSlotAvailability, - }); - - AvailabilityState copyWith({ - DateTime? currentWeekStart, - DateTime? selectedDate, - Map? dayAvailability, - Map>? timeSlotAvailability, - }) { - return AvailabilityState( - currentWeekStart: currentWeekStart ?? this.currentWeekStart, - selectedDate: selectedDate ?? this.selectedDate, - dayAvailability: dayAvailability ?? this.dayAvailability, - timeSlotAvailability: timeSlotAvailability ?? this.timeSlotAvailability, - ); - } - - @override - List get props => [ - currentWeekStart, - selectedDate, - dayAvailability, - timeSlotAvailability, - ]; -} - -// --- Cubit --- -class AvailabilityCubit extends Cubit { - AvailabilityCubit() - : super(AvailabilityState( - currentWeekStart: _getStartOfWeek(DateTime.now()), - selectedDate: DateTime.now(), - dayAvailability: { - 'monday': true, - 'tuesday': true, - 'wednesday': true, - 'thursday': true, - 'friday': true, - 'saturday': false, - 'sunday': false, - }, - timeSlotAvailability: { - 'monday': {'morning': true, 'afternoon': true, 'evening': true}, - 'tuesday': {'morning': true, 'afternoon': true, 'evening': true}, - 'wednesday': {'morning': true, 'afternoon': true, 'evening': true}, - 'thursday': {'morning': true, 'afternoon': true, 'evening': true}, - 'friday': {'morning': true, 'afternoon': true, 'evening': true}, - 'saturday': {'morning': false, 'afternoon': false, 'evening': false}, - 'sunday': {'morning': false, 'afternoon': false, 'evening': false}, - }, - )); - - static DateTime _getStartOfWeek(DateTime date) { - final diff = date.weekday - 1; // Mon=1 -> 0 - final start = date.subtract(Duration(days: diff)); - return DateTime(start.year, start.month, start.day); - } - - void selectDate(DateTime date) { - emit(state.copyWith(selectedDate: date)); - } - - void navigateWeek(int weeks) { - emit(state.copyWith( - currentWeekStart: state.currentWeekStart.add(Duration(days: weeks * 7)), - )); - } - - void toggleDay(String dayKey) { - final currentObj = Map.from(state.dayAvailability); - currentObj[dayKey] = !(currentObj[dayKey] ?? false); - emit(state.copyWith(dayAvailability: currentObj)); - } - - void toggleSlot(String dayKey, String slotId) { - final allSlots = Map>.from(state.timeSlotAvailability); - final daySlots = Map.from(allSlots[dayKey] ?? {}); - - // Default to true if missing, so we toggle to false - final currentVal = daySlots[slotId] ?? true; - daySlots[slotId] = !currentVal; - - allSlots[dayKey] = daySlots; - emit(state.copyWith(timeSlotAvailability: allSlots)); - } - - void quickSet(String type) { - final newAvailability = {}; - final days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; - - switch (type) { - case 'all': - for (var d in days) { - newAvailability[d] = true; - } - break; - case 'weekdays': - for (var d in days) { - newAvailability[d] = (d != 'saturday' && d != 'sunday'); - } - break; - case 'weekends': - for (var d in days) { - newAvailability[d] = (d == 'saturday' || d == 'sunday'); - } - break; - case 'clear': - for (var d in days) { - newAvailability[d] = false; - } - break; - } - - emit(state.copyWith(dayAvailability: newAvailability)); - } -} diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_event.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_event.dart index e6074504..70e3f540 100644 --- a/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_event.dart +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_event.dart @@ -1,54 +1,89 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +/// Base class for availability events. abstract class AvailabilityEvent extends Equatable { + /// Creates an [AvailabilityEvent]. const AvailabilityEvent(); + @override - List get props => []; + List get props => []; } +/// Requests loading availability for a given week. class LoadAvailability extends AvailabilityEvent { - final DateTime weekStart; - final DateTime? preselectedDate; // Maintain selection after reload - + /// Creates a [LoadAvailability] event. const LoadAvailability(this.weekStart, {this.preselectedDate}); - + + /// The Monday of the week to load. + final DateTime weekStart; + + /// Optional date to pre-select after loading. + final DateTime? preselectedDate; + @override - List get props => [weekStart, preselectedDate]; + List get props => [weekStart, preselectedDate]; } +/// User selected a date in the week strip. class SelectDate extends AvailabilityEvent { - final DateTime date; + /// Creates a [SelectDate] event. const SelectDate(this.date); + + /// The selected date. + final DateTime date; + @override - List get props => [date]; + List get props => [date]; } +/// Toggles the overall availability status of a day. class ToggleDayStatus extends AvailabilityEvent { - final DayAvailability day; + /// Creates a [ToggleDayStatus] event. const ToggleDayStatus(this.day); + + /// The day to toggle. + final AvailabilityDay day; + @override - List get props => [day]; + List get props => [day]; } +/// Toggles an individual time slot within a day. class ToggleSlotStatus extends AvailabilityEvent { - final DayAvailability day; - final String slotId; - const ToggleSlotStatus(this.day, this.slotId); + /// Creates a [ToggleSlotStatus] event. + const ToggleSlotStatus(this.day, this.slotIndex); + + /// The parent day. + final AvailabilityDay day; + + /// Index of the slot to toggle within [day.slots]. + final int slotIndex; + @override - List get props => [day, slotId]; + List get props => [day, slotIndex]; } +/// Navigates forward or backward by one week. class NavigateWeek extends AvailabilityEvent { - final int direction; // -1 or 1 + /// Creates a [NavigateWeek] event. const NavigateWeek(this.direction); + + /// -1 for previous week, 1 for next week. + final int direction; + @override - List get props => [direction]; + List get props => [direction]; } +/// Applies a quick-set preset to the current week. class PerformQuickSet extends AvailabilityEvent { - final String type; // all, weekdays, weekends, clear + /// Creates a [PerformQuickSet] event. const PerformQuickSet(this.type); + + /// One of: `all`, `weekdays`, `weekends`, `clear`. + final String type; + @override - List get props => [type]; + List get props => [type]; } diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_state.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_state.dart index e48fed83..ce1a6417 100644 --- a/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_state.dart +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_state.dart @@ -1,23 +1,24 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +/// Base class for availability states. abstract class AvailabilityState extends Equatable { + /// Creates an [AvailabilityState]. const AvailabilityState(); + @override - List get props => []; + List get props => []; } +/// Initial state before any data is loaded. class AvailabilityInitial extends AvailabilityState {} +/// Loading state while fetching availability data. class AvailabilityLoading extends AvailabilityState {} +/// State when availability data has been loaded. class AvailabilityLoaded extends AvailabilityState { - final List days; - final DateTime currentWeekStart; - final DateTime selectedDate; - final bool isActionInProgress; - final String? successMessage; - + /// Creates an [AvailabilityLoaded] state. const AvailabilityLoaded({ required this.days, required this.currentWeekStart, @@ -26,20 +27,41 @@ class AvailabilityLoaded extends AvailabilityState { this.successMessage, }); - /// Helper to get the currently selected day's availability object - DayAvailability get selectedDayAvailability { + /// The list of daily availability entries for the current week. + final List days; + + /// The Monday of the currently displayed week. + final DateTime currentWeekStart; + + /// The currently selected date in the week strip. + final DateTime selectedDate; + + /// Whether a background action (update/quick-set) is in progress. + final bool isActionInProgress; + + /// Optional success message for snackbar feedback. + final String? successMessage; + + /// The [AvailabilityDay] matching the current [selectedDate]. + AvailabilityDay get selectedDayAvailability { + final String selectedIso = _toIsoDate(selectedDate); return days.firstWhere( - (d) => isSameDay(d.date, selectedDate), - orElse: () => DayAvailability(date: selectedDate), // Fallback + (AvailabilityDay d) => d.date == selectedIso, + orElse: () => AvailabilityDay( + date: selectedIso, + dayOfWeek: selectedDate.weekday % 7, + availabilityStatus: AvailabilityStatus.unavailable, + ), ); } + /// Creates a copy with optionally replaced fields. AvailabilityLoaded copyWith({ - List? days, + List? days, DateTime? currentWeekStart, DateTime? selectedDate, bool? isActionInProgress, - String? successMessage, // Nullable override + String? successMessage, bool clearSuccessMessage = false, }) { return AvailabilityLoaded( @@ -47,21 +69,41 @@ class AvailabilityLoaded extends AvailabilityState { currentWeekStart: currentWeekStart ?? this.currentWeekStart, selectedDate: selectedDate ?? this.selectedDate, isActionInProgress: isActionInProgress ?? this.isActionInProgress, - successMessage: clearSuccessMessage ? null : (successMessage ?? this.successMessage), + successMessage: + clearSuccessMessage ? null : (successMessage ?? this.successMessage), ); } + /// Checks whether two [DateTime]s represent the same calendar day. static bool isSameDay(DateTime a, DateTime b) { return a.year == b.year && a.month == b.month && a.day == b.day; } + /// Formats a [DateTime] as `YYYY-MM-DD`. + static String _toIsoDate(DateTime date) { + return '${date.year.toString().padLeft(4, '0')}-' + '${date.month.toString().padLeft(2, '0')}-' + '${date.day.toString().padLeft(2, '0')}'; + } + @override - List get props => [days, currentWeekStart, selectedDate, isActionInProgress, successMessage]; + List get props => [ + days, + currentWeekStart, + selectedDate, + isActionInProgress, + successMessage, + ]; } +/// Error state when availability loading or an action fails. class AvailabilityError extends AvailabilityState { - final String message; + /// Creates an [AvailabilityError] state. const AvailabilityError(this.message); + + /// Error key for localisation. + final String message; + @override - List get props => [message]; + List get props => [message]; } diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart index 7d254a70..cd82a9cf 100644 --- a/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart @@ -6,13 +6,14 @@ import 'package:flutter_modular/flutter_modular.dart' hide ModularWatchExtension; import 'package:intl/intl.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_availability/src/presentation/blocs/availability_bloc.dart'; +import 'package:staff_availability/src/presentation/blocs/availability_event.dart'; +import 'package:staff_availability/src/presentation/blocs/availability_state.dart'; +import 'package:staff_availability/src/presentation/widgets/availability_page_skeleton/availability_page_skeleton.dart'; -import '../blocs/availability_bloc.dart'; -import '../blocs/availability_event.dart'; -import '../blocs/availability_state.dart'; -import '../widgets/availability_page_skeleton/availability_page_skeleton.dart'; - +/// Page for managing staff weekly availability. class AvailabilityPage extends StatefulWidget { + /// Creates an [AvailabilityPage]. const AvailabilityPage({super.key}); @override @@ -28,10 +29,10 @@ class _AvailabilityPageState extends State { _calculateInitialWeek(); } + /// Computes the Monday of the current week and triggers initial load. void _calculateInitialWeek() { - final today = DateTime.now(); - final day = today.weekday; // Mon=1, Sun=7 - final diff = day - 1; // Assuming Monday start + final DateTime today = DateTime.now(); + final int diff = today.weekday - 1; DateTime currentWeekStart = today.subtract(Duration(days: diff)); currentWeekStart = DateTime( currentWeekStart.year, @@ -43,25 +44,25 @@ class _AvailabilityPageState extends State { @override Widget build(BuildContext context) { - final i18n = Translations.of(context).staff.availability; - return BlocProvider.value( + final dynamic i18n = Translations.of(context).staff.availability; + return BlocProvider.value( value: _bloc, child: Scaffold( appBar: UiAppBar( - title: i18n.title, + title: i18n.title as String, centerTitle: false, showBackButton: true, ), body: BlocListener( - listener: (context, state) { - if (state is AvailabilityLoaded && state.successMessage != null) { + listener: (BuildContext context, AvailabilityState state) { + if (state is AvailabilityLoaded && + state.successMessage != null) { UiSnackbar.show( context, message: state.successMessage!, type: UiSnackbarType.success, ); } - if (state is AvailabilityError) { UiSnackbar.show( context, @@ -71,59 +72,19 @@ class _AvailabilityPageState extends State { } }, child: BlocBuilder( - builder: (context, state) { + builder: (BuildContext context, AvailabilityState state) { if (state is AvailabilityLoading) { return const AvailabilityPageSkeleton(); } else if (state is AvailabilityLoaded) { - return Stack( - children: [ - SingleChildScrollView( - padding: const EdgeInsets.only(bottom: 100), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space5, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: UiConstants.space6, - children: [ - _buildQuickSet(context), - _buildWeekNavigation(context, state), - _buildSelectedDayAvailability( - context, - state.selectedDayAvailability, - ), - _buildInfoCard(), - ], - ), - ), - ], - ), - ), - if (state.isActionInProgress) - Positioned.fill( - child: Container( - color: UiColors.white.withValues(alpha: 0.5), - child: const Center(child: CircularProgressIndicator()), - ), - ), - ], - ); + return _buildLoaded(context, state); } else if (state is AvailabilityError) { return Center( child: Padding( padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - translateErrorKey(state.message), - textAlign: TextAlign.center, - style: UiTypography.body2r.textSecondary, - ), - ], + child: Text( + translateErrorKey(state.message), + textAlign: TextAlign.center, + style: UiTypography.body2r.textSecondary, ), ), ); @@ -136,8 +97,48 @@ class _AvailabilityPageState extends State { ); } + Widget _buildLoaded(BuildContext context, AvailabilityLoaded state) { + return Stack( + children: [ + SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 100), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: UiConstants.space6), + _buildQuickSet(context), + const SizedBox(height: UiConstants.space6), + _buildWeekNavigation(context, state), + const SizedBox(height: UiConstants.space6), + _buildSelectedDayAvailability( + context, + state.selectedDayAvailability, + ), + const SizedBox(height: UiConstants.space6), + _buildInfoCard(), + ], + ), + ), + ), + if (state.isActionInProgress) + Positioned.fill( + child: Container( + color: UiColors.white.withValues(alpha: 0.5), + child: const Center(child: CircularProgressIndicator()), + ), + ), + ], + ); + } + + // ── Quick Set Section ───────────────────────────────────────────────── + Widget _buildQuickSet(BuildContext context) { - final i18n = Translations.of(context).staff.availability; + final dynamic i18n = Translations.of(context).staff.availability; return Container( padding: const EdgeInsets.all(UiConstants.space4), decoration: BoxDecoration( @@ -146,30 +147,39 @@ class _AvailabilityPageState extends State { ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - i18n.quick_set_title, - style: UiTypography.body2b, - ), + children: [ + Text(i18n.quick_set_title as String, style: UiTypography.body2b), const SizedBox(height: UiConstants.space3), Row( - children: [ + children: [ Expanded( - child: _buildQuickSetButton(context, i18n.all_week, 'all'), - ), - const SizedBox(width: UiConstants.space2), - Expanded( - child: _buildQuickSetButton(context, i18n.weekdays, 'weekdays'), - ), - const SizedBox(width: UiConstants.space2), - Expanded( - child: _buildQuickSetButton(context, i18n.weekends, 'weekends'), + child: _buildQuickSetButton( + context, + i18n.all_week as String, + 'all', + ), ), const SizedBox(width: UiConstants.space2), Expanded( child: _buildQuickSetButton( context, - i18n.clear_all, + i18n.weekdays as String, + 'weekdays', + ), + ), + const SizedBox(width: UiConstants.space2), + Expanded( + child: _buildQuickSetButton( + context, + i18n.weekends as String, + 'weekends', + ), + ), + const SizedBox(width: UiConstants.space2), + Expanded( + child: _buildQuickSetButton( + context, + i18n.clear_all as String, 'clear', isDestructive: true, ), @@ -203,9 +213,8 @@ class _AvailabilityPageState extends State { shape: RoundedRectangleBorder( borderRadius: UiConstants.radiusLg, ), - foregroundColor: isDestructive - ? UiColors.destructive - : UiColors.primary, + foregroundColor: + isDestructive ? UiColors.destructive : UiColors.primary, ), child: Text( label, @@ -217,10 +226,15 @@ class _AvailabilityPageState extends State { ); } - Widget _buildWeekNavigation(BuildContext context, AvailabilityLoaded state) { - // Middle date for month display - final middleDate = state.currentWeekStart.add(const Duration(days: 3)); - final monthYear = DateFormat('MMMM yyyy').format(middleDate); + // ── Week Navigation ─────────────────────────────────────────────────── + + Widget _buildWeekNavigation( + BuildContext context, + AvailabilityLoaded state, + ) { + final DateTime middleDate = + state.currentWeekStart.add(const Duration(days: 3)); + final String monthYear = DateFormat('MMMM yyyy').format(middleDate); return Container( padding: const EdgeInsets.all(UiConstants.space4), @@ -230,37 +244,33 @@ class _AvailabilityPageState extends State { border: Border.all(color: UiColors.border), ), child: Column( - children: [ - // Nav Header + children: [ Padding( padding: const EdgeInsets.only(bottom: UiConstants.space4), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + children: [ _buildNavButton( UiIcons.chevronLeft, () => context.read().add( - const NavigateWeek(-1), - ), - ), - Text( - monthYear, - style: UiTypography.title2b, + const NavigateWeek(-1), + ), ), + Text(monthYear, style: UiTypography.title2b), _buildNavButton( UiIcons.chevronRight, () => context.read().add( - const NavigateWeek(1), - ), + const NavigateWeek(1), + ), ), ], ), ), - // Days Row Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: state.days - .map((day) => _buildDayItem(context, day, state.selectedDate)) + .map((AvailabilityDay day) => + _buildDayItem(context, day, state.selectedDate)) .toList(), ), ], @@ -285,16 +295,19 @@ class _AvailabilityPageState extends State { Widget _buildDayItem( BuildContext context, - DayAvailability day, + AvailabilityDay day, DateTime selectedDate, ) { - final isSelected = AvailabilityLoaded.isSameDay(day.date, selectedDate); - final isAvailable = day.isAvailable; - final isToday = AvailabilityLoaded.isSameDay(day.date, DateTime.now()); + final DateTime dayDate = DateTime.parse(day.date); + final bool isSelected = AvailabilityLoaded.isSameDay(dayDate, selectedDate); + final bool isAvailable = day.isAvailable; + final bool isToday = + AvailabilityLoaded.isSameDay(dayDate, DateTime.now()); return Expanded( child: GestureDetector( - onTap: () => context.read().add(SelectDate(day.date)), + onTap: () => + context.read().add(SelectDate(dayDate)), child: Container( margin: const EdgeInsets.symmetric(horizontal: 2), padding: const EdgeInsets.symmetric(vertical: UiConstants.space3), @@ -314,11 +327,11 @@ class _AvailabilityPageState extends State { child: Stack( clipBehavior: Clip.none, alignment: Alignment.center, - children: [ + children: [ Column( - children: [ + children: [ Text( - day.date.day.toString().padLeft(2, '0'), + dayDate.day.toString().padLeft(2, '0'), style: UiTypography.title1m.copyWith( fontWeight: FontWeight.bold, color: isSelected @@ -330,7 +343,7 @@ class _AvailabilityPageState extends State { ), const SizedBox(height: 2), Text( - DateFormat('EEE').format(day.date), + DateFormat('EEE').format(dayDate), style: UiTypography.footnote2r.copyWith( color: isSelected ? UiColors.white.withValues(alpha: 0.8) @@ -360,12 +373,15 @@ class _AvailabilityPageState extends State { ); } + // ── Selected Day Detail ─────────────────────────────────────────────── + Widget _buildSelectedDayAvailability( BuildContext context, - DayAvailability day, + AvailabilityDay day, ) { - final dateStr = DateFormat('EEEE, MMM d').format(day.date); - final isAvailable = day.isAvailable; + final DateTime dayDate = DateTime.parse(day.date); + final String dateStr = DateFormat('EEEE, MMM d').format(dayDate); + final bool isAvailable = day.isAvailable; return Container( padding: const EdgeInsets.all(UiConstants.space5), @@ -375,18 +391,14 @@ class _AvailabilityPageState extends State { border: Border.all(color: UiColors.border), ), child: Column( - children: [ - // Header Row + children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - dateStr, - style: UiTypography.title2b, - ), + children: [ + Text(dateStr, style: UiTypography.title2b), Text( isAvailable ? Translations.of(context) @@ -403,94 +415,54 @@ class _AvailabilityPageState extends State { ), Switch( value: isAvailable, - onChanged: (val) => - context.read().add(ToggleDayStatus(day)), + onChanged: (bool val) => context + .read() + .add(ToggleDayStatus(day)), activeThumbColor: UiColors.primary, ), ], ), - const SizedBox(height: UiConstants.space4), - - // Time Slots (only from Domain) - ...day.slots.map((slot) { - // Get UI config for this slot ID - final uiConfig = _getSlotUiConfig(slot.id); - - return _buildTimeSlotItem(context, day, slot, uiConfig); + ...day.slots.asMap().entries.map((MapEntry entry) { + final int index = entry.key; + final TimeSlot slot = entry.value; + return _buildTimeSlotItem(context, day, slot, index); }), ], ), ); } - Map _getSlotUiConfig(String slotId) { - switch (slotId) { - case 'morning': - return { - 'icon': UiIcons.sunrise, - 'bg': UiColors.primary.withValues(alpha: 0.1), - 'iconColor': UiColors.primary, - }; - case 'afternoon': - return { - 'icon': UiIcons.sun, - 'bg': UiColors.primary.withValues(alpha: 0.2), - 'iconColor': UiColors.primary, - }; - case 'evening': - return { - 'icon': UiIcons.moon, - 'bg': UiColors.bgSecondary, - 'iconColor': UiColors.foreground, - }; - default: - return { - 'icon': UiIcons.clock, - 'bg': UiColors.bgSecondary, - 'iconColor': UiColors.iconSecondary, - }; - } - } - Widget _buildTimeSlotItem( BuildContext context, - DayAvailability day, - AvailabilitySlot slot, - Map uiConfig, + AvailabilityDay day, + TimeSlot slot, + int index, ) { - // Determine styles based on state - final isEnabled = day.isAvailable; - final isActive = slot.isAvailable; + final bool isEnabled = day.isAvailable; + final Map uiConfig = _getSlotUiConfig(slot); - // Container style Color bgColor; Color borderColor; if (!isEnabled) { bgColor = UiColors.bgSecondary; borderColor = UiColors.borderInactive; - } else if (isActive) { + } else { bgColor = UiColors.primary.withValues(alpha: 0.05); borderColor = UiColors.primary.withValues(alpha: 0.2); - } else { - bgColor = UiColors.bgSecondary; - borderColor = UiColors.borderPrimary; } - // Text colors - final titleColor = (isEnabled && isActive) - ? UiColors.foreground - : UiColors.mutedForeground; - final subtitleColor = (isEnabled && isActive) - ? UiColors.mutedForeground - : UiColors.textInactive; + final Color titleColor = + isEnabled ? UiColors.foreground : UiColors.mutedForeground; + final Color subtitleColor = + isEnabled ? UiColors.mutedForeground : UiColors.textInactive; return GestureDetector( onTap: isEnabled - ? () => context.read().add( - ToggleSlotStatus(day, slot.id), - ) + ? () => context + .read() + .add(ToggleSlotStatus(day, index)) : null, child: AnimatedContainer( duration: const Duration(milliseconds: 200), @@ -502,40 +474,38 @@ class _AvailabilityPageState extends State { border: Border.all(color: borderColor, width: 2), ), child: Row( - children: [ - // Icon + children: [ Container( width: 40, height: 40, decoration: BoxDecoration( - color: uiConfig['bg'], - borderRadius: BorderRadius.circular(UiConstants.radiusBase), + color: uiConfig['bg'] as Color, + borderRadius: + BorderRadius.circular(UiConstants.radiusBase), ), child: Icon( - uiConfig['icon'], - color: uiConfig['iconColor'], + uiConfig['icon'] as IconData, + color: uiConfig['iconColor'] as Color, size: 20, ), ), const SizedBox(width: UiConstants.space3), - // Text Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ Text( - slot.label, + '${slot.startTime} - ${slot.endTime}', style: UiTypography.body2m.copyWith(color: titleColor), ), Text( - slot.timeRange, + _slotPeriodLabel(slot), style: UiTypography.body3r.copyWith(color: subtitleColor), ), ], ), ), - // Checkbox indicator - if (isEnabled && isActive) + if (isEnabled) Container( width: 24, height: 24, @@ -548,18 +518,6 @@ class _AvailabilityPageState extends State { size: 16, color: UiColors.white, ), - ) - else if (isEnabled && !isActive) - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: UiColors.borderStill, - width: 2, - ), - ), ), ], ), @@ -567,8 +525,48 @@ class _AvailabilityPageState extends State { ); } + /// Returns UI config (icon, bg, iconColor) based on time slot hours. + Map _getSlotUiConfig(TimeSlot slot) { + final int hour = _parseHour(slot.startTime); + if (hour < 12) { + return { + 'icon': UiIcons.sunrise, + 'bg': UiColors.primary.withValues(alpha: 0.1), + 'iconColor': UiColors.primary, + }; + } else if (hour < 17) { + return { + 'icon': UiIcons.sun, + 'bg': UiColors.primary.withValues(alpha: 0.2), + 'iconColor': UiColors.primary, + }; + } else { + return { + 'icon': UiIcons.moon, + 'bg': UiColors.bgSecondary, + 'iconColor': UiColors.foreground, + }; + } + } + + /// Parses the hour from an `HH:MM` string. + int _parseHour(String time) { + final List parts = time.split(':'); + return int.tryParse(parts.first) ?? 0; + } + + /// Returns a human-readable period label for a slot. + String _slotPeriodLabel(TimeSlot slot) { + final int hour = _parseHour(slot.startTime); + if (hour < 12) return 'Morning'; + if (hour < 17) return 'Afternoon'; + return 'Evening'; + } + + // ── Info Card ───────────────────────────────────────────────────────── + Widget _buildInfoCard() { - final i18n = Translations.of(context).staff.availability; + final dynamic i18n = Translations.of(context).staff.availability; return Container( padding: const EdgeInsets.all(UiConstants.space4), decoration: BoxDecoration( @@ -577,20 +575,20 @@ class _AvailabilityPageState extends State { ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, - spacing: UiConstants.space3, - children: [ + children: [ const Icon(UiIcons.clock, size: 20, color: UiColors.primary), + const SizedBox(width: UiConstants.space3), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - spacing: UiConstants.space1, - children: [ + children: [ Text( - i18n.auto_match_title, + i18n.auto_match_title as String, style: UiTypography.body2m, ), + const SizedBox(height: UiConstants.space1), Text( - i18n.auto_match_description, + i18n.auto_match_description as String, style: UiTypography.body3r.textSecondary, ), ], diff --git a/apps/mobile/packages/features/staff/availability/lib/src/staff_availability_module.dart b/apps/mobile/packages/features/staff/availability/lib/src/staff_availability_module.dart index 7c7b7a74..f77f1bb1 100644 --- a/apps/mobile/packages/features/staff/availability/lib/src/staff_availability_module.dart +++ b/apps/mobile/packages/features/staff/availability/lib/src/staff_availability_module.dart @@ -1,31 +1,49 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_availability/src/data/repositories_impl/availability_repository_impl.dart'; +import 'package:staff_availability/src/domain/repositories/availability_repository.dart'; +import 'package:staff_availability/src/domain/usecases/apply_quick_set_usecase.dart'; +import 'package:staff_availability/src/domain/usecases/get_weekly_availability_usecase.dart'; +import 'package:staff_availability/src/domain/usecases/update_day_availability_usecase.dart'; +import 'package:staff_availability/src/presentation/blocs/availability_bloc.dart'; import 'package:staff_availability/src/presentation/pages/availability_page.dart'; -import 'data/repositories_impl/availability_repository_impl.dart'; -import 'domain/repositories/availability_repository.dart'; -import 'domain/usecases/apply_quick_set_usecase.dart'; -import 'domain/usecases/get_weekly_availability_usecase.dart'; -import 'domain/usecases/update_day_availability_usecase.dart'; -import 'presentation/blocs/availability_bloc.dart'; - +/// Module for the staff availability feature. +/// +/// Uses the V2 REST API via [BaseApiService] for all backend access. class StaffAvailabilityModule extends Module { @override - List get imports => [DataConnectModule()]; + List get imports => [CoreModule()]; @override void binds(Injector i) { - // Repository - i.addLazySingleton(AvailabilityRepositoryImpl.new); + // Repository — V2 API + i.addLazySingleton( + () => AvailabilityRepositoryImpl( + apiService: i.get(), + ), + ); - // UseCases - i.addLazySingleton(GetWeeklyAvailabilityUseCase.new); - i.addLazySingleton(UpdateDayAvailabilityUseCase.new); - i.addLazySingleton(ApplyQuickSetUseCase.new); + // Use cases + i.addLazySingleton( + () => GetWeeklyAvailabilityUseCase(i.get()), + ); + i.addLazySingleton( + () => UpdateDayAvailabilityUseCase(i.get()), + ); + i.addLazySingleton( + () => ApplyQuickSetUseCase(i.get()), + ); // BLoC - i.add(AvailabilityBloc.new); + i.add( + () => AvailabilityBloc( + getWeeklyAvailability: i.get(), + updateDayAvailability: i.get(), + applyQuickSet: i.get(), + ), + ); } @override diff --git a/apps/mobile/packages/features/staff/availability/pubspec.yaml b/apps/mobile/packages/features/staff/availability/pubspec.yaml index b8353e1a..af073f88 100644 --- a/apps/mobile/packages/features/staff/availability/pubspec.yaml +++ b/apps/mobile/packages/features/staff/availability/pubspec.yaml @@ -19,8 +19,6 @@ dependencies: path: ../../../design_system krow_domain: path: ../../../domain - krow_data_connect: - path: ../../../data_connect krow_core: path: ../../../core @@ -28,8 +26,6 @@ dependencies: equatable: ^2.0.5 intl: ^0.20.0 flutter_modular: ^6.3.2 - firebase_data_connect: ^0.2.2+2 - firebase_auth: ^6.1.4 dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart index c2509429..c0cfe0c2 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart @@ -1,235 +1,99 @@ -import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; -import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/repositories/clock_in_repository_interface.dart'; +import 'package:staff_clock_in/src/domain/repositories/clock_in_repository_interface.dart'; -/// Implementation of [ClockInRepositoryInterface] using Firebase Data Connect. +/// Implementation of [ClockInRepositoryInterface] using the V2 REST API. +/// +/// All backend calls go through [BaseApiService] with [V2ApiEndpoints]. +/// The old Data Connect implementation has been removed. class ClockInRepositoryImpl implements ClockInRepositoryInterface { - ClockInRepositoryImpl() : _service = dc.DataConnectService.instance; + /// Creates a [ClockInRepositoryImpl] backed by the V2 API. + ClockInRepositoryImpl({required BaseApiService apiService}) + : _apiService = apiService; - final dc.DataConnectService _service; - final Map _shiftToApplicationId = {}; - String? _activeApplicationId; - - ({fdc.Timestamp start, fdc.Timestamp end}) _utcDayRange(DateTime localDay) { - final DateTime dayStartUtc = DateTime.utc( - localDay.year, - localDay.month, - localDay.day, - ); - final DateTime dayEndUtc = DateTime.utc( - localDay.year, - localDay.month, - localDay.day, - 23, - 59, - 59, - 999, - 999, - ); - return ( - start: _service.toTimestamp(dayStartUtc), - end: _service.toTimestamp(dayEndUtc), - ); - } - - /// Helper to find today's applications ordered with the closest at the end. - Future> _getTodaysApplications( - String staffId, - ) async { - final DateTime now = DateTime.now(); - final ({fdc.Timestamp start, fdc.Timestamp end}) range = _utcDayRange(now); - final fdc.QueryResult result = await _service.run( - () => _service.connector - .getApplicationsByStaffId(staffId: staffId) - .dayStart(range.start) - .dayEnd(range.end) - .execute(), - ); - - final List apps = - result.data.applications; - if (apps.isEmpty) return const []; - - _shiftToApplicationId - ..clear() - ..addEntries(apps.map((dc.GetApplicationsByStaffIdApplications app) => - MapEntry(app.shiftId, app.id))); - - apps.sort((dc.GetApplicationsByStaffIdApplications a, - dc.GetApplicationsByStaffIdApplications b) { - final DateTime? aTime = - _service.toDateTime(a.shift.startTime) ?? _service.toDateTime(a.shift.date); - final DateTime? bTime = - _service.toDateTime(b.shift.startTime) ?? _service.toDateTime(b.shift.date); - if (aTime == null && bTime == null) return 0; - if (aTime == null) return -1; - if (bTime == null) return 1; - final Duration aDiff = aTime.difference(now).abs(); - final Duration bDiff = bTime.difference(now).abs(); - return bDiff.compareTo(aDiff); // closest at the end - }); - - return apps; - } + final BaseApiService _apiService; @override Future> getTodaysShifts() async { - return _service.run(() async { - final String staffId = await _service.getStaffId(); - final List apps = - await _getTodaysApplications(staffId); - if (apps.isEmpty) return const []; - - final List shifts = []; - for (final dc.GetApplicationsByStaffIdApplications app in apps) { - final dc.GetApplicationsByStaffIdApplicationsShift shift = app.shift; - final DateTime? startDt = _service.toDateTime(app.shiftRole.startTime); - final DateTime? endDt = _service.toDateTime(app.shiftRole.endTime); - final DateTime? createdDt = _service.toDateTime(app.createdAt); - - final String roleName = app.shiftRole.role.name; - final String orderName = - (shift.order.eventName ?? '').trim().isNotEmpty - ? shift.order.eventName! - : shift.order.business.businessName; - final String title = '$roleName - $orderName'; - shifts.add( - Shift( - id: shift.id, - title: title, - clientName: shift.order.business.businessName, - logoUrl: shift.order.business.companyLogoUrl ?? '', - hourlyRate: app.shiftRole.role.costPerHour, - location: shift.location ?? '', - locationAddress: shift.order.teamHub.hubName, - date: startDt?.toIso8601String() ?? '', - startTime: startDt?.toIso8601String() ?? '', - endTime: endDt?.toIso8601String() ?? '', - createdDate: createdDt?.toIso8601String() ?? '', - status: shift.status?.stringValue, - description: shift.description, - latitude: shift.latitude, - longitude: shift.longitude, - ), - ); - } - - return shifts; - }); + final ApiResponse response = await _apiService.get( + V2ApiEndpoints.staffClockInShiftsToday, + ); + final List items = response.data['items'] as List; + // TODO: Ask BE to add latitude, longitude, hourlyRate, and clientName + // to the listTodayShifts query to avoid mapping gaps and extra API calls. + return items + .map( + (dynamic json) => + _mapTodayShiftJsonToShift(json as Map), + ) + .toList(); } @override Future getAttendanceStatus() async { - return _service.run(() async { - final String staffId = await _service.getStaffId(); - final List apps = - await _getTodaysApplications(staffId); - if (apps.isEmpty) { - return const AttendanceStatus(isCheckedIn: false); - } - - dc.GetApplicationsByStaffIdApplications? activeApp; - for (final dc.GetApplicationsByStaffIdApplications app in apps) { - if (app.checkInTime != null && app.checkOutTime == null) { - if (activeApp == null) { - activeApp = app; - } else { - final DateTime? current = _service.toDateTime(activeApp.checkInTime); - final DateTime? next = _service.toDateTime(app.checkInTime); - if (current == null || (next != null && next.isAfter(current))) { - activeApp = app; - } - } - } - } - - if (activeApp == null) { - _activeApplicationId = null; - return const AttendanceStatus(isCheckedIn: false); - } - - _activeApplicationId = activeApp.id; - - return AttendanceStatus( - isCheckedIn: true, - checkInTime: _service.toDateTime(activeApp.checkInTime), - checkOutTime: _service.toDateTime(activeApp.checkOutTime), - activeShiftId: activeApp.shiftId, - activeApplicationId: activeApp.id, - ); - }); + final ApiResponse response = await _apiService.get( + V2ApiEndpoints.staffClockInStatus, + ); + return AttendanceStatus.fromJson(response.data as Map); } @override - Future clockIn({required String shiftId, String? notes}) async { - return _service.run(() async { - final String staffId = await _service.getStaffId(); - - final String? cachedAppId = _shiftToApplicationId[shiftId]; - dc.GetApplicationsByStaffIdApplications? app; - if (cachedAppId != null) { - try { - final List apps = - await _getTodaysApplications(staffId); - app = apps.firstWhere( - (dc.GetApplicationsByStaffIdApplications a) => a.id == cachedAppId); - } catch (_) {} - } - app ??= (await _getTodaysApplications(staffId)).firstWhere( - (dc.GetApplicationsByStaffIdApplications a) => a.shiftId == shiftId); - - final fdc.Timestamp checkInTs = _service.toTimestamp(DateTime.now()); - - await _service.connector - .updateApplicationStatus( - id: app.id, - ) - .checkInTime(checkInTs) - .execute(); - _activeApplicationId = app.id; - - return getAttendanceStatus(); - }); + Future clockIn({ + required String shiftId, + String? notes, + }) async { + await _apiService.post( + V2ApiEndpoints.staffClockIn, + data: { + 'shiftId': shiftId, + 'sourceType': 'GEO', + if (notes != null && notes.isNotEmpty) 'notes': notes, + }, + ); + // Re-fetch the attendance status to get the canonical state after clock-in. + return getAttendanceStatus(); } @override Future clockOut({ String? notes, int? breakTimeMinutes, - String? applicationId, + String? shiftId, }) async { - return _service.run(() async { - await _service.getStaffId(); // Validate session + await _apiService.post( + V2ApiEndpoints.staffClockOut, + data: { + if (shiftId != null) 'shiftId': shiftId, + 'sourceType': 'GEO', + if (notes != null && notes.isNotEmpty) 'notes': notes, + if (breakTimeMinutes != null) 'breakMinutes': breakTimeMinutes, + }, + ); + // Re-fetch the attendance status to get the canonical state after clock-out. + return getAttendanceStatus(); + } - final String? targetAppId = applicationId ?? _activeApplicationId; - if (targetAppId == null || targetAppId.isEmpty) { - throw Exception('No active application id for checkout'); - } - final fdc.QueryResult appResult = - await _service.connector - .getApplicationById(id: targetAppId) - .execute(); - final dc.GetApplicationByIdApplication? app = appResult.data.application; - - if (app == null) { - throw Exception('Application not found for checkout'); - } - if (app.checkInTime == null || app.checkOutTime != null) { - throw Exception('No active shift found to clock out'); - } - - await _service.connector - .updateApplicationStatus( - id: targetAppId, - ) - .checkOutTime(_service.toTimestamp(DateTime.now())) - .execute(); - - return getAttendanceStatus(); - }); + /// Maps a V2 `listTodayShifts` JSON item to the domain [Shift] entity. + /// + /// The today-shifts endpoint returns a lightweight shape that lacks some + /// [Shift] fields. Missing fields are defaulted: + /// - `orderId` defaults to empty string + /// - `latitude` / `longitude` default to null (disables geofence) + /// - `requiredWorkers` / `assignedWorkers` default to 0 + // TODO: Ask BE to add latitude/longitude to the listTodayShifts query + // to avoid losing geofence validation. + static Shift _mapTodayShiftJsonToShift(Map json) { + return Shift( + id: json['shiftId'] as String, + orderId: '', + title: json['roleName'] as String? ?? '', + status: ShiftStatus.fromJson(json['attendanceStatus'] as String?), + startsAt: DateTime.parse(json['startTime'] as String), + endsAt: DateTime.parse(json['endTime'] as String), + locationName: json['location'] as String?, + requiredWorkers: 0, + assignedWorkers: 0, + ); } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_out_arguments.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_out_arguments.dart index f077eaf1..58902f0e 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_out_arguments.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_out_arguments.dart @@ -1,23 +1,23 @@ import 'package:krow_core/core.dart'; -/// Represents the arguments required for the [ClockOutUseCase]. +/// Arguments required for the [ClockOutUseCase]. class ClockOutArguments extends UseCaseArgument { - /// Creates a [ClockOutArguments] instance. const ClockOutArguments({ this.notes, this.breakTimeMinutes, - this.applicationId, + this.shiftId, }); + /// Optional notes provided by the user during clock-out. final String? notes; /// Optional break time in minutes. final int? breakTimeMinutes; - /// Optional application id for checkout. - final String? applicationId; + /// The shift id used by the V2 API to resolve the assignment. + final String? shiftId; @override - List get props => [notes, breakTimeMinutes, applicationId]; + List get props => [notes, breakTimeMinutes, shiftId]; } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/repositories/clock_in_repository_interface.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/repositories/clock_in_repository_interface.dart index 3d4795bd..9f93682b 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/repositories/clock_in_repository_interface.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/repositories/clock_in_repository_interface.dart @@ -16,10 +16,12 @@ abstract class ClockInRepositoryInterface { Future clockIn({required String shiftId, String? notes}); /// Checks the user out for the currently active shift. - /// Optionally accepts [breakTimeMinutes] if tracked. + /// + /// The V2 API resolves the assignment from [shiftId]. Optionally accepts + /// [breakTimeMinutes] if tracked. Future clockOut({ String? notes, int? breakTimeMinutes, - String? applicationId, + String? shiftId, }); } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_out_usecase.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_out_usecase.dart index aa8ecdc4..22503897 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_out_usecase.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_out_usecase.dart @@ -14,7 +14,7 @@ class ClockOutUseCase implements UseCase { return _repository.clockOut( notes: arguments.notes, breakTimeMinutes: arguments.breakTimeMinutes, - applicationId: arguments.applicationId, + shiftId: arguments.shiftId, ); } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart index 3a7e0a0e..eee69dcb 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart @@ -177,8 +177,8 @@ class ClockInBloc extends Bloc // Build validation context from combined BLoC states. final ClockInValidationContext validationContext = ClockInValidationContext( isCheckingIn: true, - shiftStartTime: _tryParseDateTime(shift?.startTime), - shiftEndTime: _tryParseDateTime(shift?.endTime), + shiftStartTime: shift?.startsAt, + shiftEndTime: shift?.endsAt, hasCoordinates: hasCoordinates, isLocationVerified: geofenceState.isLocationVerified, isLocationTimedOut: geofenceState.isLocationTimedOut, @@ -237,7 +237,7 @@ class ClockInBloc extends Bloc ClockOutArguments( notes: event.notes, breakTimeMinutes: event.breakTimeMinutes ?? 0, - applicationId: state.attendance.activeApplicationId, + shiftId: state.attendance.activeShiftId, ), ); emit(state.copyWith( @@ -299,12 +299,6 @@ class ClockInBloc extends Bloc return super.close(); } - /// Safely parses a time string into a [DateTime], returning `null` on failure. - static DateTime? _tryParseDateTime(String? value) { - if (value == null || value.isEmpty) return null; - return DateTime.tryParse(value); - } - /// Computes time-window check-in/check-out flags for the given [shift]. /// /// Uses [TimeWindowValidator] so this business logic stays out of widgets. @@ -314,37 +308,33 @@ class ClockInBloc extends Bloc } const TimeWindowValidator validator = TimeWindowValidator(); - final DateTime? shiftStart = _tryParseDateTime(shift.startTime); - final DateTime? shiftEnd = _tryParseDateTime(shift.endTime); + final DateTime shiftStart = shift.startsAt; + final DateTime shiftEnd = shift.endsAt; // Check-in window. bool isCheckInAllowed = true; String? checkInAvailabilityTime; - if (shiftStart != null) { - final ClockInValidationContext checkInCtx = ClockInValidationContext( - isCheckingIn: true, - shiftStartTime: shiftStart, - ); - isCheckInAllowed = validator.validate(checkInCtx).isValid; - if (!isCheckInAllowed) { - checkInAvailabilityTime = - TimeWindowValidator.getAvailabilityTime(shiftStart); - } + final ClockInValidationContext checkInCtx = ClockInValidationContext( + isCheckingIn: true, + shiftStartTime: shiftStart, + ); + isCheckInAllowed = validator.validate(checkInCtx).isValid; + if (!isCheckInAllowed) { + checkInAvailabilityTime = + TimeWindowValidator.getAvailabilityTime(shiftStart); } // Check-out window. bool isCheckOutAllowed = true; String? checkOutAvailabilityTime; - if (shiftEnd != null) { - final ClockInValidationContext checkOutCtx = ClockInValidationContext( - isCheckingIn: false, - shiftEndTime: shiftEnd, - ); - isCheckOutAllowed = validator.validate(checkOutCtx).isValid; - if (!isCheckOutAllowed) { - checkOutAvailabilityTime = - TimeWindowValidator.getAvailabilityTime(shiftEnd); - } + final ClockInValidationContext checkOutCtx = ClockInValidationContext( + isCheckingIn: false, + shiftEndTime: shiftEnd, + ); + isCheckOutAllowed = validator.validate(checkOutCtx).isValid; + if (!isCheckOutAllowed) { + checkOutAvailabilityTime = + TimeWindowValidator.getAvailabilityTime(shiftEnd); } return _TimeWindowFlags( diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_state.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_state.dart index ddda84ed..08eef144 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_state.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_state.dart @@ -14,7 +14,9 @@ class ClockInState extends Equatable { this.status = ClockInStatus.initial, this.todayShifts = const [], this.selectedShift, - this.attendance = const AttendanceStatus(), + this.attendance = const AttendanceStatus( + attendanceStatus: AttendanceStatusType.notClockedIn, + ), required this.selectedDate, this.checkInMode = 'swipe', this.errorMessage, diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart index 5a5ec04d..a075096c 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_action_section.dart @@ -31,7 +31,7 @@ class ClockInActionSection extends StatelessWidget { const ClockInActionSection({ required this.selectedShift, required this.isCheckedIn, - required this.checkOutTime, + required this.hasCompletedShift, required this.checkInMode, required this.isActionInProgress, this.hasClockinError = false, @@ -55,8 +55,8 @@ class ClockInActionSection extends StatelessWidget { /// Whether the user is currently checked in for the active shift. final bool isCheckedIn; - /// The check-out time, or null if the user has not checked out. - final DateTime? checkOutTime; + /// Whether the shift has been completed (clocked out). + final bool hasCompletedShift; /// The current check-in mode (e.g. "swipe" or "nfc"). final String checkInMode; @@ -87,15 +87,15 @@ class ClockInActionSection extends StatelessWidget { @override Widget build(BuildContext context) { - if (selectedShift != null && checkOutTime == null) { - return _buildActiveShiftAction(context); + if (selectedShift == null) { + return const NoShiftsBanner(); } - if (selectedShift != null && checkOutTime != null) { + if (hasCompletedShift) { return const ShiftCompletedBanner(); } - return const NoShiftsBanner(); + return _buildActiveShiftAction(context); } /// Builds the action widget for an active (not completed) shift. diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart index fc67a5b4..d4797be7 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_body.dart @@ -66,12 +66,16 @@ class _ClockInBodyState extends State { final String? activeShiftId = state.attendance.activeShiftId; final bool isActiveSelected = selectedShift != null && selectedShift.id == activeShiftId; - final DateTime? checkInTime = - isActiveSelected ? state.attendance.checkInTime : null; - final DateTime? checkOutTime = - isActiveSelected ? state.attendance.checkOutTime : null; - final bool isCheckedIn = - state.attendance.isCheckedIn && isActiveSelected; + final DateTime? clockInAt = + isActiveSelected ? state.attendance.clockInAt : null; + final bool isClockedIn = + state.attendance.isClockedIn && isActiveSelected; + // The V2 AttendanceStatus no longer carries checkOutTime. + // A closed session means the worker already clocked out for + // this shift, which the UI shows via ShiftCompletedBanner. + final bool hasCompletedShift = isActiveSelected && + state.attendance.attendanceStatus == + AttendanceStatusType.closed; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -106,8 +110,8 @@ class _ClockInBodyState extends State { // action section (check-in/out buttons) ClockInActionSection( selectedShift: selectedShift, - isCheckedIn: isCheckedIn, - checkOutTime: checkOutTime, + isCheckedIn: isClockedIn, + hasCompletedShift: hasCompletedShift, checkInMode: state.checkInMode, isActionInProgress: state.status == ClockInStatus.actionInProgress, @@ -119,9 +123,9 @@ class _ClockInBodyState extends State { ), // checked-in banner (only when checked in to the selected shift) - if (isCheckedIn && checkInTime != null) ...[ + if (isClockedIn && clockInAt != null) ...[ const SizedBox(height: UiConstants.space3), - CheckedInBanner(checkInTime: checkInTime), + CheckedInBanner(checkInTime: clockInAt), ], const SizedBox(height: UiConstants.space4), ], diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart index 1c441d99..211769d1 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart @@ -67,21 +67,7 @@ class _CommuteTrackerState extends State { // For demo purposes, check if we're within 24 hours of shift final DateTime now = DateTime.now(); - DateTime shiftStart; - try { - // Try parsing startTime as full datetime first - shiftStart = DateTime.parse(widget.shift!.startTime); - } catch (_) { - try { - // Try parsing date as full datetime - shiftStart = DateTime.parse(widget.shift!.date); - } catch (_) { - // Fall back to combining date and time - shiftStart = DateTime.parse( - '${widget.shift!.date} ${widget.shift!.startTime}', - ); - } - } + final DateTime shiftStart = widget.shift!.startsAt; final int hoursUntilShift = shiftStart.difference(now).inHours; final bool inCommuteWindow = hoursUntilShift <= 24 && hoursUntilShift >= 0; @@ -112,21 +98,7 @@ class _CommuteTrackerState extends State { int _getMinutesUntilShift() { if (widget.shift == null) return 0; final DateTime now = DateTime.now(); - DateTime shiftStart; - try { - // Try parsing startTime as full datetime first - shiftStart = DateTime.parse(widget.shift!.startTime); - } catch (_) { - try { - // Try parsing date as full datetime - shiftStart = DateTime.parse(widget.shift!.date); - } catch (_) { - // Fall back to combining date and time - shiftStart = DateTime.parse( - '${widget.shift!.date} ${widget.shift!.startTime}', - ); - } - } + final DateTime shiftStart = widget.shift!.startsAt; return shiftStart.difference(now).inMinutes; } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart index 5224e922..f140b243 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/shift_card.dart @@ -1,13 +1,12 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:krow_core/core.dart' show formatTime; - /// A selectable card that displays a single shift's summary information. /// -/// Shows the shift title, client/location, time range, and hourly rate. +/// Shows the shift title, location, and time range. /// Highlights with a primary border when [isSelected] is true. class ShiftCard extends StatelessWidget { /// Creates a shift card for the given [shift]. @@ -50,7 +49,7 @@ class ShiftCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: _ShiftDetails(shift: shift, isSelected: isSelected, i18n: i18n)), - _ShiftTimeAndRate(shift: shift), + _ShiftTimeRange(shift: shift), ], ), ), @@ -58,7 +57,7 @@ class ShiftCard extends StatelessWidget { } } -/// Displays the shift title, client name, and location on the left side. +/// Displays the shift title and location on the left side. class _ShiftDetails extends StatelessWidget { const _ShiftDetails({ required this.shift, @@ -88,8 +87,10 @@ class _ShiftDetails extends StatelessWidget { ), const SizedBox(height: 2), Text(shift.title, style: UiTypography.body2b), + // TODO: Ask BE to add clientName to the listTodayShifts response. + // Currently showing locationName as subtitle fallback. Text( - '${shift.clientName} ${shift.location}', + shift.locationName ?? '', style: UiTypography.body3r.textSecondary, ), ], @@ -97,30 +98,26 @@ class _ShiftDetails extends StatelessWidget { } } -/// Displays the shift time range and hourly rate on the right side. -class _ShiftTimeAndRate extends StatelessWidget { - const _ShiftTimeAndRate({required this.shift}); +/// Displays the shift time range on the right side. +class _ShiftTimeRange extends StatelessWidget { + const _ShiftTimeRange({required this.shift}); - /// The shift whose time and rate to display. + /// The shift whose time to display. final Shift shift; @override Widget build(BuildContext context) { - final TranslationsStaffClockInEn i18n = Translations.of( - context, - ).staff.clock_in; + final String startFormatted = DateFormat('h:mm a').format(shift.startsAt); + final String endFormatted = DateFormat('h:mm a').format(shift.endsAt); return Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - '${formatTime(shift.startTime)} - ${formatTime(shift.endTime)}', + '$startFormatted - $endFormatted', style: UiTypography.body3m.textSecondary, ), - Text( - i18n.per_hr(amount: shift.hourlyRate), - style: UiTypography.body3m.copyWith(color: UiColors.primary), - ), + // TODO: Ask BE to add hourlyRate to the listTodayShifts response. ], ); } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart index 32945ba3..671642ae 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart @@ -1,6 +1,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; import 'data/repositories_impl/clock_in_repository_impl.dart'; import 'data/services/background_geofence_service.dart'; @@ -30,8 +31,10 @@ class StaffClockInModule extends Module { @override void binds(Injector i) { - // Repositories - i.add(ClockInRepositoryImpl.new); + // Repositories (V2 API via BaseApiService from CoreModule) + i.add( + () => ClockInRepositoryImpl(apiService: i.get()), + ); // Geofence Services (resolve core singletons from DI) i.add( diff --git a/apps/mobile/packages/features/staff/clock_in/pubspec.yaml b/apps/mobile/packages/features/staff/clock_in/pubspec.yaml index 9b53e8e6..2ae0e0cb 100644 --- a/apps/mobile/packages/features/staff/clock_in/pubspec.yaml +++ b/apps/mobile/packages/features/staff/clock_in/pubspec.yaml @@ -15,7 +15,7 @@ dependencies: equatable: ^2.0.5 intl: ^0.20.2 flutter_modular: ^6.3.2 - + # Internal packages core_localization: path: ../../../core_localization @@ -23,9 +23,5 @@ dependencies: path: ../../../design_system krow_domain: path: ../../../domain - krow_data_connect: - path: ../../../data_connect krow_core: path: ../../../core - firebase_data_connect: ^0.2.2+2 - firebase_auth: ^6.1.4 diff --git a/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart b/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart index 118c66e5..b24461cf 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart @@ -1,187 +1,33 @@ -import 'package:intl/intl.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:staff_home/src/domain/repositories/home_repository.dart'; -class HomeRepositoryImpl - implements HomeRepository { - HomeRepositoryImpl() : _service = DataConnectService.instance; +/// V2 API implementation of [HomeRepository]. +/// +/// Fetches staff dashboard data from `GET /staff/dashboard` and profile +/// completion from `GET /staff/profile-completion`. +class HomeRepositoryImpl implements HomeRepository { + /// Creates a [HomeRepositoryImpl]. + HomeRepositoryImpl({required BaseApiService apiService}) + : _apiService = apiService; - final DataConnectService _service; + /// The API service used for network requests. + final BaseApiService _apiService; @override - Future> getTodayShifts() async { - return _getShiftsForDate(DateTime.now()); + Future getDashboard() async { + final ApiResponse response = + await _apiService.get(V2ApiEndpoints.staffDashboard); + final Map data = response.data as Map; + return StaffDashboard.fromJson(data); } @override - Future> getTomorrowShifts() async { - return _getShiftsForDate(DateTime.now().add(const Duration(days: 1))); - } - - Future> _getShiftsForDate(DateTime date) async { - return _service.run(() async { - final staffId = await _service.getStaffId(); - - // Create start and end timestamps for the target date - 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 response = await _service.run(() => _service.connector - .getApplicationsByStaffId(staffId: staffId) - .dayStart(_service.toTimestamp(start)) - .dayEnd(_service.toTimestamp(end)) - .execute()); - - // Filter for CONFIRMED applications (same logic as shifts_repository_impl) - final apps = response.data.applications.where((app) => - (app.status is Known && - (app.status as Known).value == ApplicationStatus.CONFIRMED)); - - final List shifts = []; - for (final app in apps) { - shifts.add(_mapApplicationToShift(app)); - } - - return shifts; - }); - } - - @override - Future> getRecommendedShifts() async { - // Logic: List ALL open shifts (simple recommendation engine) - // Limitation: listShifts might return ALL shifts. We should ideally filter by status=PUBLISHED. - return _service.run(() async { - final response = - await _service.run(() => _service.connector.listShifts().execute()); - - return response.data.shifts - .where((s) { - final isOpen = s.status is Known && - (s.status as Known).value == ShiftStatus.OPEN; - if (!isOpen) return false; - - final start = _service.toDateTime(s.startTime); - if (start == null) return false; - - return start.isAfter(DateTime.now()); - }) - .take(10) - .map((s) => _mapConnectorShiftToDomain(s)) - .toList(); - }); - } - - @override - Future getStaffName() async { - final session = StaffSessionStore.instance.session; - - // If session data is available, return staff name immediately - if (session?.staff?.name != null) { - return session!.staff!.name; - } - - // If session is not initialized, attempt to fetch staff data to populate session - return await _service.run(() async { - final staffId = await _service.getStaffId(); - final response = await _service.connector - .getStaffById(id: staffId) - .execute(); - - if (response.data.staff == null) { - throw Exception('Staff data not found for ID: $staffId'); - } - - final staff = response.data.staff!; - final updatedSession = StaffSession( - staff: Staff( - id: staff.id, - authProviderId: staff.userId, - name: staff.fullName, - email: staff.email ?? '', - phone: staff.phone, - status: StaffStatus.completedProfile, - address: staff.addres, - avatar: staff.photoUrl, - ), - ownerId: staff.ownerId, - ); - StaffSessionStore.instance.setSession(updatedSession); - - return staff.fullName; - }); - } - - @override - Future> getBenefits() async { - return _service.run(() async { - final staffId = await _service.getStaffId(); - final response = await _service.connector - .listBenefitsDataByStaffId(staffId: staffId) - .execute(); - - return response.data.benefitsDatas.map((data) { - final plan = data.vendorBenefitPlan; - final total = plan.total?.toDouble() ?? 0.0; - final remaining = data.current.toDouble(); - return Benefit( - title: plan.title, - entitlementHours: total, - usedHours: (total - remaining).clamp(0.0, total), - ); - }).toList(); - }); - } - - // Mappers specific to Home's Domain Entity 'Shift' - // Note: Home's 'Shift' entity might differ slightly from 'StaffPayment' Shift. - - Shift _mapApplicationToShift(GetApplicationsByStaffIdApplications app) { - final s = app.shift; - final r = app.shiftRole; - - return ShiftAdapter.fromApplicationData( - shiftId: s.id, - roleId: r.roleId, - roleName: r.role.name, - businessName: s.order.business.businessName, - companyLogoUrl: s.order.business.companyLogoUrl, - costPerHour: r.role.costPerHour, - shiftLocation: s.location, - teamHubName: s.order.teamHub.hubName, - shiftDate: _service.toDateTime(s.date), - startTime: _service.toDateTime(r.startTime), - endTime: _service.toDateTime(r.endTime), - createdAt: _service.toDateTime(app.createdAt), - status: 'confirmed', - description: s.description, - durationDays: s.durationDays, - count: r.count, - assigned: r.assigned, - eventName: s.order.eventName, - hasApplied: true, - ); - } - - Shift _mapConnectorShiftToDomain(ListShiftsShifts s) { - return Shift( - id: s.id, - title: s.title, - clientName: s.order.business.businessName, - hourlyRate: s.cost ?? 0.0, - location: s.location ?? 'Unknown', - locationAddress: s.locationAddress ?? '', - date: _service.toDateTime(s.date)?.toIso8601String() ?? '', - startTime: DateFormat('HH:mm') - .format(_service.toDateTime(s.startTime) ?? DateTime.now()), - endTime: DateFormat('HH:mm') - .format(_service.toDateTime(s.endTime) ?? DateTime.now()), - createdDate: _service.toDateTime(s.createdAt)?.toIso8601String() ?? '', - tipsAvailable: false, - mealProvided: false, - managers: [], - description: s.description, - ); + Future getProfileCompletion() async { + final ApiResponse response = + await _apiService.get(V2ApiEndpoints.staffProfileCompletion); + final Map data = response.data as Map; + final ProfileCompletion completion = ProfileCompletion.fromJson(data); + return completion.completed; } } diff --git a/apps/mobile/packages/features/staff/home/lib/src/domain/entities/shift.dart b/apps/mobile/packages/features/staff/home/lib/src/domain/entities/shift.dart deleted file mode 100644 index 476281b9..00000000 --- a/apps/mobile/packages/features/staff/home/lib/src/domain/entities/shift.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Entity representing a shift for the staff home screen. -/// -/// This entity aggregates essential shift details needed for display cards. -class Shift extends Equatable { - const Shift({ - required this.id, - required this.title, - required this.clientName, - this.logoUrl, - required this.hourlyRate, - required this.location, - this.locationAddress, - required this.date, - required this.startTime, - required this.endTime, - required this.createdDate, - this.tipsAvailable, - this.travelTime, - this.mealProvided, - this.parkingAvailable, - this.gasCompensation, - this.description, - this.instructions, - this.managers, - this.latitude, - this.longitude, - this.status, - this.durationDays, - }); - - final String id; - final String title; - final String clientName; - final String? logoUrl; - final double hourlyRate; - final String location; - final String? locationAddress; - final String date; - final String startTime; - final String endTime; - final String createdDate; - final bool? tipsAvailable; - final bool? travelTime; - final bool? mealProvided; - final bool? parkingAvailable; - final bool? gasCompensation; - final String? description; - final String? instructions; - final List? managers; - final double? latitude; - final double? longitude; - final String? status; - final int? durationDays; - - @override - List get props => [ - id, - title, - clientName, - logoUrl, - hourlyRate, - location, - locationAddress, - date, - startTime, - endTime, - createdDate, - tipsAvailable, - travelTime, - mealProvided, - parkingAvailable, - gasCompensation, - description, - instructions, - managers, - latitude, - longitude, - status, - durationDays, - ]; -} - -class ShiftManager extends Equatable { - const ShiftManager({required this.name, required this.phone, this.avatar}); - - final String name; - final String phone; - final String? avatar; - - @override - List get props => [name, phone, avatar]; -} diff --git a/apps/mobile/packages/features/staff/home/lib/src/domain/repositories/home_repository.dart b/apps/mobile/packages/features/staff/home/lib/src/domain/repositories/home_repository.dart index 0b2b9f0d..91144b86 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/domain/repositories/home_repository.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/domain/repositories/home_repository.dart @@ -2,22 +2,14 @@ import 'package:krow_domain/krow_domain.dart'; /// Repository interface for home screen data operations. /// -/// This interface defines the contract for fetching shift data -/// displayed on the worker home screen. Implementations should -/// handle data retrieval from appropriate data sources. +/// This interface defines the contract for fetching dashboard data +/// displayed on the worker home screen. The V2 API returns all data +/// in a single `/staff/dashboard` call. abstract class HomeRepository { - /// Retrieves the list of shifts scheduled for today. - Future> getTodayShifts(); + /// Retrieves the staff dashboard containing today's shifts, tomorrow's + /// shifts, recommended shifts, benefits, and the staff member's name. + Future getDashboard(); - /// Retrieves the list of shifts scheduled for tomorrow. - Future> getTomorrowShifts(); - - /// Retrieves shifts recommended for the worker based on their profile. - Future> getRecommendedShifts(); - - /// Retrieves the current staff member's name. - Future getStaffName(); - - /// Retrieves the list of benefits for the staff member. - Future> getBenefits(); + /// Retrieves whether the staff member's profile is complete. + Future getProfileCompletion(); } diff --git a/apps/mobile/packages/features/staff/home/lib/src/domain/usecases/get_home_shifts.dart b/apps/mobile/packages/features/staff/home/lib/src/domain/usecases/get_home_shifts.dart index dd8d7958..93654702 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/domain/usecases/get_home_shifts.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/domain/usecases/get_home_shifts.dart @@ -1,42 +1,31 @@ import 'package:krow_domain/krow_domain.dart'; import 'package:staff_home/src/domain/repositories/home_repository.dart'; -/// Use case for fetching all shifts displayed on the home screen. +/// Use case for fetching the staff dashboard data. /// -/// This use case aggregates shift data from multiple time periods -/// (today, tomorrow, and recommended) into a single response. -class GetHomeShifts { - final HomeRepository repository; +/// Wraps the repository call and returns the full [StaffDashboard] +/// containing shifts, benefits, and the staff member's name. +class GetDashboardUseCase { + /// Creates a [GetDashboardUseCase]. + GetDashboardUseCase(this._repository); - GetHomeShifts(this.repository); + /// The repository used for data access. + final HomeRepository _repository; - /// Executes the use case to fetch all home screen shift data. - /// - /// Returns a [HomeShifts] object containing today's shifts, - /// tomorrow's shifts, and recommended shifts. - Future call() async { - final today = await repository.getTodayShifts(); - final tomorrow = await repository.getTomorrowShifts(); - final recommended = await repository.getRecommendedShifts(); - return HomeShifts( - today: today, - tomorrow: tomorrow, - recommended: recommended, - ); - } + /// Executes the use case to fetch dashboard data. + Future call() => _repository.getDashboard(); } -/// Data transfer object containing all shifts for the home screen. +/// Use case for checking staff profile completion status. /// -/// Groups shifts by time period for easy presentation layer consumption. -class HomeShifts { - final List today; - final List tomorrow; - final List recommended; +/// Returns `true` when all required profile fields are filled. +class GetProfileCompletionUseCase { + /// Creates a [GetProfileCompletionUseCase]. + GetProfileCompletionUseCase(this._repository); - HomeShifts({ - required this.today, - required this.tomorrow, - required this.recommended, - }); + /// The repository used for data access. + final HomeRepository _repository; + + /// Executes the use case to check profile completion. + Future call() => _repository.getProfileCompletion(); } diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart index f6f1bffb..e53c19a1 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart @@ -6,27 +6,32 @@ import 'package:staff_home/src/domain/repositories/home_repository.dart'; part 'benefits_overview_state.dart'; -/// Cubit to manage benefits overview page state. +/// Cubit managing the benefits overview page state. +/// +/// Fetches the dashboard and extracts benefits for the detail page. class BenefitsOverviewCubit extends Cubit with BlocErrorHandler { - final HomeRepository _repository; - + /// Creates a [BenefitsOverviewCubit]. BenefitsOverviewCubit({required HomeRepository repository}) : _repository = repository, super(const BenefitsOverviewState.initial()); + /// The repository used for data access. + final HomeRepository _repository; + + /// Loads benefits from the dashboard endpoint. Future loadBenefits() async { if (isClosed) return; emit(state.copyWith(status: BenefitsOverviewStatus.loading)); await handleError( emit: emit, action: () async { - final benefits = await _repository.getBenefits(); + final StaffDashboard dashboard = await _repository.getDashboard(); if (isClosed) return; emit( state.copyWith( status: BenefitsOverviewStatus.loaded, - benefits: benefits, + benefits: dashboard.benefits, ), ); }, diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home/home_cubit.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home/home_cubit.dart index ac0e2408..d4645ada 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home/home_cubit.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home/home_cubit.dart @@ -1,61 +1,56 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:staff_home/src/domain/repositories/home_repository.dart'; import 'package:staff_home/src/domain/usecases/get_home_shifts.dart'; part 'home_state.dart'; -/// Simple Cubit to manage home page state (shifts + loading/error). +/// Cubit managing the staff home page state. +/// +/// Fetches the dashboard and profile-completion status concurrently +/// using the V2 API via [GetDashboardUseCase] and +/// [GetProfileCompletionUseCase]. class HomeCubit extends Cubit with BlocErrorHandler { - final GetHomeShifts _getHomeShifts; - final HomeRepository _repository; + /// Creates a [HomeCubit]. + HomeCubit({ + required GetDashboardUseCase getDashboard, + required GetProfileCompletionUseCase getProfileCompletion, + }) : _getDashboard = getDashboard, + _getProfileCompletion = getProfileCompletion, + super(const HomeState.initial()); + + /// Use case that fetches the full staff dashboard. + final GetDashboardUseCase _getDashboard; /// Use case that checks whether the staff member's profile is complete. - /// - /// Used to determine whether profile-gated features (such as shift browsing) - /// should be enabled on the home screen. final GetProfileCompletionUseCase _getProfileCompletion; - HomeCubit({ - required HomeRepository repository, - required GetProfileCompletionUseCase getProfileCompletion, - }) : _getHomeShifts = GetHomeShifts(repository), - _repository = repository, - _getProfileCompletion = getProfileCompletion, - super(const HomeState.initial()); - + /// Loads dashboard data and profile completion concurrently. Future loadShifts() async { if (isClosed) return; emit(state.copyWith(status: HomeStatus.loading)); await handleError( emit: emit, action: () async { - // Fetch shifts, name, benefits and profile completion status concurrently - final results = await Future.wait([ - _getHomeShifts.call(), + final List results = await Future.wait(>[ + _getDashboard.call(), _getProfileCompletion.call(), - _repository.getBenefits(), - _repository.getStaffName(), ]); - - final homeResult = results[0] as HomeShifts; - final isProfileComplete = results[1] as bool; - final benefits = results[2] as List; - final name = results[3] as String?; + + final StaffDashboard dashboard = results[0] as StaffDashboard; + final bool isProfileComplete = results[1] as bool; if (isClosed) return; emit( state.copyWith( status: HomeStatus.loaded, - todayShifts: homeResult.today, - tomorrowShifts: homeResult.tomorrow, - recommendedShifts: homeResult.recommended, - staffName: name, + todayShifts: dashboard.todaysShifts, + tomorrowShifts: dashboard.tomorrowsShifts, + recommendedShifts: dashboard.recommendedShifts, + staffName: dashboard.staffName, isProfileComplete: isProfileComplete, - benefits: benefits, + benefits: dashboard.benefits, ), ); }, @@ -66,6 +61,7 @@ class HomeCubit extends Cubit with BlocErrorHandler { ); } + /// Toggles the auto-match preference. void toggleAutoMatch(bool enabled) { emit(state.copyWith(autoMatchEnabled: enabled)); } diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home/home_state.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home/home_state.dart index 48a87e92..18cd788b 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home/home_state.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home/home_state.dart @@ -1,37 +1,62 @@ part of 'home_cubit.dart'; +/// Status of the home page data loading. enum HomeStatus { initial, loading, loaded, error } +/// State for the staff home page. +/// +/// Contains today's shifts, tomorrow's shifts, recommended shifts, benefits, +/// and profile-completion status from the V2 dashboard API. class HomeState extends Equatable { - final HomeStatus status; - final List todayShifts; - final List tomorrowShifts; - final List recommendedShifts; - final bool autoMatchEnabled; - final bool isProfileComplete; - final String? staffName; - final String? errorMessage; - final List benefits; - + /// Creates a [HomeState]. const HomeState({ required this.status, - this.todayShifts = const [], - this.tomorrowShifts = const [], - this.recommendedShifts = const [], + this.todayShifts = const [], + this.tomorrowShifts = const [], + this.recommendedShifts = const [], this.autoMatchEnabled = false, this.isProfileComplete = false, this.staffName, this.errorMessage, - this.benefits = const [], + this.benefits = const [], }); + /// Initial state with no data loaded. const HomeState.initial() : this(status: HomeStatus.initial); + /// Current loading status. + final HomeStatus status; + + /// Shifts assigned for today. + final List todayShifts; + + /// Shifts assigned for tomorrow. + final List tomorrowShifts; + + /// Recommended open shifts. + final List recommendedShifts; + + /// Whether auto-match is enabled. + final bool autoMatchEnabled; + + /// Whether the staff profile is complete. + final bool isProfileComplete; + + /// The staff member's display name. + final String? staffName; + + /// Error message if loading failed. + final String? errorMessage; + + /// Active benefits. + final List benefits; + + /// Creates a copy with the given fields replaced. HomeState copyWith({ HomeStatus? status, - List? todayShifts, - List? tomorrowShifts, - List? recommendedShifts, + List? todayShifts, + List? tomorrowShifts, + List? recommendedShifts, bool? autoMatchEnabled, bool? isProfileComplete, String? staffName, @@ -52,7 +77,7 @@ class HomeState extends Equatable { } @override - List get props => [ + List get props => [ status, todayShifts, tomorrowShifts, @@ -63,4 +88,4 @@ class HomeState extends Equatable { errorMessage, benefits, ]; -} \ No newline at end of file +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card.dart index 1294f979..330bd8ee 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card.dart @@ -1,4 +1,3 @@ -import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -6,20 +5,14 @@ import 'package:staff_home/src/presentation/widgets/benefits_overview/benefit_ca /// Card widget displaying detailed benefit information. class BenefitCard extends StatelessWidget { - /// The benefit to display. - final Benefit benefit; - /// Creates a [BenefitCard]. const BenefitCard({required this.benefit, super.key}); + /// The benefit to display. + final Benefit benefit; + @override Widget build(BuildContext context) { - final bool isSickLeave = benefit.title.toLowerCase().contains('sick'); - final bool isVacation = benefit.title.toLowerCase().contains('vacation'); - final bool isHolidays = benefit.title.toLowerCase().contains('holiday'); - - final i18n = t.staff.home.benefits.overview; - return Container( padding: const EdgeInsets.all(UiConstants.space6), decoration: BoxDecoration( @@ -29,17 +22,8 @@ class BenefitCard extends StatelessWidget { ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ BenefitCardHeader(benefit: benefit), - // const SizedBox(height: UiConstants.space6), - // if (isSickLeave) ...[ - // AccordionHistory(label: i18n.sick_leave_history), - // const SizedBox(height: UiConstants.space6), - // ], - // if (isVacation || isHolidays) ...[ - // ComplianceBanner(text: i18n.compliance_banner), - // const SizedBox(height: UiConstants.space6), - // ], ], ), ); diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card_header.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card_header.dart index 3be875c0..16d7f534 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card_header.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card_header.dart @@ -6,30 +6,33 @@ import 'package:staff_home/src/presentation/widgets/benefits_overview/circular_p import 'package:staff_home/src/presentation/widgets/benefits_overview/stat_chip.dart'; /// Header section of a benefit card showing progress circle, title, and stats. +/// +/// Uses V2 [Benefit] entity fields: [Benefit.targetHours], +/// [Benefit.trackedHours], and [Benefit.remainingHours]. class BenefitCardHeader extends StatelessWidget { - /// The benefit to display. - final Benefit benefit; - /// Creates a [BenefitCardHeader]. const BenefitCardHeader({required this.benefit, super.key}); + /// The benefit to display. + final Benefit benefit; + @override Widget build(BuildContext context) { - final i18n = t.staff.home.benefits.overview; + final dynamic i18n = t.staff.home.benefits.overview; return Row( - children: [ + children: [ _buildProgressCircle(), const SizedBox(width: UiConstants.space4), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ Text( benefit.title, style: UiTypography.body1b.textPrimary, ), - if (_getSubtitle(benefit.title).isNotEmpty) ...[ + if (_getSubtitle(benefit.title).isNotEmpty) ...[ const SizedBox(height: UiConstants.space2), Text( _getSubtitle(benefit.title), @@ -46,8 +49,8 @@ class BenefitCardHeader extends StatelessWidget { } Widget _buildProgressCircle() { - final double progress = benefit.entitlementHours > 0 - ? (benefit.remainingHours / benefit.entitlementHours) + final double progress = benefit.targetHours > 0 + ? (benefit.remainingHours / benefit.targetHours) : 0.0; return SizedBox( @@ -60,14 +63,14 @@ class BenefitCardHeader extends StatelessWidget { child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, - children: [ + children: [ Text( - '${benefit.remainingHours.toInt()}/${benefit.entitlementHours.toInt()}', + '${benefit.remainingHours}/${benefit.targetHours}', style: UiTypography.body2b.textPrimary.copyWith(fontSize: 14), ), Text( t.client_billing.hours_suffix, - style: UiTypography.footnote1r.textSecondary + style: UiTypography.footnote1r.textSecondary, ), ], ), @@ -78,27 +81,27 @@ class BenefitCardHeader extends StatelessWidget { Widget _buildStatsRow(dynamic i18n) { return Row( - children: [ + children: [ StatChip( label: i18n.entitlement, - value: '${benefit.entitlementHours.toInt()}', + value: '${benefit.targetHours}', ), const SizedBox(width: 8), StatChip( label: i18n.used, - value: '${benefit.usedHours.toInt()}', + value: '${benefit.trackedHours}', ), const SizedBox(width: 8), StatChip( label: i18n.remaining, - value: '${benefit.remainingHours.toInt()}', + value: '${benefit.remainingHours}', ), ], ); } String _getSubtitle(String title) { - final i18n = t.staff.home.benefits.overview; + final dynamic i18n = t.staff.home.benefits.overview; if (title.toLowerCase().contains('sick')) { return i18n.sick_leave_subtitle; } else if (title.toLowerCase().contains('vacation')) { diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart index 51f863d3..0f518d9d 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart @@ -2,23 +2,33 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; +/// Card widget for a recommended open shift. +/// +/// Displays the role name, pay rate, time range, and location +/// from an [OpenShift] entity. class RecommendedShiftCard extends StatelessWidget { - final Shift shift; + /// Creates a [RecommendedShiftCard]. + const RecommendedShiftCard({required this.shift, super.key}); - const RecommendedShiftCard({super.key, required this.shift}); + /// The open shift to display. + final OpenShift shift; + + String _formatTime(DateTime time) { + return DateFormat('h:mma').format(time).toLowerCase(); + } @override Widget build(BuildContext context) { - final recI18n = t.staff.home.recommended_card; - final size = MediaQuery.sizeOf(context); + final dynamic recI18n = t.staff.home.recommended_card; + final Size size = MediaQuery.sizeOf(context); + final double hourlyRate = shift.hourlyRateCents / 100; return GestureDetector( - onTap: () { - Modular.to.toShiftDetails(shift); - }, + onTap: () => Modular.to.toShiftDetailsById(shift.shiftId), child: Container( width: size.width * 0.8, padding: const EdgeInsets.all(UiConstants.space4), @@ -31,10 +41,10 @@ class RecommendedShiftCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, - children: [ + children: [ Row( crossAxisAlignment: CrossAxisAlignment.center, - children: [ + children: [ Container( width: UiConstants.space10, height: UiConstants.space10, @@ -52,20 +62,20 @@ class RecommendedShiftCard extends StatelessWidget { Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.center, - children: [ + children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, spacing: UiConstants.space1, - children: [ + children: [ Flexible( child: Text( - shift.title, + shift.roleName, style: UiTypography.body1m.textPrimary, overflow: TextOverflow.ellipsis, ), ), Text( - '\$${shift.hourlyRate}/h', + '\$${hourlyRate.toStringAsFixed(0)}/h', style: UiTypography.headline4b, ), ], @@ -73,13 +83,13 @@ class RecommendedShiftCard extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, spacing: UiConstants.space1, - children: [ + children: [ Text( - shift.clientName, + shift.orderType.toJson(), style: UiTypography.body3r.textSecondary, ), Text( - '\$${shift.hourlyRate.toStringAsFixed(0)}/hr', + '\$${hourlyRate.toStringAsFixed(0)}/hr', style: UiTypography.body3r.textSecondary, ), ], @@ -91,14 +101,17 @@ class RecommendedShiftCard extends StatelessWidget { ), const SizedBox(height: UiConstants.space3), Row( - children: [ + children: [ const Icon( UiIcons.calendar, size: UiConstants.space3, color: UiColors.mutedForeground, ), const SizedBox(width: UiConstants.space1), - Text(recI18n.today, style: UiTypography.body3r.textSecondary), + Text( + recI18n.today, + style: UiTypography.body3r.textSecondary, + ), const SizedBox(width: UiConstants.space3), const Icon( UiIcons.clock, @@ -108,8 +121,8 @@ class RecommendedShiftCard extends StatelessWidget { const SizedBox(width: UiConstants.space1), Text( recI18n.time_range( - start: shift.startTime, - end: shift.endTime, + start: _formatTime(shift.startTime), + end: _formatTime(shift.endTime), ), style: UiTypography.body3r.textSecondary, ), @@ -117,7 +130,7 @@ class RecommendedShiftCard extends StatelessWidget { ), const SizedBox(height: UiConstants.space1), Row( - children: [ + children: [ const Icon( UiIcons.mapPin, size: UiConstants.space3, @@ -126,7 +139,7 @@ class RecommendedShiftCard extends StatelessWidget { const SizedBox(width: UiConstants.space1), Expanded( child: Text( - shift.locationAddress, + shift.location, style: UiTypography.body3r.textSecondary, maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shifts_section.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shifts_section.dart index 0410bc1f..48a3bde1 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shifts_section.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shifts_section.dart @@ -2,6 +2,7 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_domain/krow_domain.dart'; import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart'; import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.dart'; @@ -10,23 +11,23 @@ import 'package:staff_home/src/presentation/widgets/home_page/section_layout.dar /// A widget that displays recommended shifts section. /// -/// Shows a horizontal scrolling list of shifts recommended for the worker -/// based on their profile and preferences. +/// Shows a horizontal scrolling list of [OpenShift] entities recommended +/// for the worker based on their profile and preferences. class RecommendedShiftsSection extends StatelessWidget { /// Creates a [RecommendedShiftsSection]. const RecommendedShiftsSection({super.key}); @override Widget build(BuildContext context) { - final t = Translations.of(context); - final sectionsI18n = t.staff.home.sections; - final emptyI18n = t.staff.home.empty_states; - final size = MediaQuery.sizeOf(context); + final Translations i18nRoot = Translations.of(context); + final dynamic sectionsI18n = i18nRoot.staff.home.sections; + final dynamic emptyI18n = i18nRoot.staff.home.empty_states; + final Size size = MediaQuery.sizeOf(context); return SectionLayout( title: sectionsI18n.recommended_for_you, child: BlocBuilder( - builder: (context, state) { + builder: (BuildContext context, HomeState state) { if (state.recommendedShifts.isEmpty) { return EmptyStateWidget(message: emptyI18n.no_recommended_shifts); } @@ -36,7 +37,7 @@ class RecommendedShiftsSection extends StatelessWidget { scrollDirection: Axis.horizontal, itemCount: state.recommendedShifts.length, clipBehavior: Clip.none, - itemBuilder: (context, index) => Padding( + itemBuilder: (BuildContext context, int index) => Padding( padding: const EdgeInsets.only(right: UiConstants.space3), child: RecommendedShiftCard( shift: state.recommendedShifts[index], diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/todays_shifts_section.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/todays_shifts_section.dart index adad147a..ea0e376c 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/todays_shifts_section.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/todays_shifts_section.dart @@ -3,36 +3,35 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart'; import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.dart'; import 'package:staff_home/src/presentation/widgets/home_page/section_layout.dart'; -import 'package:staff_home/src/presentation/widgets/shift_card.dart'; /// A widget that displays today's shifts section. /// -/// Shows a list of shifts scheduled for today, with loading state -/// and empty state handling. +/// Shows a list of shifts scheduled for today using [TodayShift] entities +/// from the V2 dashboard API. class TodaysShiftsSection extends StatelessWidget { /// Creates a [TodaysShiftsSection]. const TodaysShiftsSection({super.key}); @override Widget build(BuildContext context) { - final t = Translations.of(context); - final sectionsI18n = t.staff.home.sections; - final emptyI18n = t.staff.home.empty_states; + final Translations i18nRoot = Translations.of(context); + final dynamic sectionsI18n = i18nRoot.staff.home.sections; + final dynamic emptyI18n = i18nRoot.staff.home.empty_states; return BlocBuilder( - builder: (context, state) { - final shifts = state.todayShifts; + builder: (BuildContext context, HomeState state) { + final List shifts = state.todayShifts; return SectionLayout( title: sectionsI18n.todays_shift, action: shifts.isNotEmpty - ? sectionsI18n.scheduled_count( - count: shifts.length, - ) + ? sectionsI18n.scheduled_count(count: shifts.length) : null, child: state.status == HomeStatus.loading ? const _ShiftsSectionSkeleton() @@ -46,10 +45,7 @@ class TodaysShiftsSection extends StatelessWidget { : Column( children: shifts .map( - (shift) => ShiftCard( - shift: shift, - compact: true, - ), + (TodayShift shift) => _TodayShiftCard(shift: shift), ) .toList(), ), @@ -59,6 +55,70 @@ class TodaysShiftsSection extends StatelessWidget { } } +/// Compact card for a today's shift. +class _TodayShiftCard extends StatelessWidget { + const _TodayShiftCard({required this.shift}); + + /// The today-shift to display. + final TodayShift shift; + + String _formatTime(DateTime time) { + return DateFormat('h:mma').format(time).toLowerCase(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => Modular.to.toShiftDetailsById(shift.shiftId), + child: Container( + margin: const EdgeInsets.only(bottom: UiConstants.space3), + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all(color: UiColors.border), + ), + child: Row( + children: [ + Container( + width: UiConstants.space12, + height: UiConstants.space12, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: + BorderRadius.circular(UiConstants.radiusBase), + border: Border.all(color: UiColors.border), + ), + child: Icon( + UiIcons.building, + color: UiColors.mutedForeground, + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + shift.roleName, + style: UiTypography.body1m.textPrimary, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: UiConstants.space1), + Text( + '${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)} \u2022 ${shift.location}', + style: UiTypography.body3r.textSecondary, + ), + ], + ), + ), + ], + ), + ), + ); + } +} + /// Inline shimmer skeleton for the shifts section loading state. class _ShiftsSectionSkeleton extends StatelessWidget { const _ShiftsSectionSkeleton(); @@ -68,20 +128,20 @@ class _ShiftsSectionSkeleton extends StatelessWidget { return UiShimmer( child: UiShimmerList( itemCount: 2, - itemBuilder: (index) => Container( + itemBuilder: (int index) => Container( padding: const EdgeInsets.all(UiConstants.space3), decoration: BoxDecoration( border: Border.all(color: UiColors.border), borderRadius: UiConstants.radiusLg, ), child: const Row( - children: [ + children: [ UiShimmerBox(width: 48, height: 48), SizedBox(width: UiConstants.space3), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ UiShimmerLine(width: 160, height: 14), SizedBox(height: UiConstants.space2), UiShimmerLine(width: 120, height: 12), diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/tomorrows_shifts_section.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/tomorrows_shifts_section.dart index 66cf393f..da46d3cf 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/tomorrows_shifts_section.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/tomorrows_shifts_section.dart @@ -1,42 +1,42 @@ import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:intl/intl.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart'; import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.dart'; import 'package:staff_home/src/presentation/widgets/home_page/section_layout.dart'; -import 'package:staff_home/src/presentation/widgets/shift_card.dart'; /// A widget that displays tomorrow's shifts section. /// -/// Shows a list of shifts scheduled for tomorrow with empty state handling. +/// Shows a list of [AssignedShift] entities scheduled for tomorrow. class TomorrowsShiftsSection extends StatelessWidget { /// Creates a [TomorrowsShiftsSection]. const TomorrowsShiftsSection({super.key}); @override Widget build(BuildContext context) { - final t = Translations.of(context); - final sectionsI18n = t.staff.home.sections; - final emptyI18n = t.staff.home.empty_states; + final Translations i18nRoot = Translations.of(context); + final dynamic sectionsI18n = i18nRoot.staff.home.sections; + final dynamic emptyI18n = i18nRoot.staff.home.empty_states; return BlocBuilder( - builder: (context, state) { - final shifts = state.tomorrowShifts; - + builder: (BuildContext context, HomeState state) { + final List shifts = state.tomorrowShifts; + return SectionLayout( title: sectionsI18n.tomorrow, child: shifts.isEmpty - ? EmptyStateWidget( - message: emptyI18n.no_shifts_tomorrow, - ) + ? EmptyStateWidget(message: emptyI18n.no_shifts_tomorrow) : Column( children: shifts .map( - (shift) => ShiftCard( - shift: shift, - compact: true, - ), + (AssignedShift shift) => + _TomorrowShiftCard(shift: shift), ) .toList(), ), @@ -45,3 +45,89 @@ class TomorrowsShiftsSection extends StatelessWidget { ); } } + +/// Compact card for a tomorrow's shift. +class _TomorrowShiftCard extends StatelessWidget { + const _TomorrowShiftCard({required this.shift}); + + /// The assigned shift to display. + final AssignedShift shift; + + String _formatTime(DateTime time) { + return DateFormat('h:mma').format(time).toLowerCase(); + } + + @override + Widget build(BuildContext context) { + final double hourlyRate = shift.hourlyRateCents / 100; + + return GestureDetector( + onTap: () => Modular.to.toShiftDetailsById(shift.shiftId), + child: Container( + margin: const EdgeInsets.only(bottom: UiConstants.space3), + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all(color: UiColors.border), + ), + child: Row( + children: [ + Container( + width: UiConstants.space12, + height: UiConstants.space12, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: + BorderRadius.circular(UiConstants.radiusBase), + border: Border.all(color: UiColors.border), + ), + child: Icon( + UiIcons.building, + color: UiColors.mutedForeground, + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + shift.roleName, + style: UiTypography.body1m.textPrimary, + overflow: TextOverflow.ellipsis, + ), + ), + Text.rich( + TextSpan( + text: + '\$${hourlyRate % 1 == 0 ? hourlyRate.toInt() : hourlyRate.toStringAsFixed(2)}', + style: UiTypography.body1b.textPrimary, + children: [ + TextSpan( + text: '/h', + style: UiTypography.body3r, + ), + ], + ), + ), + ], + ), + const SizedBox(height: UiConstants.space1), + Text( + '${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)} \u2022 ${shift.location}', + style: UiTypography.body3r.textSecondary, + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart deleted file mode 100644 index fd484758..00000000 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart +++ /dev/null @@ -1,395 +0,0 @@ -import 'package:core_localization/core_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_modular/flutter_modular.dart'; - -import 'package:intl/intl.dart'; - -import 'package:design_system/design_system.dart'; -import 'package:krow_domain/krow_domain.dart'; -import 'package:krow_core/core.dart'; - -class ShiftCard extends StatefulWidget { - final Shift shift; - final VoidCallback? onApply; - final VoidCallback? onDecline; - final bool compact; - final bool disableTapNavigation; // Added property - - const ShiftCard({ - super.key, - required this.shift, - this.onApply, - this.onDecline, - this.compact = false, - this.disableTapNavigation = false, - }); - - @override - State createState() => _ShiftCardState(); -} - -class _ShiftCardState extends State { - bool isExpanded = false; - - String _formatTime(String time) { - if (time.isEmpty) return ''; - try { - final parts = time.split(':'); - final hour = int.parse(parts[0]); - final minute = int.parse(parts[1]); - final dt = DateTime(2022, 1, 1, hour, minute); - return DateFormat('h:mma').format(dt).toLowerCase(); - } catch (e) { - return time; - } - } - - String _formatDate(String dateStr) { - if (dateStr.isEmpty) return ''; - try { - final date = DateTime.parse(dateStr); - return DateFormat('MMMM d').format(date); - } catch (e) { - return dateStr; - } - } - - String _getTimeAgo(String dateStr) { - if (dateStr.isEmpty) return ''; - try { - final date = DateTime.parse(dateStr); - final diff = DateTime.now().difference(date); - if (diff.inHours < 1) return t.staff_shifts.card.just_now; - if (diff.inHours < 24) - return t.staff_shifts.details.pending_time(time: '${diff.inHours}h'); - return t.staff_shifts.details.pending_time(time: '${diff.inDays}d'); - } catch (e) { - return ''; - } - } - - @override - Widget build(BuildContext context) { - if (widget.compact) { - return GestureDetector( - onTap: widget.disableTapNavigation - ? null - : () { - setState(() => isExpanded = !isExpanded); - Modular.to.toShiftDetails(widget.shift); - }, - child: Container( - margin: const EdgeInsets.only(bottom: UiConstants.space3), - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all(color: UiColors.border), - ), - child: Row( - children: [ - Container( - width: UiConstants.space12, - height: UiConstants.space12, - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all(color: UiColors.border), - ), - child: widget.shift.logoUrl != null - ? ClipRRect( - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, - ), - child: Image.network( - widget.shift.logoUrl!, - fit: BoxFit.contain, - ), - ) - : Icon(UiIcons.building, color: UiColors.mutedForeground), - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: Text( - widget.shift.title, - style: UiTypography.body1m.textPrimary, - overflow: TextOverflow.ellipsis, - ), - ), - Text.rich( - TextSpan( - text: '\$${widget.shift.hourlyRate % 1 == 0 ? widget.shift.hourlyRate.toInt() : widget.shift.hourlyRate.toStringAsFixed(2)}', - style: UiTypography.body1b.textPrimary, - children: [ - TextSpan(text: '/h', style: UiTypography.body3r), - ], - ), - ), - ], - ), - Text( - widget.shift.clientName, - style: UiTypography.body2r.textSecondary, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: UiConstants.space1), - Text( - '${_formatTime(widget.shift.startTime)} • ${widget.shift.location}', - style: UiTypography.body3r.textSecondary, - ), - ], - ), - ), - ], - ), - ), - ); - } - - return Container( - margin: const EdgeInsets.only(bottom: UiConstants.space4), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all(color: UiColors.border), - boxShadow: [ - BoxShadow( - color: UiColors.black.withValues(alpha: 0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: Column( - children: [ - // Header - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - width: UiConstants.space14, - height: UiConstants.space14, - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, - ), - border: Border.all(color: UiColors.border), - ), - child: widget.shift.logoUrl != null - ? ClipRRect( - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, - ), - child: Image.network( - widget.shift.logoUrl!, - fit: BoxFit.contain, - ), - ) - : Icon( - UiIcons.building, - size: UiConstants.iconXl - 4, // 28px - color: UiColors.primary, - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space4, - vertical: 6, - ), - decoration: BoxDecoration( - color: UiColors.primary, - borderRadius: UiConstants.radiusFull, - ), - child: Text( - t.staff_shifts.card.assigned( - time: _getTimeAgo(widget.shift.createdDate) - .replaceAll('Pending ', '') - .replaceAll('Just now', 'just now'), - ), - style: UiTypography.body3m.white, - ), - ), - ], - ), - const SizedBox(height: UiConstants.space4), - - // Title and Rate - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.shift.title, - style: UiTypography.headline3m.textPrimary, - ), - Text( - widget.shift.clientName, - style: UiTypography.body2r.textSecondary, - ), - ], - ), - ), - Text.rich( - TextSpan( - text: '\$${widget.shift.hourlyRate % 1 == 0 ? widget.shift.hourlyRate.toInt() : widget.shift.hourlyRate.toStringAsFixed(2)}', - style: UiTypography.headline3m.textPrimary, - children: [ - TextSpan(text: '/h', style: UiTypography.body1r), - ], - ), - ), - ], - ), - const SizedBox(height: UiConstants.space4), - - // Location and Date - Row( - children: [ - Icon( - UiIcons.mapPin, - size: UiConstants.iconSm, - color: UiColors.mutedForeground, - ), - const SizedBox(width: 6), - Expanded( - child: Text( - widget.shift.location, - style: UiTypography.body2r.textSecondary, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: UiConstants.space4), - Icon( - UiIcons.calendar, - size: UiConstants.iconSm, - color: UiColors.mutedForeground, - ), - const SizedBox(width: 6), - Text( - '${_formatDate(widget.shift.date)}, ${_formatTime(widget.shift.startTime)}', - style: UiTypography.body2r.textSecondary, - ), - ], - ), - const SizedBox(height: UiConstants.space4), - - // Tags - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _buildTag( - UiIcons.zap, - t.staff_shifts.tags.immediate_start, - UiColors.accent.withValues(alpha: 0.3), - UiColors.foreground, - ), - _buildTag( - UiIcons.timer, - t.staff_shifts.tags.no_experience, - UiColors.tagError, - UiColors.textError, - ), - ], - ), - - const SizedBox(height: UiConstants.space4), - ], - ), - ), - - // Actions - if (!widget.compact) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space5, - ), - child: Column( - children: [ - SizedBox( - width: double.infinity, - height: UiConstants.space12, - child: ElevatedButton( - onPressed: widget.onApply, - style: ElevatedButton.styleFrom( - backgroundColor: UiColors.primary, - foregroundColor: UiColors.white, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, - ), - ), - ), - child: Text(t.staff_shifts.card.accept_shift), - ), - ), - const SizedBox(height: UiConstants.space2), - SizedBox( - width: double.infinity, - height: UiConstants.space12, - child: OutlinedButton( - onPressed: widget.onDecline, - style: OutlinedButton.styleFrom( - foregroundColor: UiColors.destructive, - side: BorderSide( - color: UiColors.destructive.withValues(alpha: 0.3), - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, - ), - ), - ), - child: Text(t.staff_shifts.card.decline_shift), - ), - ), - const SizedBox(height: UiConstants.space5), - ], - ), - ), - ], - ), - ); - } - - Widget _buildTag(IconData icon, String label, Color bg, Color text) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: bg, - borderRadius: UiConstants.radiusFull, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: UiConstants.iconSm - 2, color: text), - const SizedBox(width: UiConstants.space1), - Flexible( - child: Text( - label, - style: UiTypography.body3m.copyWith(color: text), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - } -} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/worker_benefits/benefits_widget.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/worker_benefits/benefits_widget.dart index a66bd82b..78eac92c 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/worker_benefits/benefits_widget.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/worker_benefits/benefits_widget.dart @@ -2,16 +2,17 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:staff_home/src/presentation/widgets/worker/worker_benefits/benefit_item.dart'; + /// Widget for displaying staff benefits, using design system tokens. /// -/// Shows a list of benefits with circular progress indicators. +/// Shows a list of V2 [Benefit] entities with circular progress indicators. class BenefitsWidget extends StatelessWidget { - /// The list of benefits to display. - final List benefits; - /// Creates a [BenefitsWidget]. const BenefitsWidget({required this.benefits, super.key}); + /// The list of benefits to display. + final List benefits; + @override Widget build(BuildContext context) { if (benefits.isEmpty) { @@ -26,9 +27,9 @@ class BenefitsWidget extends StatelessWidget { return Expanded( child: BenefitItem( label: benefit.title, - remaining: benefit.remainingHours, - total: benefit.entitlementHours, - used: benefit.usedHours, + remaining: benefit.remainingHours.toDouble(), + total: benefit.targetHours.toDouble(), + used: benefit.trackedHours.toDouble(), ), ); }).toList(), diff --git a/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart b/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart index 921a304a..bcb4af20 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_domain/krow_domain.dart'; import 'package:staff_home/src/data/repositories/home_repository_impl.dart'; import 'package:staff_home/src/domain/repositories/home_repository.dart'; +import 'package:staff_home/src/domain/usecases/get_home_shifts.dart'; import 'package:staff_home/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart'; import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart'; import 'package:staff_home/src/presentation/pages/benefits_overview_page.dart'; @@ -12,36 +13,37 @@ import 'package:staff_home/src/presentation/pages/worker_home_page.dart'; /// The module for the staff home feature. /// /// This module provides dependency injection bindings for the home feature -/// following Clean Architecture principles. It injects the repository -/// implementation and state management components. +/// following Clean Architecture principles. It uses the V2 REST API via +/// [BaseApiService] for all backend access. class StaffHomeModule extends Module { @override - void binds(Injector i) { - // Repository - provides home data (shifts, staff name) - i.addLazySingleton(() => HomeRepositoryImpl()); + List get imports => [CoreModule()]; - // StaffConnectorRepository for profile completion queries - i.addLazySingleton( - () => StaffConnectorRepositoryImpl(), + @override + void binds(Injector i) { + // Repository - uses V2 API for dashboard data + i.addLazySingleton( + () => HomeRepositoryImpl(apiService: i.get()), ); - // Use case for checking profile completion + // Use cases + i.addLazySingleton( + () => GetDashboardUseCase(i.get()), + ); i.addLazySingleton( - () => GetProfileCompletionUseCase( - repository: i.get(), - ), + () => GetProfileCompletionUseCase(i.get()), ); // Presentation layer - Cubits - i.addLazySingleton( + i.addLazySingleton( () => HomeCubit( - repository: i.get(), + getDashboard: i.get(), getProfileCompletion: i.get(), ), ); // Cubit for benefits overview page - i.addLazySingleton( + i.addLazySingleton( () => BenefitsOverviewCubit(repository: i.get()), ); } diff --git a/apps/mobile/packages/features/staff/home/pubspec.yaml b/apps/mobile/packages/features/staff/home/pubspec.yaml index e4e1225d..cd39dd2b 100644 --- a/apps/mobile/packages/features/staff/home/pubspec.yaml +++ b/apps/mobile/packages/features/staff/home/pubspec.yaml @@ -18,11 +18,7 @@ dependencies: path: ../../../core krow_domain: path: ../../../domain - staff_shifts: - path: ../shifts - krow_data_connect: - path: ../../../data_connect - + flutter: sdk: flutter flutter_bloc: ^8.1.0 @@ -30,8 +26,6 @@ dependencies: flutter_modular: ^6.3.0 equatable: ^2.0.5 intl: ^0.20.0 - google_fonts: ^7.0.0 - firebase_data_connect: dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart b/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart index 3c701b36..3530ef62 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart @@ -1,98 +1,76 @@ -import 'package:firebase_data_connect/src/core/ref.dart'; -import 'package:krow_data_connect/krow_data_connect.dart' as dc; -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/repositories/payments_repository.dart'; +import 'package:staff_payments/src/domain/repositories/payments_repository.dart'; -class PaymentsRepositoryImpl - implements PaymentsRepository { +/// V2 REST API implementation of [PaymentsRepository]. +/// +/// Calls the staff payments endpoints via [BaseApiService]. +class PaymentsRepositoryImpl implements PaymentsRepository { + /// Creates a [PaymentsRepositoryImpl] with the given [apiService]. + PaymentsRepositoryImpl({required BaseApiService apiService}) + : _apiService = apiService; - PaymentsRepositoryImpl() : _service = DataConnectService.instance; - final DataConnectService _service; + /// The API service used for HTTP requests. + final BaseApiService _apiService; @override - Future getPaymentSummary() async { - return _service.run(() async { - final String currentStaffId = await _service.getStaffId(); - - // Fetch recent payments with a limit - final QueryResult response = await _service.connector.listRecentPaymentsByStaffId( - staffId: currentStaffId, - ).limit(100).execute(); - - final List payments = response.data.recentPayments; - - double weekly = 0; - double monthly = 0; - double pending = 0; - double total = 0; - - final DateTime now = DateTime.now(); - final DateTime startOfWeek = now.subtract(const Duration(days: 7)); - final DateTime startOfMonth = DateTime(now.year, now.month, 1); - - for (final dc.ListRecentPaymentsByStaffIdRecentPayments p in payments) { - final DateTime? date = _service.toDateTime(p.invoice.issueDate) ?? _service.toDateTime(p.createdAt); - final double amount = p.invoice.amount; - final String? status = p.status?.stringValue; - - if (status == 'PENDING') { - pending += amount; - } else if (status == 'PAID') { - total += amount; - if (date != null) { - if (date.isAfter(startOfWeek)) weekly += amount; - if (date.isAfter(startOfMonth)) monthly += amount; - } - } - } - - return PaymentSummary( - weeklyEarnings: weekly, - monthlyEarnings: monthly, - pendingEarnings: pending, - totalEarnings: total, - ); - }); + Future getPaymentSummary({ + String? startDate, + String? endDate, + }) async { + final Map params = { + if (startDate != null) 'startDate': startDate, + if (endDate != null) 'endDate': endDate, + }; + final ApiResponse response = await _apiService.get( + V2ApiEndpoints.staffPaymentsSummary, + params: params.isEmpty ? null : params, + ); + return PaymentSummary.fromJson(response.data as Map); } @override - Future> getPaymentHistory(String period) async { - return _service.run(() async { - final String currentStaffId = await _service.getStaffId(); - - final QueryResult response = await _service.connector - .listRecentPaymentsByStaffId(staffId: currentStaffId) - .execute(); + Future> getPaymentHistory({ + String? startDate, + String? endDate, + }) async { + final Map params = { + if (startDate != null) 'startDate': startDate, + if (endDate != null) 'endDate': endDate, + }; + final ApiResponse response = await _apiService.get( + V2ApiEndpoints.staffPaymentsHistory, + params: params.isEmpty ? null : params, + ); + final Map body = response.data as Map; + final List items = body['items'] as List; + return items + .map((dynamic json) => + PaymentRecord.fromJson(json as Map)) + .toList(); + } - return response.data.recentPayments.map((dc.ListRecentPaymentsByStaffIdRecentPayments payment) { - // Extract shift details from nested application structure - final String? shiftTitle = payment.application.shiftRole.shift.title; - final String? locationAddress = payment.application.shiftRole.shift.locationAddress; - final double? hoursWorked = payment.application.shiftRole.hours; - final double? hourlyRate = payment.application.shiftRole.role.costPerHour; - // Extract hub details from order - final String? locationHub = payment.invoice.order.teamHub.hubName; - final String? hubAddress = payment.invoice.order.teamHub.address; - final String? shiftLocation = locationAddress ?? hubAddress; - - return StaffPayment( - id: payment.id, - staffId: payment.staffId, - assignmentId: payment.applicationId, - amount: payment.invoice.amount, - status: PaymentAdapter.toPaymentStatus(payment.status?.stringValue ?? 'UNKNOWN'), - paidAt: _service.toDateTime(payment.invoice.issueDate), - shiftTitle: shiftTitle, - shiftLocation: locationHub, - locationAddress: shiftLocation, - hoursWorked: hoursWorked, - hourlyRate: hourlyRate, - workedTime: payment.workedTime, - ); - }).toList(); - }); + @override + Future> getPaymentChart({ + String? startDate, + String? endDate, + String bucket = 'day', + }) async { + final Map params = { + 'bucket': bucket, + if (startDate != null) 'startDate': startDate, + if (endDate != null) 'endDate': endDate, + }; + final ApiResponse response = await _apiService.get( + V2ApiEndpoints.staffPaymentsChart, + params: params, + ); + final Map body = response.data as Map; + final List items = body['items'] as List; + return items + .map((dynamic json) => + PaymentChartPoint.fromJson(json as Map)) + .toList(); } } - diff --git a/apps/mobile/packages/features/staff/payments/lib/src/domain/arguments/get_payment_chart_arguments.dart b/apps/mobile/packages/features/staff/payments/lib/src/domain/arguments/get_payment_chart_arguments.dart new file mode 100644 index 00000000..1c915ff5 --- /dev/null +++ b/apps/mobile/packages/features/staff/payments/lib/src/domain/arguments/get_payment_chart_arguments.dart @@ -0,0 +1,23 @@ +import 'package:krow_core/core.dart'; + +/// Arguments for retrieving payment chart data. +class GetPaymentChartArguments extends UseCaseArgument { + /// Creates [GetPaymentChartArguments] with the [bucket] granularity. + const GetPaymentChartArguments({ + this.bucket = 'day', + this.startDate, + this.endDate, + }); + + /// Time bucket granularity: `day`, `week`, or `month`. + final String bucket; + + /// ISO-8601 start date for the range filter. + final String? startDate; + + /// ISO-8601 end date for the range filter. + final String? endDate; + + @override + List get props => [bucket, startDate, endDate]; +} diff --git a/apps/mobile/packages/features/staff/payments/lib/src/domain/arguments/get_payment_history_arguments.dart b/apps/mobile/packages/features/staff/payments/lib/src/domain/arguments/get_payment_history_arguments.dart index e1f4d357..f7a54922 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/domain/arguments/get_payment_history_arguments.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/domain/arguments/get_payment_history_arguments.dart @@ -1,12 +1,19 @@ import 'package:krow_core/core.dart'; -/// Arguments for getting payment history. +/// Arguments for retrieving payment history. class GetPaymentHistoryArguments extends UseCaseArgument { + /// Creates [GetPaymentHistoryArguments] with optional date range. + const GetPaymentHistoryArguments({ + this.startDate, + this.endDate, + }); - const GetPaymentHistoryArguments(this.period); - /// The period to filter by (e.g., "monthly", "weekly"). - final String period; + /// ISO-8601 start date for the range filter. + final String? startDate; + + /// ISO-8601 end date for the range filter. + final String? endDate; @override - List get props => [period]; + List get props => [startDate, endDate]; } diff --git a/apps/mobile/packages/features/staff/payments/lib/src/domain/repositories/payments_repository.dart b/apps/mobile/packages/features/staff/payments/lib/src/domain/repositories/payments_repository.dart index 227c783e..21c51dbe 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/domain/repositories/payments_repository.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/domain/repositories/payments_repository.dart @@ -1,13 +1,25 @@ import 'package:krow_domain/krow_domain.dart'; -/// Repository interface for Payments feature. +/// Repository interface for the staff payments feature. /// -/// Defines the contract for data access related to staff payments. -/// Implementations of this interface should reside in the data layer. +/// Implementations live in the data layer and call the V2 REST API. abstract class PaymentsRepository { - /// Fetches the payment summary (earnings). - Future getPaymentSummary(); + /// Fetches the aggregated payment summary for the given date range. + Future getPaymentSummary({ + String? startDate, + String? endDate, + }); - /// Fetches the payment history for a specific period. - Future> getPaymentHistory(String period); + /// Fetches payment history records for the given date range. + Future> getPaymentHistory({ + String? startDate, + String? endDate, + }); + + /// Fetches aggregated chart data points for the given date range and bucket. + Future> getPaymentChart({ + String? startDate, + String? endDate, + String bucket, + }); } diff --git a/apps/mobile/packages/features/staff/payments/lib/src/domain/usecases/get_payment_chart_usecase.dart b/apps/mobile/packages/features/staff/payments/lib/src/domain/usecases/get_payment_chart_usecase.dart new file mode 100644 index 00000000..dd1b7f0d --- /dev/null +++ b/apps/mobile/packages/features/staff/payments/lib/src/domain/usecases/get_payment_chart_usecase.dart @@ -0,0 +1,26 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_payments/src/domain/arguments/get_payment_chart_arguments.dart'; +import 'package:staff_payments/src/domain/repositories/payments_repository.dart'; + +/// Retrieves aggregated chart data for the current staff member's payments. +class GetPaymentChartUseCase + extends UseCase> { + /// Creates a [GetPaymentChartUseCase]. + GetPaymentChartUseCase(this._repository); + + /// The payments repository. + final PaymentsRepository _repository; + + @override + Future> call( + GetPaymentChartArguments arguments, + ) async { + return _repository.getPaymentChart( + startDate: arguments.startDate, + endDate: arguments.endDate, + bucket: arguments.bucket, + ); + } +} diff --git a/apps/mobile/packages/features/staff/payments/lib/src/domain/usecases/get_payment_history_usecase.dart b/apps/mobile/packages/features/staff/payments/lib/src/domain/usecases/get_payment_history_usecase.dart index 29b5c6e3..bb0aa7e2 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/domain/usecases/get_payment_history_usecase.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/domain/usecases/get_payment_history_usecase.dart @@ -1,19 +1,25 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../arguments/get_payment_history_arguments.dart'; -import '../repositories/payments_repository.dart'; -/// Use case to retrieve payment history filtered by a period. -/// -/// This use case delegates the data retrieval to [PaymentsRepository]. -class GetPaymentHistoryUseCase extends UseCase> { +import 'package:staff_payments/src/domain/arguments/get_payment_history_arguments.dart'; +import 'package:staff_payments/src/domain/repositories/payments_repository.dart'; +/// Retrieves payment history records for the current staff member. +class GetPaymentHistoryUseCase + extends UseCase> { /// Creates a [GetPaymentHistoryUseCase]. - GetPaymentHistoryUseCase(this.repository); - final PaymentsRepository repository; + GetPaymentHistoryUseCase(this._repository); + + /// The payments repository. + final PaymentsRepository _repository; @override - Future> call(GetPaymentHistoryArguments arguments) async { - return await repository.getPaymentHistory(arguments.period); + Future> call( + GetPaymentHistoryArguments arguments, + ) async { + return _repository.getPaymentHistory( + startDate: arguments.startDate, + endDate: arguments.endDate, + ); } } diff --git a/apps/mobile/packages/features/staff/payments/lib/src/domain/usecases/get_payment_summary_usecase.dart b/apps/mobile/packages/features/staff/payments/lib/src/domain/usecases/get_payment_summary_usecase.dart index 0f054097..1aa53f11 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/domain/usecases/get_payment_summary_usecase.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/domain/usecases/get_payment_summary_usecase.dart @@ -1,16 +1,18 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../repositories/payments_repository.dart'; -/// Use case to retrieve payment summary information. +import 'package:staff_payments/src/domain/repositories/payments_repository.dart'; + +/// Retrieves the aggregated payment summary for the current staff member. class GetPaymentSummaryUseCase extends NoInputUseCase { - /// Creates a [GetPaymentSummaryUseCase]. - GetPaymentSummaryUseCase(this.repository); - final PaymentsRepository repository; + GetPaymentSummaryUseCase(this._repository); + + /// The payments repository. + final PaymentsRepository _repository; @override Future call() async { - return await repository.getPaymentSummary(); + return _repository.getPaymentSummary(); } } diff --git a/apps/mobile/packages/features/staff/payments/lib/src/payments_module.dart b/apps/mobile/packages/features/staff/payments/lib/src/payments_module.dart index 6f30e5d5..026ea2ff 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/payments_module.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/payments_module.dart @@ -1,26 +1,50 @@ import 'package:flutter/src/widgets/framework.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'domain/repositories/payments_repository.dart'; -import 'domain/usecases/get_payment_summary_usecase.dart'; -import 'domain/usecases/get_payment_history_usecase.dart'; -import 'data/repositories/payments_repository_impl.dart'; -import 'presentation/blocs/payments/payments_bloc.dart'; -import 'presentation/pages/payments_page.dart'; -import 'presentation/pages/early_pay_page.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_payments/src/data/repositories/payments_repository_impl.dart'; +import 'package:staff_payments/src/domain/repositories/payments_repository.dart'; +import 'package:staff_payments/src/domain/usecases/get_payment_chart_usecase.dart'; +import 'package:staff_payments/src/domain/usecases/get_payment_history_usecase.dart'; +import 'package:staff_payments/src/domain/usecases/get_payment_summary_usecase.dart'; +import 'package:staff_payments/src/presentation/blocs/payments/payments_bloc.dart'; +import 'package:staff_payments/src/presentation/pages/early_pay_page.dart'; +import 'package:staff_payments/src/presentation/pages/payments_page.dart'; + +/// Module for the staff payments feature. class StaffPaymentsModule extends Module { + @override + List get imports => [CoreModule()]; + @override void binds(Injector i) { // Repositories - i.add(PaymentsRepositoryImpl.new); + i.add( + () => PaymentsRepositoryImpl( + apiService: i.get(), + ), + ); // Use Cases - i.add(GetPaymentSummaryUseCase.new); - i.add(GetPaymentHistoryUseCase.new); + i.add( + () => GetPaymentSummaryUseCase(i.get()), + ); + i.add( + () => GetPaymentHistoryUseCase(i.get()), + ); + i.add( + () => GetPaymentChartUseCase(i.get()), + ); // Blocs - i.add(PaymentsBloc.new); + i.add( + () => PaymentsBloc( + getPaymentSummary: i.get(), + getPaymentHistory: i.get(), + getPaymentChart: i.get(), + ), + ); } @override diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/blocs/payments/payments_bloc.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/blocs/payments/payments_bloc.dart index f0e096db..e310c90d 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/presentation/blocs/payments/payments_bloc.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/blocs/payments/payments_bloc.dart @@ -1,24 +1,38 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../../domain/arguments/get_payment_history_arguments.dart'; -import '../../../domain/usecases/get_payment_history_usecase.dart'; -import '../../../domain/usecases/get_payment_summary_usecase.dart'; -import 'payments_event.dart'; -import 'payments_state.dart'; +import 'package:staff_payments/src/domain/arguments/get_payment_chart_arguments.dart'; +import 'package:staff_payments/src/domain/arguments/get_payment_history_arguments.dart'; +import 'package:staff_payments/src/domain/usecases/get_payment_chart_usecase.dart'; +import 'package:staff_payments/src/domain/usecases/get_payment_history_usecase.dart'; +import 'package:staff_payments/src/domain/usecases/get_payment_summary_usecase.dart'; +import 'package:staff_payments/src/presentation/blocs/payments/payments_event.dart'; +import 'package:staff_payments/src/presentation/blocs/payments/payments_state.dart'; + +/// BLoC that manages loading and displaying staff payment data. class PaymentsBloc extends Bloc with BlocErrorHandler { + /// Creates a [PaymentsBloc] injecting the required use cases. PaymentsBloc({ required this.getPaymentSummary, required this.getPaymentHistory, + required this.getPaymentChart, }) : super(PaymentsInitial()) { on(_onLoadPayments); on(_onChangePeriod); } + + /// Use case for fetching the earnings summary. final GetPaymentSummaryUseCase getPaymentSummary; + + /// Use case for fetching payment history records. final GetPaymentHistoryUseCase getPaymentHistory; + /// Use case for fetching chart data points. + final GetPaymentChartUseCase getPaymentChart; + + /// Handles the initial load of all payment data. Future _onLoadPayments( LoadPaymentsEvent event, Emitter emit, @@ -27,15 +41,28 @@ class PaymentsBloc extends Bloc await handleError( emit: emit.call, action: () async { - final PaymentSummary currentSummary = await getPaymentSummary(); - - final List history = await getPaymentHistory( - const GetPaymentHistoryArguments('week'), - ); + final _DateRange range = _dateRangeFor('week'); + final List results = await Future.wait(>[ + getPaymentSummary(), + getPaymentHistory( + GetPaymentHistoryArguments( + startDate: range.start, + endDate: range.end, + ), + ), + getPaymentChart( + GetPaymentChartArguments( + startDate: range.start, + endDate: range.end, + bucket: 'day', + ), + ), + ]); emit( PaymentsLoaded( - summary: currentSummary, - history: history, + summary: results[0] as PaymentSummary, + history: results[1] as List, + chartPoints: results[2] as List, activePeriod: 'week', ), ); @@ -44,6 +71,7 @@ class PaymentsBloc extends Bloc ); } + /// Handles switching the active period tab. Future _onChangePeriod( ChangePeriodEvent event, Emitter emit, @@ -53,12 +81,27 @@ class PaymentsBloc extends Bloc await handleError( emit: emit.call, action: () async { - final List newHistory = await getPaymentHistory( - GetPaymentHistoryArguments(event.period), - ); + final _DateRange range = _dateRangeFor(event.period); + final String bucket = _bucketFor(event.period); + final List results = await Future.wait(>[ + getPaymentHistory( + GetPaymentHistoryArguments( + startDate: range.start, + endDate: range.end, + ), + ), + getPaymentChart( + GetPaymentChartArguments( + startDate: range.start, + endDate: range.end, + bucket: bucket, + ), + ), + ]); emit( currentState.copyWith( - history: newHistory, + history: results[0] as List, + chartPoints: results[1] as List, activePeriod: event.period, ), ); @@ -67,5 +110,46 @@ class PaymentsBloc extends Bloc ); } } + + /// Computes start and end ISO-8601 date strings for a given period. + static _DateRange _dateRangeFor(String period) { + final DateTime now = DateTime.now(); + final DateTime end = now; + late final DateTime start; + switch (period) { + case 'week': + start = now.subtract(const Duration(days: 7)); + case 'month': + start = DateTime(now.year, now.month - 1, now.day); + case 'year': + start = DateTime(now.year - 1, now.month, now.day); + default: + start = now.subtract(const Duration(days: 7)); + } + return _DateRange( + start: start.toIso8601String(), + end: end.toIso8601String(), + ); + } + + /// Maps a period identifier to the chart bucket granularity. + static String _bucketFor(String period) { + switch (period) { + case 'week': + return 'day'; + case 'month': + return 'week'; + case 'year': + return 'month'; + default: + return 'day'; + } + } } +/// Internal helper for holding a date range pair. +class _DateRange { + const _DateRange({required this.start, required this.end}); + final String start; + final String end; +} diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/blocs/payments/payments_event.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/blocs/payments/payments_event.dart index bf0cad93..11a3fce1 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/presentation/blocs/payments/payments_event.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/blocs/payments/payments_event.dart @@ -1,17 +1,23 @@ import 'package:equatable/equatable.dart'; +/// Base event for the payments feature. abstract class PaymentsEvent extends Equatable { + /// Creates a [PaymentsEvent]. const PaymentsEvent(); @override List get props => []; } +/// Triggered on initial load to fetch summary, history, and chart data. class LoadPaymentsEvent extends PaymentsEvent {} +/// Triggered when the user switches the period tab (week, month, year). class ChangePeriodEvent extends PaymentsEvent { - + /// Creates a [ChangePeriodEvent] for the given [period]. const ChangePeriodEvent(this.period); + + /// The selected period identifier. final String period; @override diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/blocs/payments/payments_state.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/blocs/payments/payments_state.dart index edd2fb8c..241f2ab3 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/presentation/blocs/payments/payments_state.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/blocs/payments/payments_state.dart @@ -1,47 +1,69 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +/// Base state for the payments feature. abstract class PaymentsState extends Equatable { + /// Creates a [PaymentsState]. const PaymentsState(); @override List get props => []; } +/// Initial state before any data has been requested. class PaymentsInitial extends PaymentsState {} +/// Data is being loaded from the backend. class PaymentsLoading extends PaymentsState {} +/// Data loaded successfully. class PaymentsLoaded extends PaymentsState { - + /// Creates a [PaymentsLoaded] state. const PaymentsLoaded({ required this.summary, required this.history, + required this.chartPoints, this.activePeriod = 'week', }); + + /// Aggregated payment summary. final PaymentSummary summary; - final List history; + + /// List of individual payment records. + final List history; + + /// Chart data points for the earnings trend graph. + final List chartPoints; + + /// Currently selected period tab (week, month, year). final String activePeriod; + /// Creates a copy with optional overrides. PaymentsLoaded copyWith({ PaymentSummary? summary, - List? history, + List? history, + List? chartPoints, String? activePeriod, }) { return PaymentsLoaded( summary: summary ?? this.summary, history: history ?? this.history, + chartPoints: chartPoints ?? this.chartPoints, activePeriod: activePeriod ?? this.activePeriod, ); } @override - List get props => [summary, history, activePeriod]; + List get props => + [summary, history, chartPoints, activePeriod]; } +/// An error occurred while loading payments data. class PaymentsError extends PaymentsState { - + /// Creates a [PaymentsError] with the given [message]. const PaymentsError(this.message); + + /// The error key or message. final String message; @override diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart index 1420c110..8c76a863 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart @@ -5,15 +5,18 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:intl/intl.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:core_localization/core_localization.dart'; -import '../blocs/payments/payments_bloc.dart'; -import '../blocs/payments/payments_event.dart'; -import '../blocs/payments/payments_state.dart'; -import '../widgets/payments_page_skeleton.dart'; -import '../widgets/payment_stats_card.dart'; -import '../widgets/payment_history_item.dart'; -import '../widgets/earnings_graph.dart'; +import 'package:staff_payments/src/presentation/blocs/payments/payments_bloc.dart'; +import 'package:staff_payments/src/presentation/blocs/payments/payments_event.dart'; +import 'package:staff_payments/src/presentation/blocs/payments/payments_state.dart'; +import 'package:staff_payments/src/presentation/widgets/payments_page_skeleton.dart'; +import 'package:staff_payments/src/presentation/widgets/payment_stats_card.dart'; +import 'package:staff_payments/src/presentation/widgets/payment_history_item.dart'; +import 'package:staff_payments/src/presentation/widgets/earnings_graph.dart'; + +/// Main page for the staff payments feature. class PaymentsPage extends StatefulWidget { + /// Creates a [PaymentsPage]. const PaymentsPage({super.key}); @override @@ -38,12 +41,11 @@ class _PaymentsPageState extends State { backgroundColor: UiColors.background, body: BlocConsumer( listener: (BuildContext context, PaymentsState state) { - // Error is already shown on the page itself (lines 53-63), no need for snackbar + // Error is rendered inline, no snackbar needed. }, builder: (BuildContext context, PaymentsState state) { if (state is PaymentsLoading) { return const PaymentsPageSkeleton(); - } else if (state is PaymentsError) { return Center( child: Padding( @@ -51,7 +53,8 @@ class _PaymentsPageState extends State { child: Text( translateErrorKey(state.message), textAlign: TextAlign.center, - style: UiTypography.body2r.copyWith(color: UiColors.textSecondary), + style: UiTypography.body2r + .copyWith(color: UiColors.textSecondary), ), ), ); @@ -65,7 +68,10 @@ class _PaymentsPageState extends State { ); } + /// Builds the loaded content layout. Widget _buildContent(BuildContext context, PaymentsLoaded state) { + final String totalFormatted = + _formatCents(state.summary.totalEarningsCents); return SingleChildScrollView( child: Column( children: [ @@ -91,7 +97,7 @@ class _PaymentsPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "Earnings", + 'Earnings', style: UiTypography.displayMb.white, ), const SizedBox(height: UiConstants.space6), @@ -101,14 +107,14 @@ class _PaymentsPageState extends State { child: Column( children: [ Text( - "Total Earnings", + 'Total Earnings', style: UiTypography.body2r.copyWith( color: UiColors.accent, ), ), const SizedBox(height: UiConstants.space1), Text( - "\$${state.summary.totalEarnings.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]},')}", + totalFormatted, style: UiTypography.displayL.white, ), ], @@ -121,13 +127,14 @@ class _PaymentsPageState extends State { padding: const EdgeInsets.all(UiConstants.space1), decoration: BoxDecoration( color: UiColors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderRadius: + BorderRadius.circular(UiConstants.radiusBase), ), child: Row( children: [ - _buildTab("Week", 'week', state.activePeriod), - _buildTab("Month", 'month', state.activePeriod), - _buildTab("Year", 'year', state.activePeriod), + _buildTab('Week', 'week', state.activePeriod), + _buildTab('Month', 'month', state.activePeriod), + _buildTab('Year', 'year', state.activePeriod), ], ), ), @@ -139,16 +146,18 @@ class _PaymentsPageState extends State { Transform.translate( offset: const Offset(0, -UiConstants.space4), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), + padding: + const EdgeInsets.symmetric(horizontal: UiConstants.space5), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Earnings Graph EarningsGraph( - payments: state.history, + chartPoints: state.chartPoints, period: state.activePeriod, ), const SizedBox(height: UiConstants.space6), + // Quick Stats Row( children: [ @@ -156,8 +165,8 @@ class _PaymentsPageState extends State { child: PaymentStatsCard( icon: UiIcons.chart, iconColor: UiColors.success, - label: "This Week", - amount: "\$${state.summary.weeklyEarnings}", + label: 'Total Earnings', + amount: totalFormatted, ), ), const SizedBox(width: UiConstants.space3), @@ -165,8 +174,14 @@ class _PaymentsPageState extends State { child: PaymentStatsCard( icon: UiIcons.calendar, iconColor: UiColors.primary, - label: "This Month", - amount: "\$${state.summary.monthlyEarnings.toStringAsFixed(0)}", + label: '${state.history.length} Payments', + amount: _formatCents( + state.history.fold( + 0, + (int sum, PaymentRecord r) => + sum + r.amountCents, + ), + ), ), ), ], @@ -179,28 +194,26 @@ class _PaymentsPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "Recent Payments", + 'Recent Payments', style: UiTypography.body1b, ), const SizedBox(height: UiConstants.space3), Column( - children: state.history.map((StaffPayment payment) { + children: + state.history.map((PaymentRecord payment) { return Padding( padding: const EdgeInsets.only( bottom: UiConstants.space2), child: PaymentHistoryItem( - amount: payment.amount, - title: payment.shiftTitle ?? "Shift Payment", - location: payment.shiftLocation ?? "Varies", - address: payment.locationAddress ?? payment.id, - date: payment.paidAt != null - ? DateFormat('E, MMM d') - .format(payment.paidAt!) - : 'Pending', - workedTime: payment.workedTime ?? "Completed", - hours: (payment.hoursWorked ?? 0).toInt(), - rate: payment.hourlyRate ?? 0.0, - status: payment.status.name.toUpperCase(), + amountCents: payment.amountCents, + title: payment.shiftName ?? 'Shift Payment', + location: payment.location ?? 'Varies', + date: + DateFormat('E, MMM d').format(payment.date), + minutesWorked: payment.minutesWorked ?? 0, + hourlyRateCents: + payment.hourlyRateCents ?? 0, + status: payment.status, ), ); }).toList(), @@ -218,16 +231,19 @@ class _PaymentsPageState extends State { ); } + /// Builds a period tab widget. Widget _buildTab(String label, String value, String activePeriod) { final bool isSelected = activePeriod == value; return Expanded( child: GestureDetector( onTap: () => _bloc.add(ChangePeriodEvent(value)), child: Container( - padding: const EdgeInsets.symmetric(vertical: UiConstants.space2), + padding: + const EdgeInsets.symmetric(vertical: UiConstants.space2), decoration: BoxDecoration( color: isSelected ? UiColors.white : UiColors.transparent, - borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + borderRadius: + BorderRadius.circular(UiConstants.radiusMdValue), ), child: Center( child: Text( @@ -241,5 +257,14 @@ class _PaymentsPageState extends State { ), ); } -} + /// Formats an amount in cents to a dollar string (e.g. `$1,234.56`). + static String _formatCents(int cents) { + final double dollars = cents / 100; + final NumberFormat formatter = NumberFormat.currency( + symbol: r'$', + decimalDigits: 2, + ); + return formatter.format(dollars); + } +} diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/earnings_graph.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/earnings_graph.dart index 4a7cc547..ea8b5478 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/earnings_graph.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/earnings_graph.dart @@ -4,25 +4,24 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:krow_domain/krow_domain.dart'; +/// Displays an earnings trend line chart from backend chart data. class EarningsGraph extends StatelessWidget { - + /// Creates an [EarningsGraph]. const EarningsGraph({ super.key, - required this.payments, + required this.chartPoints, required this.period, }); - final List payments; + + /// Pre-aggregated chart data points from the V2 API. + final List chartPoints; + + /// The currently selected period (week, month, year). final String period; @override Widget build(BuildContext context) { - // Basic data processing for the graph - // We'll aggregate payments by date - final List validPayments = payments.where((StaffPayment p) => p.paidAt != null).toList() - ..sort((StaffPayment a, StaffPayment b) => a.paidAt!.compareTo(b.paidAt!)); - - // If no data, show empty state or simple placeholder - if (validPayments.isEmpty) { + if (chartPoints.isEmpty) { return Container( height: 200, decoration: BoxDecoration( @@ -31,15 +30,23 @@ class EarningsGraph extends StatelessWidget { ), child: Center( child: Text( - "No sufficient data for graph", + 'No sufficient data for graph', style: UiTypography.body2r.textSecondary, ), ), ); } - final List spots = _generateSpots(validPayments); - final double maxY = spots.isNotEmpty ? spots.map((FlSpot s) => s.y).reduce((double a, double b) => a > b ? a : b) : 0.0; + final List sorted = List.of(chartPoints) + ..sort((PaymentChartPoint a, PaymentChartPoint b) => + a.bucket.compareTo(b.bucket)); + + final List spots = _generateSpots(sorted); + final double maxY = spots.isNotEmpty + ? spots + .map((FlSpot s) => s.y) + .reduce((double a, double b) => a > b ? a : b) + : 0.0; return Container( height: 220, @@ -59,7 +66,7 @@ class EarningsGraph extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "Earnings Trend", + 'Earnings Trend', style: UiTypography.body2b.textPrimary, ), const SizedBox(height: UiConstants.space4), @@ -71,26 +78,31 @@ class EarningsGraph extends StatelessWidget { bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, - getTitlesWidget: (double value, TitleMeta meta) { - // Simple logic to show a few dates - if (value % 2 != 0) return const SizedBox(); - final int index = value.toInt(); - if (index >= 0 && index < validPayments.length) { - return Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Text( - DateFormat('d').format(validPayments[index].paidAt!), - style: UiTypography.footnote1r.textSecondary, - ), - ); - } - return const SizedBox(); + getTitlesWidget: + (double value, TitleMeta meta) { + if (value % 2 != 0) return const SizedBox(); + final int index = value.toInt(); + if (index >= 0 && index < sorted.length) { + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + _formatBucketLabel( + sorted[index].bucket, period), + style: + UiTypography.footnote1r.textSecondary, + ), + ); + } + return const SizedBox(); }, ), ), - leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), ), borderData: FlBorderData(show: false), lineBarsData: [ @@ -119,20 +131,32 @@ class EarningsGraph extends StatelessWidget { ); } - List _generateSpots(List data) { - if (data.isEmpty) return []; - - // If only one data point, add a dummy point at the start to create a horizontal line + /// Converts chart points to [FlSpot] values (dollars). + List _generateSpots(List data) { + if (data.isEmpty) return []; + if (data.length == 1) { - return [ - FlSpot(0, data[0].amount), - FlSpot(1, data[0].amount), + final double dollars = data[0].amountCents / 100; + return [ + FlSpot(0, dollars), + FlSpot(1, dollars), ]; } - // Generate spots based on index in the list for simplicity in this demo return List.generate(data.length, (int index) { - return FlSpot(index.toDouble(), data[index].amount); + return FlSpot(index.toDouble(), data[index].amountCents / 100); }); } + + /// Returns a short label for a chart bucket date. + String _formatBucketLabel(DateTime bucket, String period) { + switch (period) { + case 'year': + return DateFormat('MMM').format(bucket); + case 'month': + return DateFormat('d').format(bucket); + default: + return DateFormat('d').format(bucket); + } + } } diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payment_history_item.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payment_history_item.dart index 44fe3304..aab3e56a 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payment_history_item.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payment_history_item.dart @@ -1,32 +1,53 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; +/// Displays a single payment record in the history list. class PaymentHistoryItem extends StatelessWidget { - + /// Creates a [PaymentHistoryItem]. const PaymentHistoryItem({ super.key, - required this.amount, + required this.amountCents, required this.title, required this.location, - required this.address, required this.date, - required this.workedTime, - required this.hours, - required this.rate, + required this.minutesWorked, + required this.hourlyRateCents, required this.status, }); - final double amount; + + /// Payment amount in cents. + final int amountCents; + + /// Shift or payment title. final String title; + + /// Location / hub name. final String location; - final String address; + + /// Formatted date string. final String date; - final String workedTime; - final int hours; - final double rate; - final String status; + + /// Total minutes worked. + final int minutesWorked; + + /// Hourly rate in cents. + final int hourlyRateCents; + + /// Payment processing status. + final PaymentStatus status; @override Widget build(BuildContext context) { + final String dollarAmount = _centsToDollars(amountCents); + final String rateDisplay = _centsToDollars(hourlyRateCents); + final int hours = minutesWorked ~/ 60; + final int mins = minutesWorked % 60; + final String timeDisplay = + mins > 0 ? '${hours}h ${mins}m' : '${hours}h'; + final Color statusColor = _statusColor(status); + final String statusLabel = status.value; + return Container( padding: const EdgeInsets.all(UiConstants.space4), decoration: BoxDecoration( @@ -43,16 +64,16 @@ class PaymentHistoryItem extends StatelessWidget { Container( width: 6, height: 6, - decoration: const BoxDecoration( - color: UiColors.primary, + decoration: BoxDecoration( + color: statusColor, shape: BoxShape.circle, ), ), const SizedBox(width: 6), Text( - "PAID", + statusLabel, style: UiTypography.titleUppercase4b.copyWith( - color: UiColors.primary, + color: statusColor, ), ), ], @@ -68,7 +89,8 @@ class PaymentHistoryItem extends StatelessWidget { height: 44, decoration: BoxDecoration( color: UiColors.secondary, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderRadius: + BorderRadius.circular(UiConstants.radiusBase), ), child: const Icon( UiIcons.dollar, @@ -90,10 +112,7 @@ class PaymentHistoryItem extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: UiTypography.body2m, - ), + Text(title, style: UiTypography.body2m), Text( location, style: UiTypography.body3r.textSecondary, @@ -105,12 +124,13 @@ class PaymentHistoryItem extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - "\$${amount.toStringAsFixed(0)}", + dollarAmount, style: UiTypography.headline4b, ), Text( - "\$${rate.toStringAsFixed(0)}/hr · ${hours}h", - style: UiTypography.footnote1r.textSecondary, + '$rateDisplay/hr \u00B7 $timeDisplay', + style: + UiTypography.footnote1r.textSecondary, ), ], ), @@ -118,7 +138,7 @@ class PaymentHistoryItem extends StatelessWidget { ), const SizedBox(height: UiConstants.space2), - // Date and Time + // Date Row( children: [ const Icon( @@ -139,32 +159,11 @@ class PaymentHistoryItem extends StatelessWidget { ), const SizedBox(width: UiConstants.space2), Text( - workedTime, + timeDisplay, style: UiTypography.body3r.textSecondary, ), ], ), - const SizedBox(height: 1), - - // Address - Row( - children: [ - const Icon( - UiIcons.mapPin, - size: 12, - color: UiColors.mutedForeground, - ), - const SizedBox(width: UiConstants.space2), - Expanded( - child: Text( - address, - style: UiTypography.body3r.textSecondary, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), ], ), ), @@ -174,4 +173,26 @@ class PaymentHistoryItem extends StatelessWidget { ), ); } + + /// Converts cents to a formatted dollar string. + static String _centsToDollars(int cents) { + final double dollars = cents / 100; + return '\$${dollars.toStringAsFixed(2)}'; + } + + /// Returns a colour for the given payment status. + static Color _statusColor(PaymentStatus status) { + switch (status) { + case PaymentStatus.paid: + return UiColors.primary; + case PaymentStatus.pending: + return UiColors.textWarning; + case PaymentStatus.processing: + return UiColors.primary; + case PaymentStatus.failed: + return UiColors.error; + case PaymentStatus.unknown: + return UiColors.mutedForeground; + } + } } diff --git a/apps/mobile/packages/features/staff/payments/pubspec.yaml b/apps/mobile/packages/features/staff/payments/pubspec.yaml index 51d08e71..b90d66ff 100644 --- a/apps/mobile/packages/features/staff/payments/pubspec.yaml +++ b/apps/mobile/packages/features/staff/payments/pubspec.yaml @@ -18,13 +18,9 @@ dependencies: path: ../../../domain krow_core: path: ../../../core - krow_data_connect: - path: ../../../data_connect flutter: sdk: flutter - firebase_data_connect: ^0.2.2+2 - firebase_auth: ^6.1.4 flutter_modular: ^6.3.2 intl: ^0.20.0 fl_chart: ^0.66.0 diff --git a/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart b/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart new file mode 100644 index 00000000..076db252 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart @@ -0,0 +1,36 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Repository implementation for the main profile page. +/// +/// Uses the V2 API to fetch staff profile, section statuses, and completion. +class ProfileRepositoryImpl { + /// Creates a [ProfileRepositoryImpl]. + ProfileRepositoryImpl({required BaseApiService apiService}) + : _api = apiService; + + final BaseApiService _api; + + /// Fetches the staff profile from the V2 session endpoint. + Future getStaffProfile() async { + final ApiResponse response = + await _api.get(V2ApiEndpoints.staffSession); + final Map json = + response.data['staff'] as Map; + return Staff.fromJson(json); + } + + /// Fetches the profile section completion statuses. + Future getProfileSections() async { + final ApiResponse response = + await _api.get(V2ApiEndpoints.staffProfileSections); + final Map json = + response.data as Map; + return ProfileSectionStatus.fromJson(json); + } + + /// Signs out the current user. + Future signOut() async { + await _api.post(V2ApiEndpoints.signOut); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart index db64fa43..ec70c614 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart @@ -1,62 +1,57 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'profile_state.dart'; +import 'package:staff_profile/src/data/repositories/profile_repository_impl.dart'; +import 'package:staff_profile/src/presentation/blocs/profile_state.dart'; /// Cubit for managing the Profile feature state. /// -/// Handles loading profile data and user sign-out actions. +/// Uses the V2 API via [ProfileRepositoryImpl] for all data fetching. +/// Loads the staff profile and section completion statuses in a single flow. class ProfileCubit extends Cubit with BlocErrorHandler { + /// Creates a [ProfileCubit] with the required repository. + ProfileCubit(this._repository) : super(const ProfileState()); - /// Creates a [ProfileCubit] with the required use cases. - ProfileCubit( - this._getProfileUseCase, - this._signOutUseCase, - this._getPersonalInfoCompletionUseCase, - this._getEmergencyContactsCompletionUseCase, - this._getExperienceCompletionUseCase, - this._getTaxFormsCompletionUseCase, - this._getAttireOptionsCompletionUseCase, - this._getStaffDocumentsCompletionUseCase, - this._getStaffCertificatesCompletionUseCase, - ) : super(const ProfileState()); - final GetStaffProfileUseCase _getProfileUseCase; - final SignOutStaffUseCase _signOutUseCase; - final GetPersonalInfoCompletionUseCase _getPersonalInfoCompletionUseCase; - final GetEmergencyContactsCompletionUseCase _getEmergencyContactsCompletionUseCase; - final GetExperienceCompletionUseCase _getExperienceCompletionUseCase; - final GetTaxFormsCompletionUseCase _getTaxFormsCompletionUseCase; - final GetAttireOptionsCompletionUseCase _getAttireOptionsCompletionUseCase; - final GetStaffDocumentsCompletionUseCase _getStaffDocumentsCompletionUseCase; - final GetStaffCertificatesCompletionUseCase _getStaffCertificatesCompletionUseCase; + final ProfileRepositoryImpl _repository; /// Loads the staff member's profile. - /// - /// Emits [ProfileStatus.loading] while fetching data, - /// then [ProfileStatus.loaded] with the profile data on success, - /// or [ProfileStatus.error] if an error occurs. Future loadProfile() async { emit(state.copyWith(status: ProfileStatus.loading)); await handleError( emit: emit, action: () async { - final Staff profile = await _getProfileUseCase(); + final Staff profile = await _repository.getStaffProfile(); emit(state.copyWith(status: ProfileStatus.loaded, profile: profile)); }, - onError: - (String errorKey) => - state.copyWith(status: ProfileStatus.error, errorMessage: errorKey), + onError: (String errorKey) => + state.copyWith(status: ProfileStatus.error, errorMessage: errorKey), + ); + } + + /// Loads all profile section completion statuses in a single V2 API call. + Future loadSectionStatuses() async { + await handleError( + emit: emit, + action: () async { + final ProfileSectionStatus sections = + await _repository.getProfileSections(); + emit(state.copyWith( + personalInfoComplete: sections.personalInfoCompleted, + emergencyContactsComplete: sections.emergencyContactCompleted, + experienceComplete: sections.experienceCompleted, + taxFormsComplete: sections.taxFormsCompleted, + attireComplete: sections.attireCompleted, + certificatesComplete: sections.certificateCount > 0, + )); + }, + onError: (String _) => state, ); } /// Signs out the current user. - /// - /// Delegates to the sign-out use case which handles session cleanup - /// and navigation. Future signOut() async { if (state.status == ProfileStatus.loading) { return; @@ -67,116 +62,11 @@ class ProfileCubit extends Cubit await handleError( emit: emit, action: () async { - await _signOutUseCase(); + await _repository.signOut(); emit(state.copyWith(status: ProfileStatus.signedOut)); }, - onError: (String _) { - // For sign out errors, we might want to just proceed or show error - // Current implementation was silent catch, let's keep it robust but consistent - // If we want to force navigation even on error, we would do it here - // But usually handleError emits the error state. - // Let's stick to standard error reporting for now. - return state.copyWith(status: ProfileStatus.error); - }, - ); - } - - /// Loads personal information completion status. - Future loadPersonalInfoCompletion() async { - await handleError( - emit: emit, - action: () async { - final bool isComplete = await _getPersonalInfoCompletionUseCase(); - emit(state.copyWith(personalInfoComplete: isComplete)); - }, - onError: (String _) { - return state.copyWith(personalInfoComplete: false); - }, - ); - } - - /// Loads emergency contacts completion status. - Future loadEmergencyContactsCompletion() async { - await handleError( - emit: emit, - action: () async { - final bool isComplete = await _getEmergencyContactsCompletionUseCase(); - emit(state.copyWith(emergencyContactsComplete: isComplete)); - }, - onError: (String _) { - return state.copyWith(emergencyContactsComplete: false); - }, - ); - } - - /// Loads experience completion status. - Future loadExperienceCompletion() async { - await handleError( - emit: emit, - action: () async { - final bool isComplete = await _getExperienceCompletionUseCase(); - emit(state.copyWith(experienceComplete: isComplete)); - }, - onError: (String _) { - return state.copyWith(experienceComplete: false); - }, - ); - } - - /// Loads tax forms completion status. - Future loadTaxFormsCompletion() async { - await handleError( - emit: emit, - action: () async { - final bool isComplete = await _getTaxFormsCompletionUseCase(); - emit(state.copyWith(taxFormsComplete: isComplete)); - }, - onError: (String _) { - return state.copyWith(taxFormsComplete: false); - }, - ); - } - - /// Loads attire options completion status. - Future loadAttireCompletion() async { - await handleError( - emit: emit, - action: () async { - final bool? isComplete = await _getAttireOptionsCompletionUseCase(); - emit(state.copyWith(attireComplete: isComplete)); - }, - onError: (String _) { - return state.copyWith(attireComplete: false); - }, - ); - } - - /// Loads documents completion status. - Future loadDocumentsCompletion() async { - await handleError( - emit: emit, - action: () async { - final bool? isComplete = await _getStaffDocumentsCompletionUseCase(); - emit(state.copyWith(documentsComplete: isComplete)); - }, - onError: (String _) { - return state.copyWith(documentsComplete: false); - }, - ); - } - - /// Loads certificates completion status. - Future loadCertificatesCompletion() async { - await handleError( - emit: emit, - action: () async { - final bool? isComplete = await _getStaffCertificatesCompletionUseCase(); - emit(state.copyWith(certificatesComplete: isComplete)); - }, - onError: (String _) { - return state.copyWith(certificatesComplete: false); - }, + onError: (String _) => + state.copyWith(status: ProfileStatus.error), ); } } - diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart index 7f54d16b..5dc8ef39 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart @@ -6,22 +6,19 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../blocs/profile_cubit.dart'; -import '../blocs/profile_state.dart'; -import '../widgets/logout_button.dart'; -import '../widgets/header/profile_header.dart'; -import '../widgets/profile_page_skeleton/profile_page_skeleton.dart'; -import '../widgets/reliability_score_bar.dart'; -import '../widgets/reliability_stats_card.dart'; -import '../widgets/sections/index.dart'; +import 'package:staff_profile/src/presentation/blocs/profile_cubit.dart'; +import 'package:staff_profile/src/presentation/blocs/profile_state.dart'; +import 'package:staff_profile/src/presentation/widgets/logout_button.dart'; +import 'package:staff_profile/src/presentation/widgets/header/profile_header.dart'; +import 'package:staff_profile/src/presentation/widgets/profile_page_skeleton/profile_page_skeleton.dart'; +import 'package:staff_profile/src/presentation/widgets/reliability_score_bar.dart'; +import 'package:staff_profile/src/presentation/widgets/reliability_stats_card.dart'; +import 'package:staff_profile/src/presentation/widgets/sections/index.dart'; /// The main Staff Profile page. /// -/// This page displays the staff member's profile including their stats, -/// reliability score, and various menu sections for onboarding, compliance, -/// learning, finance, and support. -/// -/// It follows Clean Architecture with BLoC for state management. +/// Displays the staff member's profile, reliability stats, and +/// various menu sections. Uses V2 API via [ProfileCubit]. class StaffProfilePage extends StatelessWidget { /// Creates a [StaffProfilePage]. const StaffProfilePage({super.key}); @@ -40,16 +37,10 @@ class StaffProfilePage extends StatelessWidget { value: cubit, child: BlocConsumer( listener: (BuildContext context, ProfileState state) { - // Load completion statuses when profile loads successfully + // Load section statuses when profile loads successfully if (state.status == ProfileStatus.loaded && state.personalInfoComplete == null) { - cubit.loadPersonalInfoCompletion(); - cubit.loadEmergencyContactsCompletion(); - cubit.loadExperienceCompletion(); - cubit.loadTaxFormsCompletion(); - cubit.loadAttireCompletion(); - cubit.loadDocumentsCompletion(); - cubit.loadCertificatesCompletion(); + cubit.loadSectionStatuses(); } if (state.status == ProfileStatus.signedOut) { @@ -64,7 +55,6 @@ class StaffProfilePage extends StatelessWidget { } }, builder: (BuildContext context, ProfileState state) { - // Show shimmer skeleton while profile data loads if (state.status == ProfileStatus.loading) { return const ProfilePageSkeleton(); } @@ -96,8 +86,8 @@ class StaffProfilePage extends StatelessWidget { child: Column( children: [ ProfileHeader( - fullName: profile.name, - photoUrl: profile.avatar, + fullName: profile.fullName, + photoUrl: null, ), Transform.translate( offset: const Offset(0, -UiConstants.space6), @@ -108,33 +98,27 @@ class StaffProfilePage extends StatelessWidget { child: Column( spacing: UiConstants.space6, children: [ - // Reliability Stats and Score + // Reliability Stats ReliabilityStatsCard( - totalShifts: profile.totalShifts, + totalShifts: 0, averageRating: profile.averageRating, - onTimeRate: profile.onTimeRate, - noShowCount: profile.noShowCount, - cancellationCount: profile.cancellationCount, + onTimeRate: 0, + noShowCount: 0, + cancellationCount: 0, ), // Reliability Score Bar - ReliabilityScoreBar( - reliabilityScore: profile.reliabilityScore, + const ReliabilityScoreBar( + reliabilityScore: 0, ), // Ordered sections const OnboardingSection(), - - // Compliance section const ComplianceSection(), - - // Finance section const FinanceSection(), - - // Support section const SupportSection(), - // Logout button at the bottom + // Logout button const LogoutButton(), const SizedBox(height: UiConstants.space6), diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/header/profile_level_badge.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/header/profile_level_badge.dart index 9514a463..c0a473b8 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/header/profile_level_badge.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/header/profile_level_badge.dart @@ -18,12 +18,12 @@ class ProfileLevelBadge extends StatelessWidget { String _mapStatusToLevel(StaffStatus status) { switch (status) { case StaffStatus.active: - case StaffStatus.verified: return 'KROWER I'; - case StaffStatus.pending: - case StaffStatus.completedProfile: + case StaffStatus.invited: return 'Pending'; - default: + case StaffStatus.inactive: + case StaffStatus.blocked: + case StaffStatus.unknown: return 'New'; } } diff --git a/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart index c49c8ecf..2b6f8f60 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart @@ -1,85 +1,32 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_domain/krow_domain.dart'; -import 'presentation/blocs/profile_cubit.dart'; -import 'presentation/pages/staff_profile_page.dart'; +import 'package:staff_profile/src/data/repositories/profile_repository_impl.dart'; +import 'package:staff_profile/src/presentation/blocs/profile_cubit.dart'; +import 'package:staff_profile/src/presentation/pages/staff_profile_page.dart'; /// The entry module for the Staff Profile feature. /// -/// This module provides dependency injection bindings for the profile feature -/// following Clean Architecture principles. -/// -/// Dependency flow: -/// - Use cases from data_connect layer (StaffConnectorRepository) -/// - Cubit depends on use cases +/// Uses the V2 REST API via [BaseApiService] for all backend access. +/// Section completion statuses are fetched in a single API call. class StaffProfileModule extends Module { + @override + List get imports => [CoreModule()]; + @override void binds(Injector i) { - // StaffConnectorRepository intialization - i.addLazySingleton( - () => StaffConnectorRepositoryImpl(), - ); - - // Use cases from data_connect - depend on StaffConnectorRepository - i.addLazySingleton( - () => - GetStaffProfileUseCase(repository: i.get()), - ); - i.addLazySingleton( - () => SignOutStaffUseCase(repository: i.get()), - ); - i.addLazySingleton( - () => GetPersonalInfoCompletionUseCase( - repository: i.get(), - ), - ); - i.addLazySingleton( - () => GetEmergencyContactsCompletionUseCase( - repository: i.get(), - ), - ); - i.addLazySingleton( - () => GetExperienceCompletionUseCase( - repository: i.get(), - ), - ); - i.addLazySingleton( - () => GetTaxFormsCompletionUseCase( - repository: i.get(), - ), - ); - i.addLazySingleton( - () => GetAttireOptionsCompletionUseCase( - repository: i.get(), - ), - ); - i.addLazySingleton( - () => GetStaffDocumentsCompletionUseCase( - repository: i.get(), - ), - ); - i.addLazySingleton( - () => GetStaffCertificatesCompletionUseCase( - repository: i.get(), + // Repository + i.addLazySingleton( + () => ProfileRepositoryImpl( + apiService: i.get(), ), ); - // Presentation layer - Cubit as singleton to avoid recreation - // BlocProvider will use this same instance, preventing state emission after close + // Cubit i.addLazySingleton( - () => ProfileCubit( - i.get(), - i.get(), - i.get(), - i.get(), - i.get(), - i.get(), - i.get(), - i.get(), - i.get(), - ), + () => ProfileCubit(i.get()), ); } diff --git a/apps/mobile/packages/features/staff/profile/pubspec.yaml b/apps/mobile/packages/features/staff/profile/pubspec.yaml index 9ba94894..c8cca402 100644 --- a/apps/mobile/packages/features/staff/profile/pubspec.yaml +++ b/apps/mobile/packages/features/staff/profile/pubspec.yaml @@ -15,7 +15,7 @@ dependencies: bloc: ^8.1.0 flutter_modular: ^6.3.0 equatable: ^2.0.5 - + # Architecture Packages design_system: path: ../../../design_system @@ -25,17 +25,6 @@ dependencies: path: ../../../core krow_domain: path: ../../../domain - krow_data_connect: - path: ../../../data_connect - - # Feature Packages - staff_profile_info: - path: ../profile_sections/onboarding/profile_info - staff_emergency_contact: - path: ../profile_sections/onboarding/emergency_contact - staff_profile_experience: - path: ../profile_sections/onboarding/experience - firebase_auth: ^6.1.4 dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart index f816eff4..b1af3e9e 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart @@ -1,137 +1,101 @@ import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; -import 'package:krow_domain/krow_domain.dart' as domain; +import 'package:krow_domain/krow_domain.dart'; -import '../../domain/repositories/certificates_repository.dart'; +import 'package:staff_certificates/src/domain/repositories/certificates_repository.dart'; -/// Implementation of [CertificatesRepository] using Data Connect. +/// Implementation of [CertificatesRepository] using the V2 API for reads +/// and core services for uploads/verification. +/// +/// Replaces the previous Firebase Data Connect implementation. class CertificatesRepositoryImpl implements CertificatesRepository { + /// Creates a [CertificatesRepositoryImpl]. CertificatesRepositoryImpl({ + required BaseApiService apiService, required FileUploadService uploadService, required SignedUrlService signedUrlService, required VerificationService verificationService, - }) : _service = DataConnectService.instance, - _uploadService = uploadService, - _signedUrlService = signedUrlService, - _verificationService = verificationService; + }) : _api = apiService, + _uploadService = uploadService, + _signedUrlService = signedUrlService, + _verificationService = verificationService; - final DataConnectService _service; + final BaseApiService _api; final FileUploadService _uploadService; final SignedUrlService _signedUrlService; final VerificationService _verificationService; @override - Future> getCertificates() async { - return _service.getStaffRepository().getStaffCertificates(); + Future> getCertificates() async { + final ApiResponse response = + await _api.get(V2ApiEndpoints.staffCertificates); + final List items = + response.data['certificates'] as List; + return items + .map((dynamic json) => + StaffCertificate.fromJson(json as Map)) + .toList(); } @override - Future uploadCertificate({ - required domain.ComplianceType certificationType, + Future uploadCertificate({ + required String certificateType, required String name, required String filePath, DateTime? expiryDate, String? issuer, String? certificateNumber, }) async { - return _service.run(() async { - // Get existing certificate to check if file has changed - final List existingCerts = await getCertificates(); - domain.StaffCertificate? existingCert; - try { - existingCert = existingCerts.firstWhere( - (domain.StaffCertificate c) => c.certificationType == certificationType, - ); - } catch (e) { - // Certificate doesn't exist yet - } + // 1. Upload the file to cloud storage + final FileUploadResponse uploadRes = await _uploadService.uploadFile( + filePath: filePath, + fileName: + 'staff_cert_${certificateType}_${DateTime.now().millisecondsSinceEpoch}.pdf', + visibility: FileVisibility.private, + ); - String? signedUrl = existingCert?.certificateUrl; - String? verificationId = existingCert?.verificationId; - final bool fileChanged = existingCert == null || existingCert.certificateUrl != filePath; + // 2. Generate a signed URL + final SignedUrlResponse signedUrlRes = + await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri); - // Only upload and verify if file path has changed - if (fileChanged) { - // 1. Upload the file to cloud storage - final FileUploadResponse uploadRes = await _uploadService.uploadFile( - filePath: filePath, - fileName: - 'staff_cert_${certificationType.name}_${DateTime.now().millisecondsSinceEpoch}.pdf', - visibility: domain.FileVisibility.private, - ); + // 3. Initiate verification + final VerificationResponse verificationRes = + await _verificationService.createVerification( + fileUri: uploadRes.fileUri, + type: 'certification', + subjectType: 'worker', + subjectId: certificateType, + rules: { + 'certificateName': name, + 'certificateIssuer': issuer, + 'certificateNumber': certificateNumber, + }, + ); - // 2. Generate a signed URL for verification service to access the file - final SignedUrlResponse signedUrlRes = await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri); - signedUrl = signedUrlRes.signedUrl; + // 4. Save certificate via V2 API + await _api.post( + V2ApiEndpoints.staffCertificates, + data: { + 'certificateType': certificateType, + 'name': name, + 'fileUri': signedUrlRes.signedUrl, + 'expiresAt': expiryDate?.toIso8601String(), + 'issuer': issuer, + 'certificateNumber': certificateNumber, + 'verificationId': verificationRes.verificationId, + }, + ); - // 3. Initiate verification - final String staffId = await _service.getStaffId(); - final VerificationResponse verificationRes = await _verificationService - .createVerification( - fileUri: uploadRes.fileUri, - type: 'certification', - subjectType: 'worker', - subjectId: staffId, - rules: { - 'certificateName': name, - 'certificateIssuer': issuer, - 'certificateNumber': certificateNumber, - }, - ); - verificationId = verificationRes.verificationId; - } - - // 4. Update/Create Certificate in Data Connect - await _service.getStaffRepository().upsertStaffCertificate( - certificationType: certificationType, - name: name, - status: existingCert?.status ?? domain.StaffCertificateStatus.pending, - fileUrl: signedUrl, - expiry: expiryDate, - issuer: issuer, - certificateNumber: certificateNumber, - validationStatus: existingCert?.validationStatus ?? domain.StaffCertificateValidationStatus.pendingExpertReview, - verificationId: verificationId, - ); - - // 5. Return updated list or the specific certificate - final List certificates = - await getCertificates(); - return certificates.firstWhere( - (domain.StaffCertificate c) => c.certificationType == certificationType, - ); - }); - } - - @override - Future upsertCertificate({ - required domain.ComplianceType certificationType, - required String name, - required domain.StaffCertificateStatus status, - String? fileUrl, - DateTime? expiry, - String? issuer, - String? certificateNumber, - domain.StaffCertificateValidationStatus? validationStatus, - }) async { - await _service.getStaffRepository().upsertStaffCertificate( - certificationType: certificationType, - name: name, - status: status, - fileUrl: fileUrl, - expiry: expiry, - issuer: issuer, - certificateNumber: certificateNumber, - validationStatus: validationStatus, + // 5. Return updated list + final List certificates = await getCertificates(); + return certificates.firstWhere( + (StaffCertificate c) => c.certificateType == certificateType, ); } @override - Future deleteCertificate({ - required domain.ComplianceType certificationType, - }) async { - return _service.getStaffRepository().deleteStaffCertificate( - certificationType: certificationType, + Future deleteCertificate({required String certificateId}) async { + await _api.delete( + V2ApiEndpoints.staffCertificateDelete(certificateId), ); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/repositories/certificates_repository.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/repositories/certificates_repository.dart index 9a21fc22..93a85a47 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/repositories/certificates_repository.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/repositories/certificates_repository.dart @@ -2,17 +2,15 @@ import 'package:krow_domain/krow_domain.dart'; /// Interface for the certificates repository. /// -/// Responsible for fetching staff compliance certificates. -/// Implementations must reside in the data layer. +/// Responsible for fetching, uploading, and deleting staff certificates +/// via the V2 API. Uses [StaffCertificate] from the V2 domain. abstract interface class CertificatesRepository { - /// Fetches the list of compliance certificates for the current staff member. - /// - /// Returns a list of [StaffCertificate] entities. + /// Fetches the list of certificates for the current staff member. Future> getCertificates(); /// Uploads a certificate file and saves the record. Future uploadCertificate({ - required ComplianceType certificationType, + required String certificateType, required String name, required String filePath, DateTime? expiryDate, @@ -20,18 +18,6 @@ abstract interface class CertificatesRepository { String? certificateNumber, }); - /// Deletes a staff certificate. - Future deleteCertificate({required ComplianceType certificationType}); - - /// Upserts a certificate record (metadata only). - Future upsertCertificate({ - required ComplianceType certificationType, - required String name, - required StaffCertificateStatus status, - String? fileUrl, - DateTime? expiry, - String? issuer, - String? certificateNumber, - StaffCertificateValidationStatus? validationStatus, - }); + /// Deletes a staff certificate by its [certificateId]. + Future deleteCertificate({required String certificateId}); } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/delete_certificate_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/delete_certificate_usecase.dart index f8104461..dc41b97e 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/delete_certificate_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/delete_certificate_usecase.dart @@ -1,15 +1,14 @@ import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; import '../repositories/certificates_repository.dart'; /// Use case for deleting a staff compliance certificate. -class DeleteCertificateUseCase extends UseCase { +class DeleteCertificateUseCase extends UseCase { /// Creates a [DeleteCertificateUseCase]. DeleteCertificateUseCase(this._repository); final CertificatesRepository _repository; @override - Future call(ComplianceType certificationType) { - return _repository.deleteCertificate(certificationType: certificationType); + Future call(String certificateId) { + return _repository.deleteCertificate(certificateId: certificateId); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upload_certificate_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upload_certificate_usecase.dart index 8e26f9ba..1794ef37 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upload_certificate_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upload_certificate_usecase.dart @@ -12,7 +12,7 @@ class UploadCertificateUseCase @override Future call(UploadCertificateParams params) { return _repository.uploadCertificate( - certificationType: params.certificationType, + certificateType: params.certificateType, name: params.name, filePath: params.filePath, expiryDate: params.expiryDate, @@ -26,7 +26,7 @@ class UploadCertificateUseCase class UploadCertificateParams { /// Creates [UploadCertificateParams]. UploadCertificateParams({ - required this.certificationType, + required this.certificateType, required this.name, required this.filePath, this.expiryDate, @@ -34,8 +34,8 @@ class UploadCertificateParams { this.certificateNumber, }); - /// The type of certification. - final ComplianceType certificationType; + /// The type of certification (e.g. "FOOD_HYGIENE", "SIA_BADGE"). + final String certificateType; /// The name of the certificate. final String name; diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upsert_certificate_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upsert_certificate_usecase.dart deleted file mode 100644 index 6773e499..00000000 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upsert_certificate_usecase.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; -import '../repositories/certificates_repository.dart'; - -/// Use case for upserting a staff compliance certificate. -class UpsertCertificateUseCase extends UseCase { - /// Creates an [UpsertCertificateUseCase]. - UpsertCertificateUseCase(this._repository); - final CertificatesRepository _repository; - - @override - Future call(UpsertCertificateParams params) { - return _repository.upsertCertificate( - certificationType: params.certificationType, - name: params.name, - status: params.status, - fileUrl: params.fileUrl, - expiry: params.expiry, - issuer: params.issuer, - certificateNumber: params.certificateNumber, - validationStatus: params.validationStatus, - ); - } -} - -/// Parameters for [UpsertCertificateUseCase]. -class UpsertCertificateParams { - /// Creates [UpsertCertificateParams]. - UpsertCertificateParams({ - required this.certificationType, - required this.name, - required this.status, - this.fileUrl, - this.expiry, - this.issuer, - this.certificateNumber, - this.validationStatus, - }); - - /// The type of certification. - final ComplianceType certificationType; - - /// The name of the certificate. - final String name; - - /// The status of the certificate. - final StaffCertificateStatus status; - - /// The URL of the certificate file. - final String? fileUrl; - - /// The expiry date of the certificate. - final DateTime? expiry; - - /// The issuer of the certificate. - final String? issuer; - - /// The certificate number. - final String? certificateNumber; - - /// The validation status of the certificate. - final StaffCertificateValidationStatus? validationStatus; -} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificate_upload/certificate_upload_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificate_upload/certificate_upload_cubit.dart index a70bc69e..79e646a2 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificate_upload/certificate_upload_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificate_upload/certificate_upload_cubit.dart @@ -23,12 +23,12 @@ class CertificateUploadCubit extends Cubit emit(state.copyWith(selectedFilePath: filePath)); } - Future deleteCertificate(ComplianceType type) async { + Future deleteCertificate(String certificateId) async { emit(state.copyWith(status: CertificateUploadStatus.uploading)); await handleError( emit: emit, action: () async { - await _deleteCertificateUseCase(type); + await _deleteCertificateUseCase(certificateId); emit(state.copyWith(status: CertificateUploadStatus.success)); }, onError: (String errorKey) => state.copyWith( diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_cubit.dart index 59a6e56a..c19b68a6 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_cubit.dart @@ -38,12 +38,12 @@ class CertificatesCubit extends Cubit ); } - Future deleteCertificate(ComplianceType type) async { + Future deleteCertificate(String certificateId) async { emit(state.copyWith(status: CertificatesStatus.loading)); await handleError( emit: emit, action: () async { - await _deleteCertificateUseCase(type); + await _deleteCertificateUseCase(certificateId); await loadCertificates(); }, onError: (String errorKey) => state.copyWith( diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_state.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_state.dart index 6d64c969..44c8ccd0 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_state.dart @@ -33,7 +33,7 @@ class CertificatesState extends Equatable { int get completedCount => certificates .where( (StaffCertificate cert) => - cert.validationStatus == StaffCertificateValidationStatus.approved, + cert.status == CertificateStatus.verified, ) .length; diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart index 3a15d10a..72a25abd 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart @@ -30,7 +30,7 @@ class _CertificateUploadPageState extends State { final TextEditingController _numberController = TextEditingController(); final TextEditingController _nameController = TextEditingController(); - ComplianceType? _selectedType; + String _selectedType = ''; final FilePickerService _filePicker = Modular.get(); @@ -44,13 +44,13 @@ class _CertificateUploadPageState extends State { _cubit = Modular.get(); if (widget.certificate != null) { - _selectedExpiryDate = widget.certificate!.expiryDate; + _selectedExpiryDate = widget.certificate!.expiresAt; _issuerController.text = widget.certificate!.issuer ?? ''; _numberController.text = widget.certificate!.certificateNumber ?? ''; _nameController.text = widget.certificate!.name; - _selectedType = widget.certificate!.certificationType; + _selectedType = widget.certificate!.certificateType; } else { - _selectedType = ComplianceType.other; + _selectedType = 'OTHER'; } } @@ -141,7 +141,7 @@ class _CertificateUploadPageState extends State { ); if (confirmed == true && mounted) { - await cubit.deleteCertificate(widget.certificate!.certificationType); + await cubit.deleteCertificate(widget.certificate!.certificateId); } } @@ -149,7 +149,7 @@ class _CertificateUploadPageState extends State { Widget build(BuildContext context) { return BlocProvider.value( value: _cubit..setSelectedFilePath( - widget.certificate?.certificateUrl, + widget.certificate?.fileUri, ), child: BlocConsumer( listener: (BuildContext context, CertificateUploadState state) { @@ -231,7 +231,7 @@ class _CertificateUploadPageState extends State { BlocProvider.of(context) .uploadCertificate( UploadCertificateParams( - certificationType: _selectedType!, + certificateType: _selectedType, name: _nameController.text, filePath: state.selectedFilePath!, expiryDate: _selectedExpiryDate, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_card.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_card.dart index 313a848e..40b733d7 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_card.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_card.dart @@ -19,28 +19,19 @@ class CertificateCard extends StatelessWidget { @override Widget build(BuildContext context) { // Determine UI state from certificate - final bool isComplete = - certificate.validationStatus == - StaffCertificateValidationStatus.approved; - final bool isExpiring = - certificate.status == StaffCertificateStatus.expiring || - certificate.status == StaffCertificateStatus.expiringSoon; - final bool isExpired = certificate.status == StaffCertificateStatus.expired; + final bool isVerified = certificate.status == CertificateStatus.verified; + final bool isExpired = certificate.status == CertificateStatus.expired || + certificate.isExpired; + final bool isPending = certificate.status == CertificateStatus.pending; + final bool isNotStarted = certificate.fileUri == null || + certificate.status == CertificateStatus.rejected; - // Override isComplete if expiring or expired - final bool showComplete = isComplete && !isExpired && !isExpiring; - - final bool isPending = - certificate.validationStatus == - StaffCertificateValidationStatus.pendingExpertReview; - final bool isNotStarted = - certificate.status == StaffCertificateStatus.notStarted || - certificate.validationStatus == - StaffCertificateValidationStatus.rejected; + // Show verified badge only if not expired + final bool showComplete = isVerified && !isExpired; // UI Properties helper final _CertificateUiProps uiProps = _getUiProps( - certificate.certificationType, + certificate.certificateType, ); return GestureDetector( @@ -55,7 +46,7 @@ class CertificateCard extends StatelessWidget { clipBehavior: Clip.hardEdge, child: Column( children: [ - if (isExpiring || isExpired) + if (isExpired) Container( padding: const EdgeInsets.symmetric( horizontal: UiConstants.space4, @@ -78,11 +69,7 @@ class CertificateCard extends StatelessWidget { ), const SizedBox(width: UiConstants.space2), Text( - isExpired - ? t.staff_certificates.card.expired - : t.staff_certificates.card.expires_in_days( - days: _daysUntilExpiry(certificate.expiryDate), - ), + t.staff_certificates.card.expired, style: UiTypography.body3m.textPrimary, ), ], @@ -151,7 +138,7 @@ class CertificateCard extends StatelessWidget { ), const SizedBox(height: 2), Text( - certificate.description ?? '', + certificate.certificateType, style: UiTypography.body3r.textSecondary, ), if (showComplete) ...[ @@ -159,17 +146,15 @@ class CertificateCard extends StatelessWidget { _buildMiniStatus( t.staff_certificates.card.verified, UiColors.primary, - certificate.expiryDate, + certificate.expiresAt, ), ], - if (isExpiring || isExpired) ...[ + if (isExpired) ...[ const SizedBox(height: UiConstants.space2), _buildMiniStatus( - isExpired - ? t.staff_certificates.card.expired - : t.staff_certificates.card.expiring_soon, - isExpired ? UiColors.destructive : UiColors.primary, - certificate.expiryDate, + t.staff_certificates.card.expired, + UiColors.destructive, + certificate.expiresAt, ), ], if (isNotStarted) ...[ @@ -220,18 +205,14 @@ class CertificateCard extends StatelessWidget { ); } - int _daysUntilExpiry(DateTime? expiry) { - if (expiry == null) return 0; - return expiry.difference(DateTime.now()).inDays; - } - - _CertificateUiProps _getUiProps(ComplianceType type) { - switch (type) { - case ComplianceType.backgroundCheck: + _CertificateUiProps _getUiProps(String type) { + switch (type.toUpperCase()) { + case 'BACKGROUND_CHECK': return _CertificateUiProps(UiIcons.fileCheck, UiColors.primary); - case ComplianceType.foodHandler: + case 'FOOD_HYGIENE': + case 'FOOD_HANDLER': return _CertificateUiProps(UiIcons.utensils, UiColors.primary); - case ComplianceType.rbs: + case 'RBS': return _CertificateUiProps(UiIcons.wine, UiColors.foreground); default: return _CertificateUiProps(UiIcons.award, UiColors.primary); diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/staff_certificates_module.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/staff_certificates_module.dart index 8865b89a..d632bebf 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/staff_certificates_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/staff_certificates_module.dart @@ -3,28 +3,38 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'data/repositories_impl/certificates_repository_impl.dart'; -import 'domain/repositories/certificates_repository.dart'; -import 'domain/usecases/get_certificates_usecase.dart'; -import 'domain/usecases/delete_certificate_usecase.dart'; -import 'domain/usecases/upsert_certificate_usecase.dart'; -import 'domain/usecases/upload_certificate_usecase.dart'; -import 'presentation/blocs/certificates/certificates_cubit.dart'; -import 'presentation/blocs/certificate_upload/certificate_upload_cubit.dart'; -import 'presentation/pages/certificate_upload_page.dart'; -import 'presentation/pages/certificates_page.dart'; +import 'package:staff_certificates/src/data/repositories_impl/certificates_repository_impl.dart'; +import 'package:staff_certificates/src/domain/repositories/certificates_repository.dart'; +import 'package:staff_certificates/src/domain/usecases/get_certificates_usecase.dart'; +import 'package:staff_certificates/src/domain/usecases/delete_certificate_usecase.dart'; +import 'package:staff_certificates/src/domain/usecases/upload_certificate_usecase.dart'; +import 'package:staff_certificates/src/presentation/blocs/certificates/certificates_cubit.dart'; +import 'package:staff_certificates/src/presentation/blocs/certificate_upload/certificate_upload_cubit.dart'; +import 'package:staff_certificates/src/presentation/pages/certificate_upload_page.dart'; +import 'package:staff_certificates/src/presentation/pages/certificates_page.dart'; +/// Module for the Staff Certificates feature. +/// +/// Uses the V2 REST API via [BaseApiService] for backend access. class StaffCertificatesModule extends Module { @override List get imports => [CoreModule()]; @override void binds(Injector i) { - i.addLazySingleton(CertificatesRepositoryImpl.new); + i.addLazySingleton( + () => CertificatesRepositoryImpl( + apiService: i.get(), + uploadService: i.get(), + signedUrlService: i.get(), + verificationService: i.get(), + ), + ); i.addLazySingleton(GetCertificatesUseCase.new); - i.addLazySingleton(DeleteCertificateUseCase.new); - i.addLazySingleton(UpsertCertificateUseCase.new); - i.addLazySingleton(UploadCertificateUseCase.new); + i.addLazySingleton( + DeleteCertificateUseCase.new); + i.addLazySingleton( + UploadCertificateUseCase.new); i.addLazySingleton(CertificatesCubit.new); i.add(CertificateUploadCubit.new); } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/pubspec.yaml index 05fd996d..906c4294 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/pubspec.yaml +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/pubspec.yaml @@ -13,9 +13,8 @@ dependencies: flutter_bloc: ^8.1.0 equatable: ^2.0.5 intl: ^0.20.0 - get_it: ^7.6.0 flutter_modular: ^6.3.0 - + # KROW Dependencies design_system: path: ../../../../../design_system @@ -25,10 +24,6 @@ dependencies: path: ../../../../../domain krow_core: path: ../../../../../core - krow_data_connect: - path: ../../../../../data_connect - firebase_auth: ^6.1.2 - firebase_data_connect: ^0.2.2 dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart index 5c0efd76..5c8ea9d2 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart @@ -1,105 +1,80 @@ -// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports, unused_element, unused_field, duplicate_ignore import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; -import 'package:krow_domain/krow_domain.dart' as domain; +import 'package:krow_domain/krow_domain.dart'; -import '../../domain/repositories/documents_repository.dart'; +import 'package:staff_documents/src/domain/repositories/documents_repository.dart'; -/// Implementation of [DocumentsRepository] using Data Connect. +/// Implementation of [DocumentsRepository] using the V2 API for reads +/// and core services for uploads/verification. +/// +/// Replaces the previous Firebase Data Connect implementation. class DocumentsRepositoryImpl implements DocumentsRepository { + /// Creates a [DocumentsRepositoryImpl]. DocumentsRepositoryImpl({ + required BaseApiService apiService, required FileUploadService uploadService, required SignedUrlService signedUrlService, required VerificationService verificationService, - }) : _service = DataConnectService.instance, - _uploadService = uploadService, - _signedUrlService = signedUrlService, - _verificationService = verificationService; + }) : _api = apiService, + _uploadService = uploadService, + _signedUrlService = signedUrlService, + _verificationService = verificationService; - final DataConnectService _service; + final BaseApiService _api; final FileUploadService _uploadService; final SignedUrlService _signedUrlService; final VerificationService _verificationService; @override - Future> getDocuments() async { - return _service.getStaffRepository().getStaffDocuments(); + Future> getDocuments() async { + final ApiResponse response = + await _api.get(V2ApiEndpoints.staffDocuments); + final List items = response.data['documents'] as List; + return items + .map((dynamic json) => + ProfileDocument.fromJson(json as Map)) + .toList(); } @override - Future uploadDocument( + Future uploadDocument( String documentId, String filePath, ) async { - return _service.run(() async { - // 1. Upload the file to cloud storage - final FileUploadResponse uploadRes = await _uploadService.uploadFile( - filePath: filePath, - fileName: 'staff_document_${DateTime.now().millisecondsSinceEpoch}.pdf', - visibility: domain.FileVisibility.private, - ); + // 1. Upload the file to cloud storage + final FileUploadResponse uploadRes = await _uploadService.uploadFile( + filePath: filePath, + fileName: + 'staff_document_${DateTime.now().millisecondsSinceEpoch}.pdf', + visibility: FileVisibility.private, + ); - // 2. Generate a signed URL for verification service to access the file - final SignedUrlResponse signedUrlRes = await _signedUrlService - .createSignedUrl(fileUri: uploadRes.fileUri); + // 2. Generate a signed URL + final SignedUrlResponse signedUrlRes = + await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri); - // 3. Initiate verification - final List allDocs = await getDocuments(); - final domain.StaffDocument currentDoc = allDocs.firstWhere( - (domain.StaffDocument d) => d.documentId == documentId, - ); - final String description = (currentDoc.description ?? '').toLowerCase(); + // 3. Initiate verification + final VerificationResponse verificationRes = + await _verificationService.createVerification( + fileUri: uploadRes.fileUri, + type: 'government_id', + subjectType: 'worker', + subjectId: documentId, + rules: {'documentId': documentId}, + ); - final String staffId = await _service.getStaffId(); - final VerificationResponse verificationRes = await _verificationService - .createVerification( - fileUri: uploadRes.fileUri, - type: 'government_id', - subjectType: 'worker', - subjectId: staffId, - rules: { - 'documentDescription': currentDoc.description, - }, - ); + // 4. Submit upload result to V2 API + await _api.put( + V2ApiEndpoints.staffDocumentUpload(documentId), + data: { + 'fileUri': signedUrlRes.signedUrl, + 'verificationId': verificationRes.verificationId, + }, + ); - // 4. Update/Create StaffDocument in Data Connect - await _service.getStaffRepository().upsertStaffDocument( - documentId: documentId, - documentUrl: signedUrlRes.signedUrl, - status: domain.DocumentStatus.pending, - verificationId: verificationRes.verificationId, - ); - - // 5. Return the updated document state - final List documents = await getDocuments(); - return documents.firstWhere( - (domain.StaffDocument d) => d.documentId == documentId, - ); - }); - } - - domain.DocumentStatus _mapStatus(EnumValue status) { - if (status is Known) { - switch (status.value) { - case DocumentStatus.VERIFIED: - case DocumentStatus.AUTO_PASS: - case DocumentStatus.APPROVED: - return domain.DocumentStatus.verified; - case DocumentStatus.PENDING: - case DocumentStatus.UPLOADED: - case DocumentStatus.PROCESSING: - case DocumentStatus.NEEDS_REVIEW: - case DocumentStatus.EXPIRING: - return domain.DocumentStatus.pending; - case DocumentStatus.MISSING: - return domain.DocumentStatus.missing; - case DocumentStatus.AUTO_FAIL: - case DocumentStatus.REJECTED: - case DocumentStatus.ERROR: - return domain.DocumentStatus.rejected; - } - } - // Default to pending for Unknown or unhandled cases - return domain.DocumentStatus.pending; + // 5. Return the updated document + final List documents = await getDocuments(); + return documents.firstWhere( + (ProfileDocument d) => d.documentId == documentId, + ); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/repositories/documents_repository.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/repositories/documents_repository.dart index 2ecd3faf..85b0e53d 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/repositories/documents_repository.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/repositories/documents_repository.dart @@ -2,11 +2,12 @@ import 'package:krow_domain/krow_domain.dart'; /// Interface for the documents repository. /// -/// Responsible for fetching staff compliance documents. +/// Responsible for fetching and uploading staff compliance documents +/// via the V2 API. Uses [ProfileDocument] from the V2 domain. abstract interface class DocumentsRepository { /// Fetches the list of compliance documents for the current staff member. - Future> getDocuments(); + Future> getDocuments(); - /// Uploads a document for the current staff member. - Future uploadDocument(String documentId, String filePath); + /// Uploads a document file for the given [documentId]. + Future uploadDocument(String documentId, String filePath); } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/usecases/get_documents_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/usecases/get_documents_usecase.dart index 8b780f48..a566b31d 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/usecases/get_documents_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/usecases/get_documents_usecase.dart @@ -5,13 +5,13 @@ import '../repositories/documents_repository.dart'; /// Use case for fetching staff compliance documents. /// /// Delegates to [DocumentsRepository]. -class GetDocumentsUseCase implements NoInputUseCase> { +class GetDocumentsUseCase implements NoInputUseCase> { GetDocumentsUseCase(this._repository); final DocumentsRepository _repository; @override - Future> call() { + Future> call() { return _repository.getDocuments(); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/usecases/upload_document_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/usecases/upload_document_usecase.dart index 13dfa2f3..e2be3bb3 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/usecases/upload_document_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/usecases/upload_document_usecase.dart @@ -3,12 +3,12 @@ import 'package:krow_domain/krow_domain.dart'; import '../repositories/documents_repository.dart'; class UploadDocumentUseCase - extends UseCase { + extends UseCase { UploadDocumentUseCase(this._repository); final DocumentsRepository _repository; @override - Future call(UploadDocumentArguments arguments) { + Future call(UploadDocumentArguments arguments) { return _repository.uploadDocument(arguments.documentId, arguments.filePath); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_cubit.dart index 89cd8d86..6f77169a 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_cubit.dart @@ -33,7 +33,7 @@ class DocumentUploadCubit extends Cubit { emit(state.copyWith(status: DocumentUploadStatus.uploading)); try { - final StaffDocument updatedDoc = await _uploadDocumentUseCase( + final ProfileDocument updatedDoc = await _uploadDocumentUseCase( UploadDocumentArguments(documentId: documentId, filePath: filePath), ); diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_state.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_state.dart index eb92a3e0..3b615343 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_state.dart @@ -17,7 +17,7 @@ class DocumentUploadState extends Equatable { final bool isAttested; final String? selectedFilePath; final String? documentUrl; - final StaffDocument? updatedDocument; + final ProfileDocument? updatedDocument; final String? errorMessage; DocumentUploadState copyWith({ @@ -25,7 +25,7 @@ class DocumentUploadState extends Equatable { bool? isAttested, String? selectedFilePath, String? documentUrl, - StaffDocument? updatedDocument, + ProfileDocument? updatedDocument, String? errorMessage, }) { return DocumentUploadState( diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/documents/documents_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/documents/documents_cubit.dart index f0cccda8..75e0c735 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/documents/documents_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/documents/documents_cubit.dart @@ -15,7 +15,7 @@ class DocumentsCubit extends Cubit await handleError( emit: emit, action: () async { - final List documents = await _getDocumentsUseCase(); + final List documents = await _getDocumentsUseCase(); emit( state.copyWith( status: DocumentsStatus.success, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/documents/documents_state.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/documents/documents_state.dart index 27c8676d..18eed431 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/documents/documents_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/documents/documents_state.dart @@ -7,16 +7,16 @@ class DocumentsState extends Equatable { const DocumentsState({ this.status = DocumentsStatus.initial, - List? documents, + List? documents, this.errorMessage, - }) : documents = documents ?? const []; + }) : documents = documents ?? const []; final DocumentsStatus status; - final List documents; + final List documents; final String? errorMessage; DocumentsState copyWith({ DocumentsStatus? status, - List? documents, + List? documents, String? errorMessage, }) { return DocumentsState( @@ -27,7 +27,7 @@ class DocumentsState extends Equatable { } int get completedCount => - documents.where((StaffDocument d) => d.status == DocumentStatus.verified).length; + documents.where((ProfileDocument d) => d.status == ProfileDocumentStatus.verified).length; int get totalCount => documents.length; diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/document_upload_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/document_upload_page.dart index 13dbe8df..77b80fc7 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/document_upload_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/document_upload_page.dart @@ -25,7 +25,7 @@ class DocumentUploadPage extends StatelessWidget { }); /// The staff document descriptor for the item being uploaded. - final StaffDocument document; + final ProfileDocument document; /// Optional URL of an already-uploaded document. final String? initialUrl; @@ -62,7 +62,6 @@ class DocumentUploadPage extends StatelessWidget { return Scaffold( appBar: UiAppBar( title: document.name, - subtitle: document.description, onLeadingPressed: () => Modular.to.toDocuments(), ), body: SingleChildScrollView( diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart index 353a0f70..fc1e6efe 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart @@ -77,11 +77,11 @@ class DocumentsPage extends StatelessWidget { ), const SizedBox(height: UiConstants.space4), ...state.documents.map( - (StaffDocument doc) => DocumentCard( + (ProfileDocument doc) => DocumentCard( document: doc, onTap: () => Modular.to.toDocumentUpload( document: doc, - initialUrl: doc.documentUrl, + initialUrl: doc.fileUri, ), ), ), diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_card.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_card.dart index 46b06131..0bd74e64 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_card.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_card.dart @@ -11,7 +11,7 @@ class DocumentCard extends StatelessWidget { required this.document, this.onTap, }); - final StaffDocument document; + final ProfileDocument document; final VoidCallback? onTap; @override @@ -57,12 +57,6 @@ class DocumentCard extends StatelessWidget { _getStatusIcon(document.status), ], ), - const SizedBox(height: UiConstants.space1 / 2), - if (document.description != null) - Text( - document.description!, - style: UiTypography.body2r.textSecondary, - ), const SizedBox(height: UiConstants.space3), Row( children: [ @@ -79,15 +73,15 @@ class DocumentCard extends StatelessWidget { ); } - Widget _getStatusIcon(DocumentStatus status) { + Widget _getStatusIcon(ProfileDocumentStatus status) { switch (status) { - case DocumentStatus.verified: + case ProfileDocumentStatus.verified: return const Icon( UiIcons.check, color: UiColors.iconSuccess, size: 20, ); - case DocumentStatus.pending: + case ProfileDocumentStatus.pending: return const Icon( UiIcons.clock, color: UiColors.textWarning, @@ -102,37 +96,32 @@ class DocumentCard extends StatelessWidget { } } - Widget _buildStatusBadge(DocumentStatus status) { + Widget _buildStatusBadge(ProfileDocumentStatus status) { Color bg; Color text; String label; switch (status) { - case DocumentStatus.verified: + case ProfileDocumentStatus.verified: bg = UiColors.tagSuccess; text = UiColors.textSuccess; label = t.staff_documents.card.verified; - break; - case DocumentStatus.pending: + case ProfileDocumentStatus.pending: bg = UiColors.tagPending; text = UiColors.textWarning; label = t.staff_documents.card.pending; - break; - case DocumentStatus.missing: + case ProfileDocumentStatus.notUploaded: bg = UiColors.textError.withValues(alpha: 0.1); text = UiColors.textError; label = t.staff_documents.card.missing; - break; - case DocumentStatus.rejected: + case ProfileDocumentStatus.rejected: bg = UiColors.textError.withValues(alpha: 0.1); text = UiColors.textError; label = t.staff_documents.card.rejected; - break; - case DocumentStatus.expired: + case ProfileDocumentStatus.expired: bg = UiColors.textError.withValues(alpha: 0.1); text = UiColors.textError; - label = t.staff_documents.card.rejected; // Or define "Expired" string - break; + label = t.staff_documents.card.rejected; } return Container( @@ -150,8 +139,8 @@ class DocumentCard extends StatelessWidget { ); } - Widget _buildActionButton(DocumentStatus status) { - final bool isVerified = status == DocumentStatus.verified; + Widget _buildActionButton(ProfileDocumentStatus status) { + final bool isVerified = status == ProfileDocumentStatus.verified; return InkWell( onTap: onTap, borderRadius: UiConstants.radiusSm, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/staff_documents_module.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/staff_documents_module.dart index 130c6d19..e82b2576 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/staff_documents_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/staff_documents_module.dart @@ -1,15 +1,19 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'data/repositories_impl/documents_repository_impl.dart'; -import 'domain/repositories/documents_repository.dart'; -import 'domain/usecases/get_documents_usecase.dart'; -import 'domain/usecases/upload_document_usecase.dart'; -import 'presentation/blocs/documents/documents_cubit.dart'; -import 'presentation/blocs/document_upload/document_upload_cubit.dart'; -import 'presentation/pages/documents_page.dart'; -import 'presentation/pages/document_upload_page.dart'; +import 'package:staff_documents/src/data/repositories_impl/documents_repository_impl.dart'; +import 'package:staff_documents/src/domain/repositories/documents_repository.dart'; +import 'package:staff_documents/src/domain/usecases/get_documents_usecase.dart'; +import 'package:staff_documents/src/domain/usecases/upload_document_usecase.dart'; +import 'package:staff_documents/src/presentation/blocs/documents/documents_cubit.dart'; +import 'package:staff_documents/src/presentation/blocs/document_upload/document_upload_cubit.dart'; +import 'package:staff_documents/src/presentation/pages/documents_page.dart'; +import 'package:staff_documents/src/presentation/pages/document_upload_page.dart'; + +/// Module for the Staff Documents feature. +/// +/// Uses the V2 REST API via [BaseApiService] for backend access. class StaffDocumentsModule extends Module { @override List get imports => [CoreModule()]; @@ -18,6 +22,7 @@ class StaffDocumentsModule extends Module { void binds(Injector i) { i.addLazySingleton( () => DocumentsRepositoryImpl( + apiService: i.get(), uploadService: i.get(), signedUrlService: i.get(), verificationService: i.get(), @@ -39,7 +44,7 @@ class StaffDocumentsModule extends Module { r.child( StaffPaths.childRoute(StaffPaths.documents, StaffPaths.documentUpload), child: (_) => DocumentUploadPage( - document: r.args.data['document'] as StaffDocument, + document: r.args.data['document'] as ProfileDocument, initialUrl: r.args.data['initialUrl'] as String?, ), ); diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/pubspec.yaml index b099e9da..37dc61b3 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/pubspec.yaml +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/pubspec.yaml @@ -15,8 +15,7 @@ dependencies: bloc: ^8.1.0 flutter_modular: ^6.3.0 equatable: ^2.0.5 - firebase_auth: ^6.1.4 - firebase_data_connect: ^0.2.2+2 + # Architecture Packages design_system: path: ../../../../../design_system @@ -26,5 +25,3 @@ dependencies: path: ../../../../../core_localization krow_domain: path: ../../../../../domain - krow_data_connect: - path: ../../../../../data_connect diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/mappers/tax_form_mapper.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/mappers/tax_form_mapper.dart deleted file mode 100644 index 6c53978e..00000000 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/mappers/tax_form_mapper.dart +++ /dev/null @@ -1,87 +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'; - -class TaxFormMapper { - static TaxForm fromDataConnect(dc.GetTaxFormsByStaffIdTaxForms form) { - // Construct the legacy map for the entity - final Map formData = { - 'firstName': form.firstName, - 'lastName': form.lastName, - 'middleInitial': form.mInitial, - 'otherLastNames': form.oLastName, - 'dob': _formatDate(form.dob), - 'ssn': form.socialSN.toString(), - 'email': form.email, - 'phone': form.phone, - 'address': form.address, - 'aptNumber': form.apt, - 'city': form.city, - 'state': form.state, - 'zipCode': form.zipCode, - - // I-9 Fields - 'citizenshipStatus': form.citizen?.stringValue, - 'uscisNumber': form.uscis, - 'passportNumber': form.passportNumber, - 'countryIssuance': form.countryIssue, - 'preparerUsed': form.prepartorOrTranslator, - - // W-4 Fields - 'filingStatus': form.marital?.stringValue, - 'multipleJobs': form.multipleJob, - 'qualifyingChildren': form.childrens, - 'otherDependents': form.otherDeps, - 'otherIncome': form.otherInconme?.toString(), - 'deductions': form.deductions?.toString(), - 'extraWithholding': form.extraWithholding?.toString(), - - 'signature': form.signature, - }; - - String title = ''; - String subtitle = ''; - String description = ''; - - final dc.TaxFormType formType; - if (form.formType is dc.Known) { - formType = (form.formType as dc.Known).value; - } else { - formType = dc.TaxFormType.W4; - } - - if (formType == dc.TaxFormType.I9) { - title = 'Form I-9'; - subtitle = 'Employment Eligibility Verification'; - description = 'Required for all new hires to verify identity.'; - } else { - title = 'Form W-4'; - subtitle = 'Employee\'s Withholding Certificate'; - description = 'Determines federal income tax withholding.'; - } - - return TaxFormAdapter.fromPrimitives( - id: form.id, - type: form.formType.stringValue, - title: title, - subtitle: subtitle, - description: description, - status: form.status.stringValue, - staffId: form.staffId, - formData: formData, - updatedAt: form.updatedAt == null - ? null - : DateTimeUtils.toDeviceTime(form.updatedAt!.toDateTime()), - ); - } - - static String? _formatDate(Timestamp? timestamp) { - if (timestamp == null) return null; - - final DateTime date = - DateTimeUtils.toDeviceTime(timestamp.toDateTime()); - - return '${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}/${date.year}'; - } -} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/repositories/tax_forms_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/repositories/tax_forms_repository_impl.dart index 35aac207..ca1649a3 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/repositories/tax_forms_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/repositories/tax_forms_repository_impl.dart @@ -1,274 +1,44 @@ import 'dart:async'; -import 'package:firebase_data_connect/firebase_data_connect.dart'; -import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/repositories/tax_forms_repository.dart'; -import '../mappers/tax_form_mapper.dart'; +import 'package:staff_tax_forms/src/domain/repositories/tax_forms_repository.dart'; +/// Implementation of [TaxFormsRepository] using the V2 API. +/// +/// Replaces the previous Firebase Data Connect implementation. class TaxFormsRepositoryImpl implements TaxFormsRepository { - TaxFormsRepositoryImpl() : _service = dc.DataConnectService.instance; + /// Creates a [TaxFormsRepositoryImpl]. + TaxFormsRepositoryImpl({required BaseApiService apiService}) + : _api = apiService; - final dc.DataConnectService _service; + final BaseApiService _api; @override Future> getTaxForms() async { - return _service.run(() async { - final String staffId = await _service.getStaffId(); - final QueryResult< - dc.GetTaxFormsByStaffIdData, - dc.GetTaxFormsByStaffIdVariables - > - response = await _service.connector - .getTaxFormsByStaffId(staffId: staffId) - .execute(); - - final List forms = response.data.taxForms - .map(TaxFormMapper.fromDataConnect) - .toList(); - - // Check if required forms exist, create if not. - final Set typesPresent = forms - .map((TaxForm f) => f.type) - .toSet(); - bool createdNew = false; - - if (!typesPresent.contains(TaxFormType.i9)) { - await _createInitialForm(staffId, TaxFormType.i9); - createdNew = true; - } - if (!typesPresent.contains(TaxFormType.w4)) { - await _createInitialForm(staffId, TaxFormType.w4); - createdNew = true; - } - - if (createdNew) { - final QueryResult< - dc.GetTaxFormsByStaffIdData, - dc.GetTaxFormsByStaffIdVariables - > - response2 = await _service.connector - .getTaxFormsByStaffId(staffId: staffId) - .execute(); - return response2.data.taxForms - .map(TaxFormMapper.fromDataConnect) - .toList(); - } - - return forms; - }); - } - - Future _createInitialForm(String staffId, TaxFormType type) async { - await _service.connector - .createTaxForm( - staffId: staffId, - formType: dc.TaxFormType.values.byName( - TaxFormAdapter.typeToString(type), - ), - firstName: '', - lastName: '', - socialSN: 0, - address: '', - status: dc.TaxFormStatus.NOT_STARTED, - ) - .execute(); + final ApiResponse response = + await _api.get(V2ApiEndpoints.staffTaxForms); + final List items = response.data['taxForms'] as List; + return items + .map((dynamic json) => + TaxForm.fromJson(json as Map)) + .toList(); } @override - Future updateI9Form(I9TaxForm form) async { - return _service.run(() async { - final Map data = form.formData; - final dc.UpdateTaxFormVariablesBuilder builder = _service.connector - .updateTaxForm(id: form.id); - _mapCommonFields(builder, data); - _mapI9Fields(builder, data); - await builder.execute(); - }); + Future updateTaxForm(TaxForm form) async { + await _api.put( + V2ApiEndpoints.staffTaxFormUpdate(form.formType), + data: form.toJson(), + ); } @override - Future submitI9Form(I9TaxForm form) async { - return _service.run(() async { - final Map data = form.formData; - final dc.UpdateTaxFormVariablesBuilder builder = _service.connector - .updateTaxForm(id: form.id); - _mapCommonFields(builder, data); - _mapI9Fields(builder, data); - await builder.status(dc.TaxFormStatus.SUBMITTED).execute(); - }); - } - - @override - Future updateW4Form(W4TaxForm form) async { - return _service.run(() async { - final Map data = form.formData; - final dc.UpdateTaxFormVariablesBuilder builder = _service.connector - .updateTaxForm(id: form.id); - _mapCommonFields(builder, data); - _mapW4Fields(builder, data); - await builder.execute(); - }); - } - - @override - Future submitW4Form(W4TaxForm form) async { - return _service.run(() async { - final Map data = form.formData; - final dc.UpdateTaxFormVariablesBuilder builder = _service.connector - .updateTaxForm(id: form.id); - _mapCommonFields(builder, data); - _mapW4Fields(builder, data); - await builder.status(dc.TaxFormStatus.SUBMITTED).execute(); - }); - } - - void _mapCommonFields( - dc.UpdateTaxFormVariablesBuilder builder, - Map data, - ) { - if (data.containsKey('firstName')) { - builder.firstName(data['firstName'] as String?); - } - if (data.containsKey('lastName')) { - builder.lastName(data['lastName'] as String?); - } - if (data.containsKey('middleInitial')) { - builder.mInitial(data['middleInitial'] as String?); - } - if (data.containsKey('otherLastNames')) { - builder.oLastName(data['otherLastNames'] as String?); - } - if (data.containsKey('dob')) { - final String dob = data['dob'] as String; - // Handle both ISO string and MM/dd/yyyy manual entry - DateTime? date; - try { - date = DateTime.parse(dob); - } catch (_) { - try { - // Fallback minimal parse for mm/dd/yyyy - final List parts = dob.split('/'); - if (parts.length == 3) { - date = DateTime( - int.parse(parts[2]), - int.parse(parts[0]), - int.parse(parts[1]), - ); - } - } catch (_) {} - } - if (date != null) { - final int ms = date.millisecondsSinceEpoch; - final int seconds = (ms / 1000).floor(); - builder.dob(Timestamp(0, seconds)); - } - } - if (data.containsKey('ssn') && data['ssn']?.toString().isNotEmpty == true) { - builder.socialSN( - int.tryParse(data['ssn'].toString().replaceAll(RegExp(r'\D'), '')) ?? 0, - ); - } - if (data.containsKey('email')) builder.email(data['email'] as String?); - if (data.containsKey('phone')) builder.phone(data['phone'] as String?); - if (data.containsKey('address')) { - builder.address(data['address'] as String?); - } - if (data.containsKey('aptNumber')) { - builder.apt(data['aptNumber'] as String?); - } - if (data.containsKey('city')) builder.city(data['city'] as String?); - if (data.containsKey('state')) builder.state(data['state'] as String?); - if (data.containsKey('zipCode')) { - builder.zipCode(data['zipCode'] as String?); - } - } - - void _mapI9Fields( - dc.UpdateTaxFormVariablesBuilder builder, - Map data, - ) { - if (data.containsKey('citizenshipStatus')) { - final String status = data['citizenshipStatus'] as String; - // Map string to enum if possible, or handle otherwise. - // Generated enum: CITIZEN, NONCITIZEN_NATIONAL, PERMANENT_RESIDENT, ALIEN_AUTHORIZED - try { - builder.citizen( - dc.CitizenshipStatus.values.byName(status.toUpperCase()), - ); - } catch (_) {} - } - if (data.containsKey('uscisNumber')) { - builder.uscis(data['uscisNumber'] as String?); - } - if (data.containsKey('passportNumber')) { - builder.passportNumber(data['passportNumber'] as String?); - } - if (data.containsKey('countryIssuance')) { - builder.countryIssue(data['countryIssuance'] as String?); - } - if (data.containsKey('preparerUsed')) { - builder.prepartorOrTranslator(data['preparerUsed'] as bool?); - } - if (data.containsKey('signature')) { - builder.signature(data['signature'] as String?); - } - // Note: admissionNumber not in builder based on file read - } - - void _mapW4Fields( - dc.UpdateTaxFormVariablesBuilder builder, - Map data, - ) { - if (data.containsKey('cityStateZip')) { - final String csz = data['cityStateZip'] as String; - // Extremely basic split: City, State Zip - final List parts = csz.split(','); - if (parts.length >= 2) { - builder.city(parts[0].trim()); - final String stateZip = parts[1].trim(); - final List szParts = stateZip.split(' '); - if (szParts.isNotEmpty) builder.state(szParts[0]); - if (szParts.length > 1) builder.zipCode(szParts.last); - } - } - if (data.containsKey('filingStatus')) { - // MARITIAL_STATUS_SINGLE, MARITIAL_STATUS_MARRIED, MARITIAL_STATUS_HEAD - try { - final String status = data['filingStatus'] as String; - // Simple mapping assumptions: - if (status.contains('single')) { - builder.marital(dc.MaritalStatus.SINGLE); - } else if (status.contains('married')) { - builder.marital(dc.MaritalStatus.MARRIED); - } else if (status.contains('head')) { - builder.marital(dc.MaritalStatus.HEAD); - } - } catch (_) {} - } - if (data.containsKey('multipleJobs')) { - builder.multipleJob(data['multipleJobs'] as bool?); - } - if (data.containsKey('qualifyingChildren')) { - builder.childrens(data['qualifyingChildren'] as int?); - } - if (data.containsKey('otherDependents')) { - builder.otherDeps(data['otherDependents'] as int?); - } - if (data.containsKey('otherIncome')) { - builder.otherInconme(double.tryParse(data['otherIncome'].toString())); - } - if (data.containsKey('deductions')) { - builder.deductions(double.tryParse(data['deductions'].toString())); - } - if (data.containsKey('extraWithholding')) { - builder.extraWithholding( - double.tryParse(data['extraWithholding'].toString()), - ); - } - if (data.containsKey('signature')) { - builder.signature(data['signature'] as String?); - } + Future submitTaxForm(TaxForm form) async { + await _api.post( + V2ApiEndpoints.staffTaxFormSubmit(form.formType), + data: form.toJson(), + ); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/repositories/tax_forms_repository.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/repositories/tax_forms_repository.dart index 26f5b061..781b00f1 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/repositories/tax_forms_repository.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/repositories/tax_forms_repository.dart @@ -1,9 +1,15 @@ import 'package:krow_domain/krow_domain.dart'; +/// Repository interface for tax form operations. +/// +/// Uses [TaxForm] from the V2 domain layer. abstract class TaxFormsRepository { + /// Fetches the list of tax forms for the current staff member. Future> getTaxForms(); - Future updateI9Form(I9TaxForm form); - Future submitI9Form(I9TaxForm form); - Future updateW4Form(W4TaxForm form); - Future submitW4Form(W4TaxForm form); + + /// Updates a tax form's fields (partial save). + Future updateTaxForm(TaxForm form); + + /// Submits a tax form for review. + Future submitTaxForm(TaxForm form); } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/save_i9_form_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/save_i9_form_usecase.dart index ca8810d7..131db085 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/save_i9_form_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/save_i9_form_usecase.dart @@ -6,7 +6,7 @@ class SaveI9FormUseCase { SaveI9FormUseCase(this._repository); final TaxFormsRepository _repository; - Future call(I9TaxForm form) async { - return _repository.updateI9Form(form); + Future call(TaxForm form) async { + return _repository.updateTaxForm(form); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/save_w4_form_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/save_w4_form_usecase.dart index 06848894..cea57d4d 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/save_w4_form_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/save_w4_form_usecase.dart @@ -6,7 +6,7 @@ class SaveW4FormUseCase { SaveW4FormUseCase(this._repository); final TaxFormsRepository _repository; - Future call(W4TaxForm form) async { - return _repository.updateW4Form(form); + Future call(TaxForm form) async { + return _repository.updateTaxForm(form); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/submit_i9_form_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/submit_i9_form_usecase.dart index 240c7e05..972f408e 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/submit_i9_form_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/submit_i9_form_usecase.dart @@ -6,7 +6,7 @@ class SubmitI9FormUseCase { SubmitI9FormUseCase(this._repository); final TaxFormsRepository _repository; - Future call(I9TaxForm form) async { - return _repository.submitI9Form(form); + Future call(TaxForm form) async { + return _repository.submitTaxForm(form); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/submit_w4_form_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/submit_w4_form_usecase.dart index 7c92f441..9439ac65 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/submit_w4_form_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/domain/usecases/submit_w4_form_usecase.dart @@ -6,7 +6,7 @@ class SubmitW4FormUseCase { SubmitW4FormUseCase(this._repository); final TaxFormsRepository _repository; - Future call(W4TaxForm form) async { - return _repository.submitW4Form(form); + Future call(TaxForm form) async { + return _repository.submitTaxForm(form); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/i9/form_i9_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/i9/form_i9_cubit.dart index d9c7a8a6..c5f83c2f 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/i9/form_i9_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/i9/form_i9_cubit.dart @@ -1,7 +1,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:uuid/uuid.dart'; import '../../../domain/usecases/submit_i9_form_usecase.dart'; import 'form_i9_state.dart'; @@ -10,16 +9,16 @@ class FormI9Cubit extends Cubit with BlocErrorHandler FormI9Cubit(this._submitI9FormUseCase) : super(const FormI9State()); final SubmitI9FormUseCase _submitI9FormUseCase; - String _formId = ''; + String _documentId = ''; void initialize(TaxForm? form) { - if (form == null || form.formData.isEmpty) { + if (form == null || form.fields.isEmpty) { emit(const FormI9State()); // Reset to empty if no form return; } - final Map data = form.formData; - _formId = form.id; + final Map data = form.fields; + _documentId = form.documentId; emit( FormI9State( firstName: data['firstName'] as String? ?? '', @@ -122,10 +121,11 @@ class FormI9Cubit extends Cubit with BlocErrorHandler 'signature': state.signature, }; - final I9TaxForm form = I9TaxForm( - id: _formId.isNotEmpty ? _formId : const Uuid().v4(), - title: 'Form I-9', - formData: formData, + final TaxForm form = TaxForm( + documentId: _documentId, + formType: 'I-9', + status: TaxFormStatus.submitted, + fields: formData, ); await _submitI9FormUseCase(form); @@ -139,4 +139,3 @@ class FormI9Cubit extends Cubit with BlocErrorHandler ); } } - diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/w4/form_w4_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/w4/form_w4_cubit.dart index 52e29b8a..2b389a8c 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/w4/form_w4_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/blocs/w4/form_w4_cubit.dart @@ -1,7 +1,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:uuid/uuid.dart'; import '../../../domain/usecases/submit_w4_form_usecase.dart'; import 'form_w4_state.dart'; @@ -10,16 +9,16 @@ class FormW4Cubit extends Cubit with BlocErrorHandler FormW4Cubit(this._submitW4FormUseCase) : super(const FormW4State()); final SubmitW4FormUseCase _submitW4FormUseCase; - String _formId = ''; + String _documentId = ''; void initialize(TaxForm? form) { - if (form == null || form.formData.isEmpty) { + if (form == null || form.fields.isEmpty) { emit(const FormW4State()); // Reset return; } - final Map data = form.formData; - _formId = form.id; + final Map data = form.fields; + _documentId = form.documentId; // Combine address parts if needed, or take existing final String city = data['city'] as String? ?? ''; @@ -98,7 +97,7 @@ class FormW4Cubit extends Cubit with BlocErrorHandler 'ssn': state.ssn, 'address': state.address, 'cityStateZip': - state.cityStateZip, // Note: Repository should split this if needed. + state.cityStateZip, 'filingStatus': state.filingStatus, 'multipleJobs': state.multipleJobs, 'qualifyingChildren': state.qualifyingChildren, @@ -109,10 +108,11 @@ class FormW4Cubit extends Cubit with BlocErrorHandler 'signature': state.signature, }; - final W4TaxForm form = W4TaxForm( - id: _formId.isNotEmpty ? _formId : const Uuid().v4(), - title: 'Form W-4', - formData: formData, + final TaxForm form = TaxForm( + documentId: _documentId, + formType: 'W-4', + status: TaxFormStatus.submitted, + fields: formData, ); await _submitW4FormUseCase(form); @@ -126,4 +126,3 @@ class FormW4Cubit extends Cubit with BlocErrorHandler ); } } - diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart index edeb738a..c573a2b5 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart @@ -82,22 +82,15 @@ class TaxFormsPage extends StatelessWidget { return TaxFormCard( form: form, onTap: () async { - if (form is I9TaxForm) { - final Object? result = await Modular.to.pushNamed( - 'i9', - arguments: form, - ); - if (result == true && context.mounted) { - await BlocProvider.of(context).loadTaxForms(); - } - } else if (form is W4TaxForm) { - final Object? result = await Modular.to.pushNamed( - 'w4', - arguments: form, - ); - if (result == true && context.mounted) { - await BlocProvider.of(context).loadTaxForms(); - } + final bool isI9 = form.formType.toUpperCase().contains('I-9') || + form.formType.toUpperCase().contains('I9'); + final String route = isI9 ? 'i9' : 'w4'; + final Object? result = await Modular.to.pushNamed( + route, + arguments: form, + ); + if (result == true && context.mounted) { + await BlocProvider.of(context).loadTaxForms(); } }, ); diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_page/tax_form_card.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_page/tax_form_card.dart index 8f214404..21318727 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_page/tax_form_card.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_page/tax_form_card.dart @@ -14,7 +14,8 @@ class TaxFormCard extends StatelessWidget { @override Widget build(BuildContext context) { // Helper to get icon based on type - final String icon = form is I9TaxForm ? '🛂' : '📋'; + final bool isI9 = form.formType.toUpperCase().contains('I-9') || + form.formType.toUpperCase().contains('I9'); return GestureDetector( onTap: onTap, @@ -35,7 +36,13 @@ class TaxFormCard extends StatelessWidget { color: UiColors.primary.withValues(alpha: 0.1), borderRadius: UiConstants.radiusLg, ), - child: Center(child: Text(icon, style: UiTypography.headline1m)), + child: Center( + child: Icon( + isI9 ? UiIcons.fileCheck : UiIcons.file, + color: UiColors.primary, + size: 24, + ), + ), ), const SizedBox(width: UiConstants.space4), Expanded( @@ -46,7 +53,7 @@ class TaxFormCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - form.title, + 'Form ${form.formType}', style: UiTypography.headline4m.textPrimary, ), TaxFormStatusBadge(status: form.status), @@ -54,11 +61,9 @@ class TaxFormCard extends StatelessWidget { ), const SizedBox(height: UiConstants.space1), Text( - form.subtitle ?? '', - style: UiTypography.body3r.textSecondary, - ), - Text( - form.description ?? '', + isI9 + ? 'Employment Eligibility Verification' + : 'Employee Withholding Certificate', style: UiTypography.body3r.textSecondary, ), ], diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/staff_tax_forms_module.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/staff_tax_forms_module.dart index c26c007f..d49f54b9 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/staff_tax_forms_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/staff_tax_forms_module.dart @@ -1,22 +1,33 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'data/repositories/tax_forms_repository_impl.dart'; -import 'domain/repositories/tax_forms_repository.dart'; -import 'domain/usecases/get_tax_forms_usecase.dart'; -import 'domain/usecases/submit_i9_form_usecase.dart'; -import 'domain/usecases/submit_w4_form_usecase.dart'; -import 'presentation/blocs/i9/form_i9_cubit.dart'; -import 'presentation/blocs/tax_forms/tax_forms_cubit.dart'; -import 'presentation/blocs/w4/form_w4_cubit.dart'; -import 'presentation/pages/form_i9_page.dart'; -import 'presentation/pages/form_w4_page.dart'; -import 'presentation/pages/tax_forms_page.dart'; +import 'package:staff_tax_forms/src/data/repositories/tax_forms_repository_impl.dart'; +import 'package:staff_tax_forms/src/domain/repositories/tax_forms_repository.dart'; +import 'package:staff_tax_forms/src/domain/usecases/get_tax_forms_usecase.dart'; +import 'package:staff_tax_forms/src/domain/usecases/submit_i9_form_usecase.dart'; +import 'package:staff_tax_forms/src/domain/usecases/submit_w4_form_usecase.dart'; +import 'package:staff_tax_forms/src/presentation/blocs/tax_forms/tax_forms_cubit.dart'; +import 'package:staff_tax_forms/src/presentation/blocs/i9/form_i9_cubit.dart'; +import 'package:staff_tax_forms/src/presentation/blocs/w4/form_w4_cubit.dart'; +import 'package:staff_tax_forms/src/presentation/pages/form_i9_page.dart'; +import 'package:staff_tax_forms/src/presentation/pages/form_w4_page.dart'; +import 'package:staff_tax_forms/src/presentation/pages/tax_forms_page.dart'; + +/// Module for the Staff Tax Forms feature. +/// +/// Uses the V2 REST API via [BaseApiService] for backend access. class StaffTaxFormsModule extends Module { + @override + List get imports => [CoreModule()]; + @override void binds(Injector i) { - i.addLazySingleton(TaxFormsRepositoryImpl.new); + i.addLazySingleton( + () => TaxFormsRepositoryImpl( + apiService: i.get(), + ), + ); // Use Cases i.addLazySingleton(GetTaxFormsUseCase.new); diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/pubspec.yaml index e02bb3b5..5899f133 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/pubspec.yaml +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/pubspec.yaml @@ -15,9 +15,7 @@ dependencies: bloc: ^8.1.0 flutter_modular: ^6.3.0 equatable: ^2.0.5 - firebase_auth: ^6.1.4 - firebase_data_connect: ^0.2.2+2 - + # Architecture Packages design_system: path: ../../../../../design_system @@ -27,5 +25,3 @@ dependencies: path: ../../../../../core_localization krow_domain: path: ../../../../../domain - krow_data_connect: - path: ../../../../../data_connect diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/data/repositories/bank_account_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/data/repositories/bank_account_repository_impl.dart index b029f4ed..03aa491a 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/data/repositories/bank_account_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/data/repositories/bank_account_repository_impl.dart @@ -1,83 +1,34 @@ -import 'package:firebase_data_connect/firebase_data_connect.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/repositories/bank_account_repository.dart'; -/// Implementation of [BankAccountRepository] that integrates with Data Connect. +import 'package:staff_bank_account/src/domain/repositories/bank_account_repository.dart'; + +/// Implementation of [BankAccountRepository] using the V2 API. +/// +/// Replaces the previous Firebase Data Connect implementation. class BankAccountRepositoryImpl implements BankAccountRepository { /// Creates a [BankAccountRepositoryImpl]. - BankAccountRepositoryImpl({ - DataConnectService? service, - }) : _service = service ?? DataConnectService.instance; + BankAccountRepositoryImpl({required BaseApiService apiService}) + : _api = apiService; - /// The Data Connect service. - final DataConnectService _service; + final BaseApiService _api; @override - Future> getAccounts() async { - return _service.run(() async { - final String staffId = await _service.getStaffId(); - - final QueryResult - result = await _service.connector - .getAccountsByOwnerId(ownerId: staffId) - .execute(); - - return result.data.accounts.map((GetAccountsByOwnerIdAccounts account) { - return BankAccountAdapter.fromPrimitives( - id: account.id, - userId: account.ownerId, - bankName: account.bank, - accountNumber: account.accountNumber, - last4: account.last4, - sortCode: account.routeNumber, - type: account.type is Known - ? (account.type as Known).value.name - : null, - isPrimary: account.isPrimary, - ); - }).toList(); - }); + Future> getAccounts() async { + final ApiResponse response = + await _api.get(V2ApiEndpoints.staffBankAccounts); + final List items = response.data['accounts'] as List; + return items + .map((dynamic json) => + BankAccount.fromJson(json as Map)) + .toList(); } @override - Future addAccount(StaffBankAccount account) async { - return _service.run(() async { - final String staffId = await _service.getStaffId(); - - final QueryResult - existingAccounts = await _service.connector - .getAccountsByOwnerId(ownerId: staffId) - .execute(); - final bool hasAccounts = existingAccounts.data.accounts.isNotEmpty; - final bool isPrimary = !hasAccounts; - - await _service.connector - .createAccount( - bank: account.bankName, - type: AccountType.values - .byName(BankAccountAdapter.typeToString(account.type)), - last4: _safeLast4(account.last4, account.accountNumber), - ownerId: staffId, - ) - .isPrimary(isPrimary) - .accountNumber(account.accountNumber) - .routeNumber(account.sortCode) - .execute(); - }); - } - - /// Ensures we have a last4 value, either from input or derived from account number. - String _safeLast4(String? last4, String accountNumber) { - if (last4 != null && last4.isNotEmpty) { - return last4; - } - if (accountNumber.isEmpty) { - return '0000'; - } - if (accountNumber.length < 4) { - return accountNumber.padLeft(4, '0'); - } - return accountNumber.substring(accountNumber.length - 4); + Future addAccount(BankAccount account) async { + await _api.post( + V2ApiEndpoints.staffBankAccounts, + data: account.toJson(), + ); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/arguments/add_bank_account_params.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/arguments/add_bank_account_params.dart index c5795bb5..3a6aa13a 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/arguments/add_bank_account_params.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/arguments/add_bank_account_params.dart @@ -6,11 +6,11 @@ import 'package:krow_domain/krow_domain.dart'; class AddBankAccountParams extends UseCaseArgument with EquatableMixin { const AddBankAccountParams({required this.account}); - final StaffBankAccount account; + final BankAccount account; @override List get props => [account]; - + @override bool? get stringify => true; } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/repositories/bank_account_repository.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/repositories/bank_account_repository.dart index 51d72774..21e68e73 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/repositories/bank_account_repository.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/repositories/bank_account_repository.dart @@ -1,10 +1,12 @@ import 'package:krow_domain/krow_domain.dart'; /// Repository interface for managing bank accounts. +/// +/// Uses [BankAccount] from the V2 domain layer. abstract class BankAccountRepository { - /// Fetches the list of bank accounts for the current user. - Future> getAccounts(); + /// Fetches the list of bank accounts for the current staff member. + Future> getAccounts(); - /// adds a new bank account. - Future addAccount(StaffBankAccount account); + /// Adds a new bank account. + Future addAccount(BankAccount account); } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/get_bank_accounts_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/get_bank_accounts_usecase.dart index ec688bf3..50e55411 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/get_bank_accounts_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/get_bank_accounts_usecase.dart @@ -3,13 +3,13 @@ import 'package:krow_domain/krow_domain.dart'; import '../repositories/bank_account_repository.dart'; /// Use case to fetch bank accounts. -class GetBankAccountsUseCase implements NoInputUseCase> { +class GetBankAccountsUseCase implements NoInputUseCase> { GetBankAccountsUseCase(this._repository); final BankAccountRepository _repository; @override - Future> call() { + Future> call() { return _repository.getAccounts(); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_cubit.dart index 2fdf8b7e..70ee70ce 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_cubit.dart @@ -23,7 +23,7 @@ class BankAccountCubit extends Cubit await handleError( emit: emit, action: () async { - final List accounts = await _getBankAccountsUseCase(); + final List accounts = await _getBankAccountsUseCase(); emit( state.copyWith(status: BankAccountStatus.loaded, accounts: accounts), ); @@ -48,19 +48,17 @@ class BankAccountCubit extends Cubit emit(state.copyWith(status: BankAccountStatus.loading)); // Create domain entity - final StaffBankAccount newAccount = StaffBankAccount( - id: '', // Generated by server usually - userId: '', // Handled by Repo/Auth + final BankAccount newAccount = BankAccount( + accountId: '', // Generated by server bankName: bankName, - accountNumber: accountNumber.length > 4 + providerReference: routingNumber, + last4: accountNumber.length > 4 ? accountNumber.substring(accountNumber.length - 4) : accountNumber, - accountName: '', - sortCode: routingNumber, - type: type == 'CHECKING' - ? StaffBankAccountType.checking - : StaffBankAccountType.savings, isPrimary: false, + accountType: type == 'CHECKING' + ? AccountType.checking + : AccountType.savings, ); await handleError( diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_state.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_state.dart index 9a4c4661..29aebccf 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_state.dart @@ -7,18 +7,18 @@ class BankAccountState extends Equatable { const BankAccountState({ this.status = BankAccountStatus.initial, - this.accounts = const [], + this.accounts = const [], this.errorMessage, this.showForm = false, }); final BankAccountStatus status; - final List accounts; + final List accounts; final String? errorMessage; final bool showForm; BankAccountState copyWith({ BankAccountStatus? status, - List? accounts, + List? accounts, String? errorMessage, bool? showForm, }) { diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart index c7a8bd8b..c74495f7 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart @@ -90,7 +90,7 @@ class BankAccountPage extends StatelessWidget { ] else ...[ const SizedBox(height: UiConstants.space4), ...state.accounts.map( - (StaffBankAccount account) => + (BankAccount account) => AccountCard(account: account, strings: strings), ), ], diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/account_card.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/account_card.dart index bf9356c9..0c9fe05b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/account_card.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/account_card.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart'; class AccountCard extends StatelessWidget { - final StaffBankAccount account; + final BankAccount account; final dynamic strings; const AccountCard({ diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/staff_bank_account_module.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/staff_bank_account_module.dart index 93e7d69d..c2a5e3a6 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/staff_bank_account_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/staff_bank_account_module.dart @@ -1,28 +1,35 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_domain/krow_domain.dart'; + import 'package:staff_bank_account/src/data/repositories/bank_account_repository_impl.dart'; +import 'package:staff_bank_account/src/domain/repositories/bank_account_repository.dart'; +import 'package:staff_bank_account/src/domain/usecases/add_bank_account_usecase.dart'; +import 'package:staff_bank_account/src/domain/usecases/get_bank_accounts_usecase.dart'; +import 'package:staff_bank_account/src/presentation/blocs/bank_account_cubit.dart'; +import 'package:staff_bank_account/src/presentation/pages/bank_account_page.dart'; -import 'domain/repositories/bank_account_repository.dart'; -import 'domain/usecases/add_bank_account_usecase.dart'; -import 'domain/usecases/get_bank_accounts_usecase.dart'; -import 'presentation/blocs/bank_account_cubit.dart'; -import 'presentation/pages/bank_account_page.dart'; - +/// Module for the Staff Bank Account feature. +/// +/// Uses the V2 REST API via [BaseApiService] for backend access. class StaffBankAccountModule extends Module { @override - List get imports => [DataConnectModule()]; - + List get imports => [CoreModule()]; + @override void binds(Injector i) { // Repositories - i.addLazySingleton(BankAccountRepositoryImpl.new); - + i.addLazySingleton( + () => BankAccountRepositoryImpl( + apiService: i.get(), + ), + ); + // Use Cases i.addLazySingleton(GetBankAccountsUseCase.new); i.addLazySingleton(AddBankAccountUseCase.new); - + // Blocs i.add( () => BankAccountCubit( diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/pubspec.yaml index 17dade37..f4b018f5 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/pubspec.yaml +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/pubspec.yaml @@ -15,9 +15,7 @@ dependencies: bloc: ^8.1.0 flutter_modular: ^6.3.0 equatable: ^2.0.5 - firebase_auth: ^6.1.4 - firebase_data_connect: ^0.2.2+2 - + # Architecture Packages design_system: path: ../../../../../design_system @@ -27,8 +25,6 @@ dependencies: path: ../../../../../core krow_domain: path: ../../../../../domain - krow_data_connect: - path: ../../../../../data_connect dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/data/repositories_impl/time_card_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/data/repositories_impl/time_card_repository_impl.dart index aa738d0c..5640aea7 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/data/repositories_impl/time_card_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/data/repositories_impl/time_card_repository_impl.dart @@ -1,74 +1,31 @@ -import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; -import 'package:intl/intl.dart'; -import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -// ignore: implementation_imports -import 'package:krow_domain/src/adapters/financial/time_card_adapter.dart'; -import '../../domain/repositories/time_card_repository.dart'; -/// Implementation of [TimeCardRepository] using Firebase Data Connect. +import 'package:staff_time_card/src/domain/repositories/time_card_repository.dart'; + +/// Implementation of [TimeCardRepository] using the V2 API. +/// +/// Replaces the previous Firebase Data Connect implementation. class TimeCardRepositoryImpl implements TimeCardRepository { - /// Creates a [TimeCardRepositoryImpl]. - TimeCardRepositoryImpl({dc.DataConnectService? service}) - : _service = service ?? dc.DataConnectService.instance; - final dc.DataConnectService _service; + TimeCardRepositoryImpl({required BaseApiService apiService}) + : _api = apiService; + + final BaseApiService _api; @override - Future> getTimeCards(DateTime month) async { - return _service.run(() async { - final String staffId = await _service.getStaffId(); - // Fetch applications. Limit can be adjusted, assuming 100 is safe for now. - final fdc.QueryResult result = - await _service.connector - .getApplicationsByStaffId(staffId: staffId) - .limit(100) - .execute(); - - return result.data.applications - .where((dc.GetApplicationsByStaffIdApplications app) { - final DateTime? shiftDate = _service.toDateTime(app.shift.date); - if (shiftDate == null) return false; - return shiftDate.year == month.year && - shiftDate.month == month.month; - }) - .map((dc.GetApplicationsByStaffIdApplications app) { - final DateTime shiftDate = _service.toDateTime(app.shift.date)!; - final String startTime = _formatTime(app.checkInTime) ?? - _formatTime(app.shift.startTime) ?? - ''; - final String endTime = _formatTime(app.checkOutTime) ?? - _formatTime(app.shift.endTime) ?? - ''; - - // Prefer shiftRole values for pay/hours - final double hours = app.shiftRole.hours ?? 0.0; - final double rate = app.shiftRole.role.costPerHour; - final double pay = app.shiftRole.totalValue ?? 0.0; - - return TimeCardAdapter.fromPrimitives( - id: app.id, - shiftTitle: app.shift.title, - clientName: app.shift.order.business.businessName, - date: shiftDate, - startTime: startTime, - endTime: endTime, - totalHours: hours, - hourlyRate: rate, - totalPay: pay, - status: app.status.stringValue, - location: app.shift.location, - ); - }) - .toList(); - }); - } - - String? _formatTime(fdc.Timestamp? timestamp) { - if (timestamp == null) return null; - final DateTime? dt = _service.toDateTime(timestamp); - if (dt == null) return null; - return DateFormat('HH:mm').format(dt); + Future> getTimeCards(DateTime month) async { + final ApiResponse response = await _api.get( + V2ApiEndpoints.staffTimeCard, + params: { + 'year': month.year, + 'month': month.month, + }, + ); + final List items = response.data['entries'] as List; + return items + .map((dynamic json) => + TimeCardEntry.fromJson(json as Map)) + .toList(); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/domain/repositories/time_card_repository.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/domain/repositories/time_card_repository.dart index c44f86e4..c7931d5a 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/domain/repositories/time_card_repository.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/domain/repositories/time_card_repository.dart @@ -2,11 +2,10 @@ import 'package:krow_domain/krow_domain.dart'; /// Repository interface for accessing time card data. /// -/// This repository handles fetching time cards and related financial data -/// for the staff member. +/// Uses [TimeCardEntry] from the V2 domain layer. abstract class TimeCardRepository { - /// Retrieves a list of [TimeCard]s for a specific month. + /// Retrieves a list of [TimeCardEntry]s for a specific month. /// /// [month] is a [DateTime] representing the month to filter by. - Future> getTimeCards(DateTime month); + Future> getTimeCards(DateTime month); } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/domain/usecases/get_time_cards_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/domain/usecases/get_time_cards_usecase.dart index c969c80e..15baccb9 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/domain/usecases/get_time_cards_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/domain/usecases/get_time_cards_usecase.dart @@ -1,19 +1,22 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../arguments/get_time_cards_arguments.dart'; -import '../repositories/time_card_repository.dart'; -/// UseCase to retrieve time cards for a given month. -class GetTimeCardsUseCase extends UseCase> { +import 'package:staff_time_card/src/domain/arguments/get_time_cards_arguments.dart'; +import 'package:staff_time_card/src/domain/repositories/time_card_repository.dart'; +/// UseCase to retrieve time card entries for a given month. +/// +/// Uses [TimeCardEntry] from the V2 domain layer. +class GetTimeCardsUseCase + extends UseCase> { + /// Creates a [GetTimeCardsUseCase]. GetTimeCardsUseCase(this.repository); + + /// The time card repository. final TimeCardRepository repository; - /// Executes the use case. - /// - /// Returns a list of [TimeCard]s for the specified month in [arguments]. @override - Future> call(GetTimeCardsArguments arguments) { + Future> call(GetTimeCardsArguments arguments) { return repository.getTimeCards(arguments.month); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/blocs/time_card_bloc.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/blocs/time_card_bloc.dart index a605a52c..023443fd 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/blocs/time_card_bloc.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/blocs/time_card_bloc.dart @@ -2,20 +2,25 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/arguments/get_time_cards_arguments.dart'; -import '../../domain/usecases/get_time_cards_usecase.dart'; + +import 'package:staff_time_card/src/domain/arguments/get_time_cards_arguments.dart'; +import 'package:staff_time_card/src/domain/usecases/get_time_cards_usecase.dart'; part 'time_card_event.dart'; part 'time_card_state.dart'; /// BLoC to manage Time Card state. +/// +/// Uses V2 API [TimeCardEntry] entities. class TimeCardBloc extends Bloc with BlocErrorHandler { - + /// Creates a [TimeCardBloc]. TimeCardBloc({required this.getTimeCards}) : super(TimeCardInitial()) { on(_onLoadTimeCards); on(_onChangeMonth); } + + /// The use case for fetching time card entries. final GetTimeCardsUseCase getTimeCards; /// Handles fetching time cards for the requested month. @@ -27,17 +32,17 @@ class TimeCardBloc extends Bloc await handleError( emit: emit.call, action: () async { - final List cards = await getTimeCards( + final List cards = await getTimeCards( GetTimeCardsArguments(event.month), ); final double totalHours = cards.fold( 0.0, - (double sum, TimeCard t) => sum + t.totalHours, + (double sum, TimeCardEntry t) => sum + t.minutesWorked / 60.0, ); final double totalEarnings = cards.fold( 0.0, - (double sum, TimeCard t) => sum + t.totalPay, + (double sum, TimeCardEntry t) => sum + t.totalPayCents / 100.0, ); emit( @@ -53,6 +58,7 @@ class TimeCardBloc extends Bloc ); } + /// Handles changing the selected month. Future _onChangeMonth( ChangeMonth event, Emitter emit, @@ -60,4 +66,3 @@ class TimeCardBloc extends Bloc add(LoadTimeCards(event.month)); } } - diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/blocs/time_card_state.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/blocs/time_card_state.dart index fc89f303..e84f055e 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/blocs/time_card_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/blocs/time_card_state.dart @@ -1,32 +1,54 @@ part of 'time_card_bloc.dart'; +/// Base class for time card states. abstract class TimeCardState extends Equatable { + /// Creates a [TimeCardState]. const TimeCardState(); @override List get props => []; } +/// Initial state before any data is loaded. class TimeCardInitial extends TimeCardState {} -class TimeCardLoading extends TimeCardState {} -class TimeCardLoaded extends TimeCardState { +/// Loading state while data is being fetched. +class TimeCardLoading extends TimeCardState {} + +/// Loaded state with time card entries and computed totals. +class TimeCardLoaded extends TimeCardState { + /// Creates a [TimeCardLoaded]. const TimeCardLoaded({ required this.timeCards, required this.selectedMonth, required this.totalHours, required this.totalEarnings, }); - final List timeCards; + + /// The list of time card entries for the selected month. + final List timeCards; + + /// The currently selected month. final DateTime selectedMonth; + + /// Total hours worked in the selected month. final double totalHours; + + /// Total earnings in the selected month (in dollars). final double totalEarnings; @override - List get props => [timeCards, selectedMonth, totalHours, totalEarnings]; + List get props => + [timeCards, selectedMonth, totalHours, totalEarnings]; } + +/// Error state when loading fails. class TimeCardError extends TimeCardState { + /// Creates a [TimeCardError]. const TimeCardError(this.message); + + /// The error message. final String message; + @override List get props => [message]; } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/shift_history_list.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/shift_history_list.dart index b3679f3f..4d9ffd0b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/shift_history_list.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/shift_history_list.dart @@ -8,7 +8,7 @@ import 'timesheet_card.dart'; class ShiftHistoryList extends StatelessWidget { const ShiftHistoryList({super.key, required this.timesheets}); - final List timesheets; + final List timesheets; @override Widget build(BuildContext context) { @@ -39,7 +39,7 @@ class ShiftHistoryList extends StatelessWidget { ), ) else - ...timesheets.map((TimeCard ts) => TimesheetCard(timesheet: ts)), + ...timesheets.map((TimeCardEntry ts) => TimesheetCard(timesheet: ts)), ], ); } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/timesheet_card.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/timesheet_card.dart index 5e0ebc33..9248f9db 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/timesheet_card.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/timesheet_card.dart @@ -8,39 +8,16 @@ import 'package:krow_domain/krow_domain.dart'; class TimesheetCard extends StatelessWidget { const TimesheetCard({super.key, required this.timesheet}); - final TimeCard timesheet; + final TimeCardEntry timesheet; @override Widget build(BuildContext context) { - final TimeCardStatus status = timesheet.status; - Color statusBg; - Color statusColor; - String statusText; - - switch (status) { - case TimeCardStatus.approved: - statusBg = UiColors.tagSuccess; - statusColor = UiColors.textSuccess; - statusText = t.staff_time_card.status.approved; - break; - case TimeCardStatus.disputed: - statusBg = UiColors.destructive.withValues(alpha: 0.12); - statusColor = UiColors.destructive; - statusText = t.staff_time_card.status.disputed; - break; - case TimeCardStatus.paid: - statusBg = UiColors.primary.withValues(alpha: 0.12); - statusColor = UiColors.primary; - statusText = t.staff_time_card.status.paid; - break; - case TimeCardStatus.pending: - statusBg = UiColors.tagPending; - statusColor = UiColors.textWarning; - statusText = t.staff_time_card.status.pending; - break; - } - final String dateStr = DateFormat('EEE, MMM d').format(timesheet.date); + final double totalHours = timesheet.minutesWorked / 60.0; + final double totalPay = timesheet.totalPayCents / 100.0; + final double hourlyRate = timesheet.hourlyRateCents != null + ? timesheet.hourlyRateCents! / 100.0 + : 0.0; return Container( margin: const EdgeInsets.only(bottom: UiConstants.space3), @@ -56,33 +33,20 @@ class TimesheetCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - timesheet.shiftTitle, - style: UiTypography.body1m.textPrimary, - ), - Text( - timesheet.clientName, - style: UiTypography.body2r.textSecondary, - ), - ], - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space2, - vertical: UiConstants.space1, - ), - decoration: BoxDecoration( - color: statusBg, - borderRadius: UiConstants.radiusFull, - ), - child: Text( - statusText, - style: UiTypography.titleUppercase4b.copyWith( - color: statusColor, - ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + timesheet.shiftName, + style: UiTypography.body1m.textPrimary, + ), + if (timesheet.location != null) + Text( + timesheet.location!, + style: UiTypography.body2r.textSecondary, + ), + ], ), ), ], @@ -93,10 +57,11 @@ class TimesheetCard extends StatelessWidget { runSpacing: UiConstants.space1, children: [ _IconText(icon: UiIcons.calendar, text: dateStr), - _IconText( - icon: UiIcons.clock, - text: '${_formatTime(timesheet.startTime)} - ${_formatTime(timesheet.endTime)}', - ), + if (timesheet.clockInAt != null && timesheet.clockOutAt != null) + _IconText( + icon: UiIcons.clock, + text: '${DateFormat('h:mm a').format(timesheet.clockInAt!)} - ${DateFormat('h:mm a').format(timesheet.clockOutAt!)}', + ), if (timesheet.location != null) _IconText(icon: UiIcons.mapPin, text: timesheet.location!), ], @@ -111,11 +76,11 @@ class TimesheetCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - '${timesheet.totalHours.toStringAsFixed(1)} ${t.staff_time_card.hours} @ \$${timesheet.hourlyRate.toStringAsFixed(2)}${t.staff_time_card.per_hr}', + '${totalHours.toStringAsFixed(1)} ${t.staff_time_card.hours} @ \$${hourlyRate.toStringAsFixed(2)}${t.staff_time_card.per_hr}', style: UiTypography.body2r.textSecondary, ), Text( - '\$${timesheet.totalPay.toStringAsFixed(2)}', + '\$${totalPay.toStringAsFixed(2)}', style: UiTypography.title2b.primary, ), ], @@ -125,21 +90,6 @@ class TimesheetCard extends StatelessWidget { ), ); } - - // Helper to safely format time strings like "HH:mm" - String _formatTime(String t) { - if (t.isEmpty) return '--:--'; - try { - final List parts = t.split(':'); - if (parts.length >= 2) { - final DateTime dt = DateTime(2000, 1, 1, int.parse(parts[0]), int.parse(parts[1])); - return DateFormat('h:mm a').format(dt); - } - return t; - } catch (_) { - return t; - } - } } class _IconText extends StatelessWidget { diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/staff_time_card_module.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/staff_time_card_module.dart index 9d8ce260..c0bae901 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/staff_time_card_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/staff_time_card_module.dart @@ -3,28 +3,31 @@ library; import 'package:flutter/widgets.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_domain/krow_domain.dart'; -import 'data/repositories_impl/time_card_repository_impl.dart'; -import 'domain/repositories/time_card_repository.dart'; -import 'domain/usecases/get_time_cards_usecase.dart'; -import 'presentation/blocs/time_card_bloc.dart'; -import 'presentation/pages/time_card_page.dart'; +import 'package:staff_time_card/src/data/repositories_impl/time_card_repository_impl.dart'; +import 'package:staff_time_card/src/domain/repositories/time_card_repository.dart'; +import 'package:staff_time_card/src/domain/usecases/get_time_cards_usecase.dart'; +import 'package:staff_time_card/src/presentation/blocs/time_card_bloc.dart'; +import 'package:staff_time_card/src/presentation/pages/time_card_page.dart'; -export 'presentation/pages/time_card_page.dart'; +export 'package:staff_time_card/src/presentation/pages/time_card_page.dart'; /// Module for the Staff Time Card feature. /// -/// This module configures dependency injection for accessing time card data, -/// including the repositories, use cases, and BLoCs. +/// Uses the V2 REST API via [BaseApiService] for backend access. class StaffTimeCardModule extends Module { @override - List get imports => [DataConnectModule()]; + List get imports => [CoreModule()]; @override void binds(Injector i) { // Repositories - i.addLazySingleton(TimeCardRepositoryImpl.new); + i.addLazySingleton( + () => TimeCardRepositoryImpl( + apiService: i.get(), + ), + ); // UseCases i.add(GetTimeCardsUseCase.new); diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/pubspec.yaml index d25b80b9..8aefa6d1 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/pubspec.yaml +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/pubspec.yaml @@ -23,8 +23,6 @@ dependencies: path: ../../../../../core krow_domain: path: ../../../../../domain - krow_data_connect: - path: ../../../../../data_connect dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart index b50f671c..84e2ab90 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart @@ -13,16 +13,19 @@ import 'domain/usecases/upload_attire_photo_usecase.dart'; import 'presentation/pages/attire_capture_page.dart'; import 'presentation/pages/attire_page.dart'; +/// Module for the Staff Attire feature. +/// +/// Uses the V2 REST API via [BaseApiService] for backend access. class StaffAttireModule extends Module { @override List get imports => [CoreModule()]; @override void binds(Injector i) { - /// third party services + /// Third party services. i.addLazySingleton(ImagePicker.new); - /// local services + /// Local services. i.addLazySingleton( () => CameraService(i.get()), ); @@ -30,6 +33,7 @@ class StaffAttireModule extends Module { // Repository i.addLazySingleton( () => AttireRepositoryImpl( + apiService: i.get(), uploadService: i.get(), signedUrlService: i.get(), verificationService: i.get(), @@ -55,7 +59,7 @@ class StaffAttireModule extends Module { r.child( StaffPaths.childRoute(StaffPaths.attire, StaffPaths.attireCapture), child: (_) => AttireCapturePage( - item: r.args.data['item'] as AttireItem, + item: r.args.data['item'] as AttireChecklist, initialPhotoUrl: r.args.data['initialPhotoUrl'] as String?, ), ); diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart index b3001b26..2846c6bd 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart @@ -1,36 +1,38 @@ import 'package:flutter/foundation.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart' - hide AttireVerificationStatus; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/repositories/attire_repository.dart'; +import 'package:staff_attire/src/domain/repositories/attire_repository.dart'; -/// Implementation of [AttireRepository]. +/// Implementation of [AttireRepository] using the V2 API for reads +/// and core services for uploads. /// -/// Delegates data access to [StaffConnectorRepository]. +/// Replaces the previous Firebase Data Connect / StaffConnectorRepository. class AttireRepositoryImpl implements AttireRepository { /// Creates an [AttireRepositoryImpl]. AttireRepositoryImpl({ + required BaseApiService apiService, required FileUploadService uploadService, required SignedUrlService signedUrlService, required VerificationService verificationService, - StaffConnectorRepository? connector, - }) : _connector = - connector ?? DataConnectService.instance.getStaffRepository(), - _uploadService = uploadService, - _signedUrlService = signedUrlService, - _verificationService = verificationService; + }) : _api = apiService, + _uploadService = uploadService, + _signedUrlService = signedUrlService, + _verificationService = verificationService; - /// The Staff Connector repository. - final StaffConnectorRepository _connector; + final BaseApiService _api; final FileUploadService _uploadService; final SignedUrlService _signedUrlService; final VerificationService _verificationService; @override - Future> getAttireOptions() async { - return _connector.getAttireOptions(); + Future> getAttireOptions() async { + final ApiResponse response = await _api.get(V2ApiEndpoints.staffAttire); + final List items = response.data['items'] as List; + return items + .map((dynamic json) => + AttireChecklist.fromJson(json as Map)) + .toList(); } @override @@ -38,13 +40,11 @@ class AttireRepositoryImpl implements AttireRepository { required List selectedItemIds, required Map photoUrls, }) async { - // We already upsert photos in uploadPhoto (to follow the new flow). - // This could save selections if there was a separate "SelectedAttire" table. - // For now, it's a no-op as the source of truth is the StaffAttire table. + // Attire selection is saved per-item via uploadPhoto; this is a no-op. } @override - Future uploadPhoto(String itemId, String filePath) async { + Future uploadPhoto(String itemId, String filePath) async { // 1. Upload file to Core API final FileUploadResponse uploadRes = await _uploadService.uploadFile( filePath: filePath, @@ -53,41 +53,40 @@ class AttireRepositoryImpl implements AttireRepository { final String fileUri = uploadRes.fileUri; - // 2. Create signed URL for the uploaded file - final SignedUrlResponse signedUrlRes = await _signedUrlService - .createSignedUrl(fileUri: fileUri); + // 2. Create signed URL + final SignedUrlResponse signedUrlRes = + await _signedUrlService.createSignedUrl(fileUri: fileUri); final String photoUrl = signedUrlRes.signedUrl; // 3. Initiate verification job - final Staff staff = await _connector.getStaffProfile(); - - // Get item details for verification rules - final List options = await _connector.getAttireOptions(); - final AttireItem targetItem = options.firstWhere( - (AttireItem e) => e.id == itemId, + final List options = await getAttireOptions(); + final AttireChecklist targetItem = options.firstWhere( + (AttireChecklist e) => e.documentId == itemId, + orElse: () => throw UnknownException( + technicalMessage: 'Attire item $itemId not found in checklist', + ), ); final String dressCode = - '${targetItem.description ?? ''} ${targetItem.label}'.trim(); + '${targetItem.description} ${targetItem.name}'.trim(); - final VerificationResponse verifyRes = await _verificationService - .createVerification( - type: 'attire', - subjectType: 'worker', - subjectId: staff.id, - fileUri: fileUri, - rules: {'dressCode': dressCode}, - ); - final String verificationId = verifyRes.verificationId; + final VerificationResponse verifyRes = + await _verificationService.createVerification( + type: 'attire', + subjectType: 'worker', + subjectId: itemId, + fileUri: fileUri, + rules: {'dressCode': dressCode}, + ); + + // 4. Poll for status until finished or timeout (max 3 seconds) VerificationStatus currentStatus = verifyRes.status; - - // 4. Poll for status until it's finished or timeout (max 10 seconds) try { int attempts = 0; bool isFinished = false; - while (!isFinished && attempts < 5) { - await Future.delayed(const Duration(seconds: 2)); - final VerificationResponse statusRes = await _verificationService - .getStatus(verificationId); + while (!isFinished && attempts < 3) { + await Future.delayed(const Duration(seconds: 1)); + final VerificationResponse statusRes = + await _verificationService.getStatus(verifyRes.verificationId); currentStatus = statusRes.status; if (currentStatus != VerificationStatus.pending && currentStatus != VerificationStatus.processing) { @@ -97,40 +96,24 @@ class AttireRepositoryImpl implements AttireRepository { } } catch (e) { debugPrint('Polling failed or timed out: $e'); - // Continue anyway, as we have the verificationId } - // 5. Update Data Connect - await _connector.upsertStaffAttire( - attireOptionId: itemId, - photoUrl: photoUrl, - verificationId: verificationId, - verificationStatus: _mapToAttireStatus(currentStatus), + // 5. Update attire item via V2 API + await _api.put( + V2ApiEndpoints.staffAttireUpload(itemId), + data: { + 'photoUrl': photoUrl, + 'verificationId': verifyRes.verificationId, + }, ); - // 6. Return updated AttireItem by re-fetching to get the PENDING/SUCCESS status - final List finalOptions = await _connector.getAttireOptions(); - return finalOptions.firstWhere((AttireItem e) => e.id == itemId); - } - - AttireVerificationStatus _mapToAttireStatus(VerificationStatus status) { - switch (status) { - case VerificationStatus.pending: - return AttireVerificationStatus.pending; - case VerificationStatus.processing: - return AttireVerificationStatus.processing; - case VerificationStatus.autoPass: - return AttireVerificationStatus.autoPass; - case VerificationStatus.autoFail: - return AttireVerificationStatus.autoFail; - case VerificationStatus.needsReview: - return AttireVerificationStatus.needsReview; - case VerificationStatus.approved: - return AttireVerificationStatus.approved; - case VerificationStatus.rejected: - return AttireVerificationStatus.rejected; - case VerificationStatus.error: - return AttireVerificationStatus.error; - } + // 6. Return updated item by re-fetching + final List finalOptions = await getAttireOptions(); + return finalOptions.firstWhere( + (AttireChecklist e) => e.documentId == itemId, + orElse: () => throw UnknownException( + technicalMessage: 'Attire item $itemId not found after upload', + ), + ); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/repositories/attire_repository.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/repositories/attire_repository.dart index a57107c0..d8573e7e 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/repositories/attire_repository.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/repositories/attire_repository.dart @@ -1,11 +1,14 @@ import 'package:krow_domain/krow_domain.dart'; +/// Repository interface for attire operations. +/// +/// Uses [AttireChecklist] from the V2 domain layer. abstract interface class AttireRepository { - /// Fetches the list of available attire options. - Future> getAttireOptions(); + /// Fetches the list of available attire checklist items from the V2 API. + Future> getAttireOptions(); /// Uploads a photo for a specific attire item. - Future uploadPhoto(String itemId, String filePath); + Future uploadPhoto(String itemId, String filePath); /// Saves the user's attire selection and attestations. Future saveAttire({ diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/get_attire_options_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/get_attire_options_usecase.dart index 42094095..41216bb5 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/get_attire_options_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/get_attire_options_usecase.dart @@ -4,14 +4,14 @@ import 'package:krow_domain/krow_domain.dart'; import '../repositories/attire_repository.dart'; /// Use case to fetch available attire options. -class GetAttireOptionsUseCase extends NoInputUseCase> { +class GetAttireOptionsUseCase extends NoInputUseCase> { /// Creates a [GetAttireOptionsUseCase]. GetAttireOptionsUseCase(this._repository); final AttireRepository _repository; @override - Future> call() { + Future> call() { return _repository.getAttireOptions(); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/upload_attire_photo_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/upload_attire_photo_usecase.dart index 39cd456b..be3343c6 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/upload_attire_photo_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/upload_attire_photo_usecase.dart @@ -5,13 +5,13 @@ import '../repositories/attire_repository.dart'; /// Use case to upload a photo for an attire item. class UploadAttirePhotoUseCase - extends UseCase { + extends UseCase { /// Creates a [UploadAttirePhotoUseCase]. UploadAttirePhotoUseCase(this._repository); final AttireRepository _repository; @override - Future call(UploadAttirePhotoArguments arguments) { + Future call(UploadAttirePhotoArguments arguments) { return _repository.uploadPhoto(arguments.itemId, arguments.filePath); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_cubit.dart index bc643b5a..ed8a962f 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_cubit.dart @@ -21,19 +21,19 @@ class AttireCubit extends Cubit await handleError( emit: emit, action: () async { - final List options = await _getAttireOptionsUseCase(); + final List options = await _getAttireOptionsUseCase(); // Extract photo URLs and selection status from backend data final Map photoUrls = {}; final List selectedIds = []; - for (final AttireItem item in options) { - if (item.photoUrl != null) { - photoUrls[item.id] = item.photoUrl!; + for (final AttireChecklist item in options) { + if (item.photoUri != null) { + photoUrls[item.documentId] = item.photoUri!; } // If mandatory or has photo, consider it selected initially - if (item.isMandatory || item.photoUrl != null) { - selectedIds.add(item.id); + if (item.mandatory || item.photoUri != null) { + selectedIds.add(item.documentId); } } @@ -68,18 +68,18 @@ class AttireCubit extends Cubit emit(state.copyWith(filter: filter)); } - void syncCapturedPhoto(AttireItem item) { + void syncCapturedPhoto(AttireChecklist item) { // Update the options list with the new item data - final List updatedOptions = state.options - .map((AttireItem e) => e.id == item.id ? item : e) + final List updatedOptions = state.options + .map((AttireChecklist e) => e.documentId == item.documentId ? item : e) .toList(); // Update the photo URLs map final Map updatedPhotos = Map.from( state.photoUrls, ); - if (item.photoUrl != null) { - updatedPhotos[item.id] = item.photoUrl!; + if (item.photoUri != null) { + updatedPhotos[item.documentId] = item.photoUri!; } emit(state.copyWith(options: updatedOptions, photoUrls: updatedPhotos)); diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_state.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_state.dart index e137aff2..5d1be6bd 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_state.dart @@ -6,14 +6,14 @@ enum AttireStatus { initial, loading, success, failure, saving, saved } class AttireState extends Equatable { const AttireState({ this.status = AttireStatus.initial, - this.options = const [], + this.options = const [], this.selectedIds = const [], this.photoUrls = const {}, this.filter = 'All', this.errorMessage, }); final AttireStatus status; - final List options; + final List options; final List selectedIds; final Map photoUrls; final String filter; @@ -23,40 +23,44 @@ class AttireState extends Equatable { bool isMandatory(String id) { return options .firstWhere( - (AttireItem e) => e.id == id, - orElse: () => const AttireItem(id: '', code: '', label: ''), + (AttireChecklist e) => e.documentId == id, + orElse: () => const AttireChecklist( + documentId: '', + name: '', + status: AttireItemStatus.notUploaded, + ), ) - .isMandatory; + .mandatory; } /// Validation logic bool get allMandatorySelected { final Iterable mandatoryIds = options - .where((AttireItem e) => e.isMandatory) - .map((AttireItem e) => e.id); + .where((AttireChecklist e) => e.mandatory) + .map((AttireChecklist e) => e.documentId); return mandatoryIds.every((String id) => selectedIds.contains(id)); } bool get allMandatoryHavePhotos { final Iterable mandatoryIds = options - .where((AttireItem e) => e.isMandatory) - .map((AttireItem e) => e.id); + .where((AttireChecklist e) => e.mandatory) + .map((AttireChecklist e) => e.documentId); return mandatoryIds.every((String id) => photoUrls.containsKey(id)); } bool get canSave => allMandatorySelected && allMandatoryHavePhotos; - List get filteredOptions { - return options.where((AttireItem item) { - if (filter == 'Required') return item.isMandatory; - if (filter == 'Non-Essential') return !item.isMandatory; + List get filteredOptions { + return options.where((AttireChecklist item) { + if (filter == 'Required') return item.mandatory; + if (filter == 'Non-Essential') return !item.mandatory; return true; }).toList(); } AttireState copyWith({ AttireStatus? status, - List? options, + List? options, List? selectedIds, Map? photoUrls, String? filter, diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_cubit.dart index a3b9eca1..678e6d9e 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_cubit.dart @@ -23,14 +23,14 @@ class AttireCaptureCubit extends Cubit await handleError( emit: emit, action: () async { - final AttireItem item = await _uploadAttirePhotoUseCase( + final AttireChecklist item = await _uploadAttirePhotoUseCase( UploadAttirePhotoArguments(itemId: itemId, filePath: filePath), ); emit( state.copyWith( status: AttireCaptureStatus.success, - photoUrl: item.photoUrl, + photoUrl: item.photoUri, updatedItem: item, ), ); diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_state.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_state.dart index 79f6e28a..ec899675 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_state.dart @@ -15,14 +15,14 @@ class AttireCaptureState extends Equatable { final AttireCaptureStatus status; final bool isAttested; final String? photoUrl; - final AttireItem? updatedItem; + final AttireChecklist? updatedItem; final String? errorMessage; AttireCaptureState copyWith({ AttireCaptureStatus? status, bool? isAttested, String? photoUrl, - AttireItem? updatedItem, + AttireChecklist? updatedItem, String? errorMessage, }) { return AttireCaptureState( diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart index 2cc52470..2bc1917a 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart @@ -24,8 +24,8 @@ class AttireCapturePage extends StatefulWidget { this.initialPhotoUrl, }); - /// The attire item being captured. - final AttireItem item; + /// The attire checklist item being captured. + final AttireChecklist item; /// Optional initial photo URL if it was already uploaded. final String? initialPhotoUrl; @@ -48,7 +48,7 @@ class _AttireCapturePageState extends State { /// Whether the item is currently pending verification. bool get _isPending => - widget.item.verificationStatus == AttireVerificationStatus.pending; + widget.item.status == AttireItemStatus.pending; /// On gallery button press Future _onGallery(BuildContext context) async { @@ -206,7 +206,7 @@ class _AttireCapturePageState extends State { return; } - await cubit.uploadPhoto(widget.item.id, _selectedLocalPath!); + await cubit.uploadPhoto(widget.item.documentId, _selectedLocalPath!); if (context.mounted && cubit.state.status == AttireCaptureStatus.success) { setState(() { _selectedLocalPath = null; @@ -215,12 +215,12 @@ class _AttireCapturePageState extends State { } String _getStatusText(bool hasUploadedPhoto) { - return switch (widget.item.verificationStatus) { - AttireVerificationStatus.approved => + return switch (widget.item.status) { + AttireItemStatus.verified => t.staff_profile_attire.capture.approved, - AttireVerificationStatus.rejected => + AttireItemStatus.rejected => t.staff_profile_attire.capture.rejected, - AttireVerificationStatus.pending => + AttireItemStatus.pending => t.staff_profile_attire.capture.pending_verification, _ => hasUploadedPhoto @@ -230,10 +230,10 @@ class _AttireCapturePageState extends State { } Color _getStatusColor(bool hasUploadedPhoto) { - return switch (widget.item.verificationStatus) { - AttireVerificationStatus.approved => UiColors.textSuccess, - AttireVerificationStatus.rejected => UiColors.textError, - AttireVerificationStatus.pending => UiColors.textWarning, + return switch (widget.item.status) { + AttireItemStatus.verified => UiColors.textSuccess, + AttireItemStatus.rejected => UiColors.textError, + AttireItemStatus.pending => UiColors.textWarning, _ => hasUploadedPhoto ? UiColors.textWarning : UiColors.textInactive, }; } @@ -250,7 +250,7 @@ class _AttireCapturePageState extends State { return Scaffold( appBar: UiAppBar( - title: widget.item.label, + title: widget.item.name, onLeadingPressed: () { Modular.to.toAttire(); }, @@ -296,7 +296,7 @@ class _AttireCapturePageState extends State { ImagePreviewSection( selectedLocalPath: _selectedLocalPath, currentPhotoUrl: currentPhotoUrl, - referenceImageUrl: widget.item.imageUrl, + referenceImageUrl: null, ), InfoSection( description: widget.item.description, diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart index 2637c9c0..831f2d13 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart @@ -53,11 +53,11 @@ class _AttirePageState extends State { return const AttireSkeleton(); } - final List requiredItems = state.options - .where((AttireItem item) => item.isMandatory) + final List requiredItems = state.options + .where((AttireChecklist item) => item.mandatory) .toList(); - final List nonEssentialItems = state.options - .where((AttireItem item) => !item.isMandatory) + final List nonEssentialItems = state.options + .where((AttireChecklist item) => !item.mandatory) .toList(); return Column( @@ -109,7 +109,7 @@ class _AttirePageState extends State { .no_items_filter, ) else - ...requiredItems.map((AttireItem item) { + ...requiredItems.map((AttireChecklist item) { return Padding( padding: const EdgeInsets.only( bottom: UiConstants.space3, @@ -117,11 +117,11 @@ class _AttirePageState extends State { child: AttireItemCard( item: item, isUploading: false, - uploadedPhotoUrl: state.photoUrls[item.id], + uploadedPhotoUrl: state.photoUrls[item.documentId], onTap: () { Modular.to.toAttireCapture( item: item, - initialPhotoUrl: state.photoUrls[item.id], + initialPhotoUrl: state.photoUrls[item.documentId], ); }, ), @@ -156,7 +156,7 @@ class _AttirePageState extends State { .no_items_filter, ) else - ...nonEssentialItems.map((AttireItem item) { + ...nonEssentialItems.map((AttireChecklist item) { return Padding( padding: const EdgeInsets.only( bottom: UiConstants.space3, @@ -164,11 +164,11 @@ class _AttirePageState extends State { child: AttireItemCard( item: item, isUploading: false, - uploadedPhotoUrl: state.photoUrls[item.id], + uploadedPhotoUrl: state.photoUrls[item.documentId], onTap: () { Modular.to.toAttireCapture( item: item, - initialPhotoUrl: state.photoUrls[item.id], + initialPhotoUrl: state.photoUrls[item.documentId], ); }, ), diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/footer_section.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/footer_section.dart index ba73830d..56c373ba 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/footer_section.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/footer_section.dart @@ -39,7 +39,7 @@ class FooterSection extends StatelessWidget { final bool hasUploadedPhoto; /// The updated attire item, if any. - final AttireItem? updatedItem; + final AttireChecklist? updatedItem; /// Whether to show the attestation checkbox. final bool showCheckbox; diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_grid.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_grid.dart index dc4a0c9e..014ba478 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_grid.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_grid.dart @@ -14,7 +14,7 @@ class AttireGrid extends StatelessWidget { required this.onToggle, required this.onUpload, }); - final List items; + final List items; final List selectedIds; final Map photoUrls; final Map uploadingStatus; @@ -34,10 +34,10 @@ class AttireGrid extends StatelessWidget { ), itemCount: items.length, itemBuilder: (BuildContext context, int index) { - final AttireItem item = items[index]; - final bool isSelected = selectedIds.contains(item.id); - final bool hasPhoto = photoUrls.containsKey(item.id); - final bool isUploading = uploadingStatus[item.id] ?? false; + final AttireChecklist item = items[index]; + final bool isSelected = selectedIds.contains(item.documentId); + final bool hasPhoto = photoUrls.containsKey(item.documentId); + final bool isUploading = uploadingStatus[item.documentId] ?? false; return _buildCard(item, isSelected, hasPhoto, isUploading); }, @@ -45,7 +45,7 @@ class AttireGrid extends StatelessWidget { } Widget _buildCard( - AttireItem item, + AttireChecklist item, bool isSelected, bool hasPhoto, bool isUploading, @@ -63,20 +63,19 @@ class AttireGrid extends StatelessWidget { ), child: Stack( children: [ - if (item.isMandatory) + if (item.mandatory) Positioned( top: UiConstants.space2, left: UiConstants.space2, child: Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: UiColors.destructive, // Red + color: UiColors.destructive, borderRadius: UiConstants.radiusSm, ), child: Text( t.staff_profile_attire.status.required, style: UiTypography.body3m.copyWith( - // 12px Medium -> Bold fontWeight: FontWeight.bold, fontSize: 9, color: UiColors.white, @@ -106,37 +105,23 @@ class AttireGrid extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ GestureDetector( - onTap: () => onToggle(item.id), + onTap: () => onToggle(item.documentId), child: Column( children: [ - item.imageUrl != null - ? Container( - height: 80, - width: 80, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, - ), - image: DecorationImage( - image: NetworkImage(item.imageUrl!), - fit: BoxFit.cover, - ), - ), - ) - : const Icon( - UiIcons.shirt, - size: 48, - color: UiColors.iconSecondary, - ), + const Icon( + UiIcons.shirt, + size: 48, + color: UiColors.iconSecondary, + ), const SizedBox(height: UiConstants.space2), Text( - item.label, + item.name, textAlign: TextAlign.center, style: UiTypography.body2m.textPrimary, ), - if (item.description != null) + if (item.description.isNotEmpty) Text( - item.description!, + item.description, textAlign: TextAlign.center, style: UiTypography.body3r.textSecondary, maxLines: 2, @@ -147,7 +132,7 @@ class AttireGrid extends StatelessWidget { ), const SizedBox(height: UiConstants.space3), InkWell( - onTap: () => onUpload(item.id), + onTap: () => onUpload(item.documentId), borderRadius: BorderRadius.circular(UiConstants.radiusBase), child: Container( padding: const EdgeInsets.symmetric( @@ -189,7 +174,7 @@ class AttireGrid extends StatelessWidget { const Icon( UiIcons.camera, size: 12, - color: UiColors.textSecondary, // Was muted + color: UiColors.textSecondary, ), const SizedBox(width: 6), Text( diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_item_card.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_item_card.dart index f0941d96..6c3a72c3 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_item_card.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_item_card.dart @@ -11,18 +11,18 @@ class AttireItemCard extends StatelessWidget { required this.onTap, }); - final AttireItem item; + final AttireChecklist item; final String? uploadedPhotoUrl; final bool isUploading; final VoidCallback onTap; @override Widget build(BuildContext context) { - final bool hasPhoto = item.photoUrl != null; - final String statusText = switch (item.verificationStatus) { - AttireVerificationStatus.approved => 'Approved', - AttireVerificationStatus.rejected => 'Rejected', - AttireVerificationStatus.pending => 'Pending', + final bool hasPhoto = item.photoUri != null; + final String statusText = switch (item.status) { + AttireItemStatus.verified => 'Approved', + AttireItemStatus.rejected => 'Rejected', + AttireItemStatus.pending => 'Pending', _ => hasPhoto ? 'Pending' : 'To Do', }; @@ -38,21 +38,29 @@ class AttireItemCard extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Image + // Image placeholder Container( width: 64, height: 64, decoration: BoxDecoration( color: UiColors.background, borderRadius: BorderRadius.circular(UiConstants.radiusBase), - image: DecorationImage( - image: NetworkImage( - item.imageUrl ?? - 'https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400', - ), - fit: BoxFit.cover, - ), + image: hasPhoto + ? DecorationImage( + image: NetworkImage(item.photoUri!), + fit: BoxFit.cover, + ) + : null, ), + child: hasPhoto + ? null + : const Center( + child: Icon( + UiIcons.camera, + color: UiColors.textSecondary, + size: 24, + ), + ), ), const SizedBox(width: UiConstants.space4), // details @@ -60,10 +68,10 @@ class AttireItemCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(item.label, style: UiTypography.body1m.textPrimary), - if (item.description != null) ...[ + Text(item.name, style: UiTypography.body1m.textPrimary), + if (item.description.isNotEmpty) ...[ Text( - item.description!, + item.description, style: UiTypography.body2r.textSecondary, maxLines: 2, overflow: TextOverflow.ellipsis, @@ -73,7 +81,7 @@ class AttireItemCard extends StatelessWidget { Row( spacing: UiConstants.space2, children: [ - if (item.isMandatory) + if (item.mandatory) const UiChip( label: 'Required', size: UiChipSize.xSmall, @@ -90,8 +98,7 @@ class AttireItemCard extends StatelessWidget { label: statusText, size: UiChipSize.xSmall, variant: - item.verificationStatus == - AttireVerificationStatus.approved + item.status == AttireItemStatus.verified ? UiChipVariant.primary : UiChipVariant.secondary, ), @@ -114,12 +121,11 @@ class AttireItemCard extends StatelessWidget { ) else if (hasPhoto && !isUploading) Icon( - item.verificationStatus == AttireVerificationStatus.approved + item.status == AttireItemStatus.verified ? UiIcons.check : UiIcons.clock, color: - item.verificationStatus == - AttireVerificationStatus.approved + item.status == AttireItemStatus.verified ? UiColors.textPrimary : UiColors.textWarning, size: 24, diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/pubspec.yaml index 0a5ffcf0..12c3a1a9 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/pubspec.yaml +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/pubspec.yaml @@ -14,15 +14,12 @@ dependencies: flutter_bloc: ^8.1.0 flutter_modular: ^6.0.0 equatable: ^2.0.5 - firebase_data_connect: ^0.2.2+1 - + # Internal packages krow_core: path: ../../../../../core krow_domain: path: ../../../../../domain - krow_data_connect: - path: ../../../../../data_connect design_system: path: ../../../../../design_system core_localization: diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/data/repositories/emergency_contact_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/data/repositories/emergency_contact_repository_impl.dart index afea63f9..a0b90d67 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/data/repositories/emergency_contact_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/data/repositories/emergency_contact_repository_impl.dart @@ -1,81 +1,38 @@ -import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/repositories/emergency_contact_repository_interface.dart'; -/// Implementation of [EmergencyContactRepositoryInterface]. +import 'package:staff_emergency_contact/src/domain/repositories/emergency_contact_repository_interface.dart'; + +/// Implementation of [EmergencyContactRepositoryInterface] using the V2 API. /// -/// This repository delegates data operations to Firebase Data Connect. +/// Replaces the previous Firebase Data Connect implementation. class EmergencyContactRepositoryImpl implements EmergencyContactRepositoryInterface { - final dc.DataConnectService _service; - /// Creates an [EmergencyContactRepositoryImpl]. - EmergencyContactRepositoryImpl({ - dc.DataConnectService? service, - }) : _service = service ?? dc.DataConnectService.instance; + EmergencyContactRepositoryImpl({required BaseApiService apiService}) + : _api = apiService; + + final BaseApiService _api; @override Future> getContacts() async { - return _service.run(() async { - final staffId = await _service.getStaffId(); - final result = await _service.connector - .getEmergencyContactsByStaffId(staffId: staffId) - .execute(); - - return result.data.emergencyContacts.map((dto) { - return EmergencyContactAdapter.fromPrimitives( - id: dto.id, - name: dto.name, - phone: dto.phone, - relationship: dto.relationship.stringValue, - ); - }).toList(); - }); + final ApiResponse response = + await _api.get(V2ApiEndpoints.staffEmergencyContacts); + final List items = response.data['contacts'] as List; + return items + .map((dynamic json) => + EmergencyContact.fromJson(json as Map)) + .toList(); } @override Future saveContacts(List contacts) async { - return _service.run(() async { - final staffId = await _service.getStaffId(); - - // 1. Get existing to delete - final existingResult = await _service.connector - .getEmergencyContactsByStaffId(staffId: staffId) - .execute(); - final existingIds = - existingResult.data.emergencyContacts.map((e) => e.id).toList(); - - // 2. Delete all existing - await Future.wait(existingIds.map( - (id) => _service.connector.deleteEmergencyContact(id: id).execute())); - - // 3. Create new - await Future.wait(contacts.map((contact) { - dc.RelationshipType rel = dc.RelationshipType.OTHER; - switch (contact.relationship) { - case RelationshipType.family: - rel = dc.RelationshipType.FAMILY; - break; - case RelationshipType.spouse: - rel = dc.RelationshipType.SPOUSE; - break; - case RelationshipType.friend: - rel = dc.RelationshipType.FRIEND; - break; - case RelationshipType.other: - rel = dc.RelationshipType.OTHER; - break; - } - - return _service.connector - .createEmergencyContact( - name: contact.name, - phone: contact.phone, - relationship: rel, - staffId: staffId, - ) - .execute(); - })); - }); + await _api.put( + V2ApiEndpoints.staffEmergencyContacts, + data: { + 'contacts': + contacts.map((EmergencyContact c) => c.toJson()).toList(), + }, + ); } -} \ No newline at end of file +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/domain/repositories/emergency_contact_repository_interface.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/domain/repositories/emergency_contact_repository_interface.dart index 1f958052..947b6b50 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/domain/repositories/emergency_contact_repository_interface.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/domain/repositories/emergency_contact_repository_interface.dart @@ -2,10 +2,10 @@ import 'package:krow_domain/krow_domain.dart'; /// Repository interface for managing emergency contacts. /// -/// This interface defines the contract for fetching and saving emergency contact information. -/// It must be implemented by the data layer. +/// Defines the contract for fetching and saving emergency contact information +/// via the V2 API. abstract class EmergencyContactRepositoryInterface { - /// Retrieves the list of emergency contacts. + /// Retrieves the list of emergency contacts for the current staff member. Future> getContacts(); /// Saves the list of emergency contacts. diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/blocs/emergency_contact_state.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/blocs/emergency_contact_state.dart index 8c2386bf..e800466b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/blocs/emergency_contact_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/blocs/emergency_contact_state.dart @@ -28,9 +28,7 @@ class EmergencyContactState extends Equatable { bool get isValid { if (contacts.isEmpty) return false; - // Check if at least one contact is valid (or all?) - // Usually all added contacts should be valid. - return contacts.every((c) => c.name.isNotEmpty && c.phone.isNotEmpty); + return contacts.every((c) => c.fullName.isNotEmpty && c.phone.isNotEmpty); } @override diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_form_item.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_form_item.dart index 7dcf5040..9a326905 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_form_item.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_form_item.dart @@ -4,6 +4,14 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_domain/krow_domain.dart'; import '../blocs/emergency_contact_bloc.dart'; +/// Available relationship type values. +const List _kRelationshipTypes = [ + 'FAMILY', + 'SPOUSE', + 'FRIEND', + 'OTHER', +]; + class EmergencyContactFormItem extends StatelessWidget { final int index; final EmergencyContact contact; @@ -33,11 +41,11 @@ class EmergencyContactFormItem extends StatelessWidget { const SizedBox(height: UiConstants.space4), _buildLabel('Full Name'), _buildTextField( - initialValue: contact.name, + initialValue: contact.fullName, hint: 'Contact name', icon: UiIcons.user, onChanged: (val) => context.read().add( - EmergencyContactUpdated(index, contact.copyWith(name: val)), + EmergencyContactUpdated(index, contact.copyWith(fullName: val)), ), ), const SizedBox(height: UiConstants.space4), @@ -54,14 +62,14 @@ class EmergencyContactFormItem extends StatelessWidget { _buildLabel('Relationship'), _buildDropdown( context, - value: contact.relationship, - items: RelationshipType.values, + value: contact.relationshipType, + items: _kRelationshipTypes, onChanged: (val) { if (val != null) { context.read().add( EmergencyContactUpdated( index, - contact.copyWith(relationship: val), + contact.copyWith(relationshipType: val), ), ); } @@ -74,9 +82,9 @@ class EmergencyContactFormItem extends StatelessWidget { Widget _buildDropdown( BuildContext context, { - required RelationshipType value, - required List items, - required ValueChanged onChanged, + required String value, + required List items, + required ValueChanged onChanged, }) { return Container( padding: const EdgeInsets.symmetric( @@ -89,13 +97,13 @@ class EmergencyContactFormItem extends StatelessWidget { border: Border.all(color: UiColors.border), ), child: DropdownButtonHideUnderline( - child: DropdownButton( - value: value, + child: DropdownButton( + value: items.contains(value) ? value : items.first, isExpanded: true, dropdownColor: UiColors.bgPopup, icon: const Icon(UiIcons.chevronDown, color: UiColors.iconSecondary), items: items.map((type) { - return DropdownMenuItem( + return DropdownMenuItem( value: type, child: Text( _formatRelationship(type), @@ -109,16 +117,18 @@ class EmergencyContactFormItem extends StatelessWidget { ); } - String _formatRelationship(RelationshipType type) { + String _formatRelationship(String type) { switch (type) { - case RelationshipType.family: + case 'FAMILY': return 'Family'; - case RelationshipType.spouse: + case 'SPOUSE': return 'Spouse'; - case RelationshipType.friend: + case 'FRIEND': return 'Friend'; - case RelationshipType.other: + case 'OTHER': return 'Other'; + default: + return type; } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/staff_emergency_contact_module.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/staff_emergency_contact_module.dart index 3f7bea36..1065f5ae 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/staff_emergency_contact_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/staff_emergency_contact_module.dart @@ -1,26 +1,38 @@ import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; -import 'data/repositories/emergency_contact_repository_impl.dart'; -import 'domain/repositories/emergency_contact_repository_interface.dart'; -import 'domain/usecases/get_emergency_contacts_usecase.dart'; -import 'domain/usecases/save_emergency_contacts_usecase.dart'; -import 'presentation/blocs/emergency_contact_bloc.dart'; -import 'presentation/pages/emergency_contact_screen.dart'; +import 'package:staff_emergency_contact/src/data/repositories/emergency_contact_repository_impl.dart'; +import 'package:staff_emergency_contact/src/domain/repositories/emergency_contact_repository_interface.dart'; +import 'package:staff_emergency_contact/src/domain/usecases/get_emergency_contacts_usecase.dart'; +import 'package:staff_emergency_contact/src/domain/usecases/save_emergency_contacts_usecase.dart'; +import 'package:staff_emergency_contact/src/presentation/blocs/emergency_contact_bloc.dart'; +import 'package:staff_emergency_contact/src/presentation/pages/emergency_contact_screen.dart'; +/// Module for the Staff Emergency Contact feature. +/// +/// Uses the V2 REST API via [BaseApiService] for backend access. class StaffEmergencyContactModule extends Module { + @override + List get imports => [CoreModule()]; + @override void binds(Injector i) { // Repository i.addLazySingleton( - EmergencyContactRepositoryImpl.new, + () => EmergencyContactRepositoryImpl( + apiService: i.get(), + ), ); // UseCases i.addLazySingleton( - () => GetEmergencyContactsUseCase(i.get()), + () => GetEmergencyContactsUseCase( + i.get()), ); i.addLazySingleton( - () => SaveEmergencyContactsUseCase(i.get()), + () => SaveEmergencyContactsUseCase( + i.get()), ); // BLoC diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/pubspec.yaml index 15529c1b..8c22d237 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/pubspec.yaml +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/pubspec.yaml @@ -14,14 +14,12 @@ dependencies: flutter_bloc: ^8.1.0 flutter_modular: ^6.3.0 equatable: ^2.0.5 - + # Architecture Packages krow_domain: path: ../../../../../domain krow_core: path: ../../../../../core - krow_data_connect: - path: ../../../../../data_connect design_system: path: ../../../../../design_system core_localization: diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/data/repositories/experience_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/data/repositories/experience_repository_impl.dart index 4b104d82..f7cc838e 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/data/repositories/experience_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/data/repositories/experience_repository_impl.dart @@ -1,42 +1,31 @@ -import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/repositories/experience_repository_interface.dart'; +import 'package:staff_profile_experience/src/domain/repositories/experience_repository_interface.dart'; -/// Implementation of [ExperienceRepositoryInterface] that delegates to Data Connect. +/// Implementation of [ExperienceRepositoryInterface] using the V2 API. +/// +/// Replaces the previous Firebase Data Connect implementation. class ExperienceRepositoryImpl implements ExperienceRepositoryInterface { - final dc.DataConnectService _service; + /// Creates an [ExperienceRepositoryImpl]. + ExperienceRepositoryImpl({required BaseApiService apiService}) + : _api = apiService; - /// Creates a [ExperienceRepositoryImpl] using Data Connect Service. - ExperienceRepositoryImpl({ - dc.DataConnectService? service, - }) : _service = service ?? dc.DataConnectService.instance; - - Future _getStaff() async { - final staffId = await _service.getStaffId(); - - final result = - await _service.connector.getStaffById(id: staffId).execute(); - if (result.data.staff == null) { - throw const ServerException(technicalMessage: 'Staff profile not found'); - } - return result.data.staff!; - } + final BaseApiService _api; @override Future> getIndustries() async { - return _service.run(() async { - final staff = await _getStaff(); - return staff.industries ?? []; - }); + final ApiResponse response = + await _api.get(V2ApiEndpoints.staffIndustries); + final List items = response.data['industries'] as List; + return items.map((dynamic e) => e.toString()).toList(); } @override Future> getSkills() async { - return _service.run(() async { - final staff = await _getStaff(); - return staff.skills ?? []; - }); + final ApiResponse response = await _api.get(V2ApiEndpoints.staffSkills); + final List items = response.data['skills'] as List; + return items.map((dynamic e) => e.toString()).toList(); } @override @@ -44,13 +33,12 @@ class ExperienceRepositoryImpl implements ExperienceRepositoryInterface { List industries, List skills, ) async { - return _service.run(() async { - final staff = await _getStaff(); - await _service.connector - .updateStaff(id: staff.id) - .industries(industries) - .skills(skills) - .execute(); - }); + await _api.put( + V2ApiEndpoints.staffPersonalInfo, + data: { + 'industries': industries, + 'skills': skills, + }, + ); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_bloc.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_bloc.dart index 20829532..562ffb6a 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_bloc.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_bloc.dart @@ -1,7 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; import '../../domain/arguments/save_experience_arguments.dart'; import '../../domain/usecases/get_staff_industries_usecase.dart'; import '../../domain/usecases/get_staff_skills_usecase.dart'; @@ -18,7 +17,7 @@ abstract class ExperienceEvent extends Equatable { class ExperienceLoaded extends ExperienceEvent {} class ExperienceIndustryToggled extends ExperienceEvent { - final Industry industry; + final String industry; const ExperienceIndustryToggled(this.industry); @override @@ -48,10 +47,10 @@ enum ExperienceStatus { initial, loading, success, failure } class ExperienceState extends Equatable { final ExperienceStatus status; - final List selectedIndustries; + final List selectedIndustries; final List selectedSkills; - final List availableIndustries; - final List availableSkills; + final List availableIndustries; + final List availableSkills; final String? errorMessage; const ExperienceState({ @@ -65,10 +64,10 @@ class ExperienceState extends Equatable { ExperienceState copyWith({ ExperienceStatus? status, - List? selectedIndustries, + List? selectedIndustries, List? selectedSkills, - List? availableIndustries, - List? availableSkills, + List? availableIndustries, + List? availableSkills, String? errorMessage, }) { return ExperienceState( @@ -92,6 +91,37 @@ class ExperienceState extends Equatable { ]; } +/// Available industry option values. +const List _kAvailableIndustries = [ + 'hospitality', + 'food_service', + 'warehouse', + 'events', + 'retail', + 'healthcare', + 'other', +]; + +/// Available skill option values. +const List _kAvailableSkills = [ + 'food_service', + 'bartending', + 'event_setup', + 'hospitality', + 'warehouse', + 'customer_service', + 'cleaning', + 'security', + 'retail', + 'driving', + 'cooking', + 'cashier', + 'server', + 'barista', + 'host_hostess', + 'busser', +]; + // BLoC class ExperienceBloc extends Bloc with BlocErrorHandler { @@ -105,8 +135,8 @@ class ExperienceBloc extends Bloc required this.saveExperience, }) : super( const ExperienceState( - availableIndustries: Industry.values, - availableSkills: ExperienceSkill.values, + availableIndustries: _kAvailableIndustries, + availableSkills: _kAvailableSkills, ), ) { on(_onLoaded); @@ -131,11 +161,7 @@ class ExperienceBloc extends Bloc emit( state.copyWith( status: ExperienceStatus.initial, - selectedIndustries: - results[0] - .map((e) => Industry.fromString(e)) - .whereType() - .toList(), + selectedIndustries: results[0], selectedSkills: results[1], ), ); @@ -151,7 +177,7 @@ class ExperienceBloc extends Bloc ExperienceIndustryToggled event, Emitter emit, ) { - final industries = List.from(state.selectedIndustries); + final industries = List.from(state.selectedIndustries); if (industries.contains(event.industry)) { industries.remove(event.industry); } else { @@ -193,7 +219,7 @@ class ExperienceBloc extends Bloc action: () async { await saveExperience( SaveExperienceArguments( - industries: state.selectedIndustries.map((e) => e.value).toList(), + industries: state.selectedIndustries, skills: state.selectedSkills, ), ); @@ -206,4 +232,3 @@ class ExperienceBloc extends Bloc ); } } - diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/pages/experience_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/pages/experience_page.dart index e33628af..2bf00f85 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/pages/experience_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/pages/experience_page.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; import '../blocs/experience_bloc.dart'; import '../widgets/experience_section_title.dart'; @@ -12,59 +11,63 @@ import '../widgets/experience_section_title.dart'; class ExperiencePage extends StatelessWidget { const ExperiencePage({super.key}); - String _getIndustryLabel(dynamic node, Industry industry) { + String _getIndustryLabel(dynamic node, String industry) { switch (industry) { - case Industry.hospitality: + case 'hospitality': return node.hospitality; - case Industry.foodService: + case 'food_service': return node.food_service; - case Industry.warehouse: + case 'warehouse': return node.warehouse; - case Industry.events: + case 'events': return node.events; - case Industry.retail: + case 'retail': return node.retail; - case Industry.healthcare: + case 'healthcare': return node.healthcare; - case Industry.other: + case 'other': return node.other; + default: + return industry; } } - String _getSkillLabel(dynamic node, ExperienceSkill skill) { + String _getSkillLabel(dynamic node, String skill) { switch (skill) { - case ExperienceSkill.foodService: + case 'food_service': return node.food_service; - case ExperienceSkill.bartending: + case 'bartending': return node.bartending; - case ExperienceSkill.eventSetup: + case 'event_setup': return node.event_setup; - case ExperienceSkill.hospitality: + case 'hospitality': return node.hospitality; - case ExperienceSkill.warehouse: + case 'warehouse': return node.warehouse; - case ExperienceSkill.customerService: + case 'customer_service': return node.customer_service; - case ExperienceSkill.cleaning: + case 'cleaning': return node.cleaning; - case ExperienceSkill.security: + case 'security': return node.security; - case ExperienceSkill.retail: + case 'retail': return node.retail; - case ExperienceSkill.driving: + case 'driving': return node.driving; - case ExperienceSkill.cooking: + case 'cooking': return node.cooking; - case ExperienceSkill.cashier: + case 'cashier': return node.cashier; - case ExperienceSkill.server: + case 'server': return node.server; - case ExperienceSkill.barista: + case 'barista': return node.barista; - case ExperienceSkill.hostHostess: + case 'host_hostess': return node.host_hostess; - case ExperienceSkill.busser: + case 'busser': return node.busser; + default: + return skill; } } @@ -154,14 +157,12 @@ class ExperiencePage extends StatelessWidget { .map( (s) => UiChip( label: _getSkillLabel(i18n.skills, s), - isSelected: state.selectedSkills.contains( - s.value, - ), + isSelected: state.selectedSkills.contains(s), onTap: () => BlocProvider.of( context, - ).add(ExperienceSkillToggled(s.value)), + ).add(ExperienceSkillToggled(s)), variant: - state.selectedSkills.contains(s.value) + state.selectedSkills.contains(s) ? UiChipVariant.primary : UiChipVariant.secondary, ), @@ -183,7 +184,7 @@ class ExperiencePage extends StatelessWidget { Widget _buildCustomSkillsList(ExperienceState state, dynamic i18n) { final customSkills = state.selectedSkills - .where((s) => !state.availableSkills.any((e) => e.value == s)) + .where((s) => !state.availableSkills.contains(s)) .toList(); if (customSkills.isEmpty) return const SizedBox.shrink(); diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/staff_profile_experience.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/staff_profile_experience.dart index f3e354fd..ad6f5668 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/staff_profile_experience.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/staff_profile_experience.dart @@ -1,7 +1,8 @@ library; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; import 'src/data/repositories/experience_repository_impl.dart'; import 'src/domain/repositories/experience_repository_interface.dart'; @@ -13,20 +14,26 @@ import 'src/presentation/pages/experience_page.dart'; export 'src/presentation/pages/experience_page.dart'; +/// Module for the Staff Experience feature. +/// +/// Uses the V2 REST API via [BaseApiService] for backend access. class StaffProfileExperienceModule extends Module { @override - List get imports => [DataConnectModule()]; + List get imports => [CoreModule()]; @override void binds(Injector i) { // Repository i.addLazySingleton( - ExperienceRepositoryImpl.new, + () => ExperienceRepositoryImpl( + apiService: i.get(), + ), ); // UseCases i.addLazySingleton( - () => GetStaffIndustriesUseCase(i.get()), + () => + GetStaffIndustriesUseCase(i.get()), ); i.addLazySingleton( () => GetStaffSkillsUseCase(i.get()), diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/pubspec.yaml index 4a28daf8..6b59e8b2 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/pubspec.yaml +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/pubspec.yaml @@ -14,15 +14,12 @@ dependencies: flutter_bloc: ^8.1.0 flutter_modular: ^6.3.0 equatable: ^2.0.5 - + # Architecture Packages krow_domain: path: ../../../../../domain krow_core: path: ../../../../../core - krow_data_connect: - path: ../../../../../data_connect - firebase_auth: ^6.1.2 design_system: path: ../../../../../design_system core_localization: diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/data/repositories/personal_info_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/data/repositories/personal_info_repository_impl.dart index 439a3ba2..af633d67 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/data/repositories/personal_info_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/data/repositories/personal_info_repository_impl.dart @@ -1,119 +1,77 @@ -import 'package:firebase_data_connect/firebase_data_connect.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/repositories/personal_info_repository_interface.dart'; +import 'package:staff_profile_info/src/domain/repositories/personal_info_repository_interface.dart'; /// Implementation of [PersonalInfoRepositoryInterface] that delegates -/// to Firebase Data Connect for all data operations. +/// to the V2 REST API for all data operations. /// -/// This implementation follows Clean Architecture by: -/// - Implementing the domain's repository interface -/// - Delegating all data access to the data_connect layer -/// - Mapping between data_connect DTOs and domain entities -/// - Containing no business logic -class PersonalInfoRepositoryImpl - implements PersonalInfoRepositoryInterface { +/// Replaces the previous Firebase Data Connect implementation. +class PersonalInfoRepositoryImpl implements PersonalInfoRepositoryInterface { /// Creates a [PersonalInfoRepositoryImpl]. /// - /// Requires the Firebase Data Connect service. + /// Requires the V2 [BaseApiService] for HTTP communication, + /// [FileUploadService] for uploading files to cloud storage, and + /// [SignedUrlService] for generating signed download URLs. PersonalInfoRepositoryImpl({ - DataConnectService? service, - }) : _service = service ?? DataConnectService.instance; + required BaseApiService apiService, + required FileUploadService uploadService, + required SignedUrlService signedUrlService, + }) : _api = apiService, + _uploadService = uploadService, + _signedUrlService = signedUrlService; - final DataConnectService _service; + final BaseApiService _api; + final FileUploadService _uploadService; + final SignedUrlService _signedUrlService; @override - Future getStaffProfile() async { - return _service.run(() async { - final String uid = _service.auth.currentUser!.uid; - - // Query staff data from Firebase Data Connect - final QueryResult result = - await _service.connector.getStaffByUserId(userId: uid).execute(); - - if (result.data.staffs.isEmpty) { - throw const ServerException(technicalMessage: 'Staff profile not found'); - } - - final GetStaffByUserIdStaffs rawStaff = result.data.staffs.first; - - // Map from data_connect DTO to domain entity - return _mapToStaffEntity(rawStaff); - }); + Future getStaffProfile() async { + final ApiResponse response = + await _api.get(V2ApiEndpoints.staffPersonalInfo); + final Map json = + response.data as Map; + return StaffPersonalInfo.fromJson(json); } @override - Future updateStaffProfile( - {required String staffId, required Map data}) async { - return _service.run(() async { - // Start building the update mutation - UpdateStaffVariablesBuilder updateBuilder = - _service.connector.updateStaff(id: staffId); - - // Apply updates from map if present - if (data.containsKey('name')) { - updateBuilder = updateBuilder.fullName(data['name'] as String); - } - if (data.containsKey('email')) { - updateBuilder = updateBuilder.email(data['email'] as String); - } - if (data.containsKey('phone')) { - updateBuilder = updateBuilder.phone(data['phone'] as String?); - } - if (data.containsKey('avatar')) { - updateBuilder = updateBuilder.photoUrl(data['avatar'] as String?); - } - if (data.containsKey('preferredLocations')) { - // After schema update and SDK regeneration, preferredLocations accepts List - updateBuilder = updateBuilder.preferredLocations( - data['preferredLocations'] as List); - } - - // Execute the update - final OperationResult result = - await updateBuilder.execute(); - - if (result.data.staff_update == null) { - throw const ServerException( - technicalMessage: 'Failed to update staff profile'); - } - - // Fetch the updated staff profile to return complete entity - return getStaffProfile(); - }); + Future updateStaffProfile({ + required String staffId, + required Map data, + }) async { + final ApiResponse response = await _api.put( + V2ApiEndpoints.staffPersonalInfo, + data: data, + ); + final Map json = + response.data as Map; + return StaffPersonalInfo.fromJson(json); } @override Future uploadProfilePhoto(String filePath) async { - // TODO: Implement photo upload to Firebase Storage - // This will be implemented when Firebase Storage integration is ready - throw UnimplementedError( - 'Photo upload not yet implemented. Will integrate with Firebase Storage.', + // 1. Upload the file to cloud storage. + final FileUploadResponse uploadRes = await _uploadService.uploadFile( + filePath: filePath, + fileName: + 'staff_profile_photo_${DateTime.now().millisecondsSinceEpoch}.jpg', + visibility: FileVisibility.public, ); - } - /// Maps a data_connect Staff DTO to a domain Staff entity. - /// - /// This mapping isolates the domain from data layer implementation details. - Staff _mapToStaffEntity(GetStaffByUserIdStaffs dto) { - return Staff( - id: dto.id, - authProviderId: dto.userId, - name: dto.fullName, - email: dto.email ?? '', - phone: dto.phone, - avatar: dto.photoUrl, - status: StaffStatus.active, - address: dto.addres, - totalShifts: dto.totalShifts, - averageRating: dto.averageRating, - onTimeRate: dto.onTimeRate, - noShowCount: dto.noShowCount, - cancellationCount: dto.cancellationCount, - reliabilityScore: dto.reliabilityScore, - // After schema update and SDK regeneration, preferredLocations is List? - preferredLocations: dto.preferredLocations, + // 2. Generate a signed URL for the uploaded file. + final SignedUrlResponse signedUrlRes = + await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri); + final String photoUrl = signedUrlRes.signedUrl; + + // 3. Submit the photo URL to the V2 API. + await _api.post( + V2ApiEndpoints.staffProfilePhoto, + data: { + 'fileUri': uploadRes.fileUri, + 'photoUrl': photoUrl, + }, ); + + return photoUrl; } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/repositories/personal_info_repository_interface.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/repositories/personal_info_repository_interface.dart index da0d595d..ca2d8b62 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/repositories/personal_info_repository_interface.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/repositories/personal_info_repository_interface.dart @@ -4,24 +4,23 @@ import 'package:krow_domain/krow_domain.dart'; /// /// This repository defines the contract for loading and updating /// staff profile information during onboarding or profile editing. -/// -/// Implementations must delegate all data operations through -/// the data_connect layer, following Clean Architecture principles. abstract interface class PersonalInfoRepositoryInterface { - /// Retrieves the staff profile for the current authenticated user. + /// Retrieves the personal info for the current authenticated staff member. /// - /// Returns the complete [Staff] entity with all profile information. - Future getStaffProfile(); + /// Returns the [StaffPersonalInfo] entity with name, contact, and location data. + Future getStaffProfile(); - /// Updates the staff profile information. + /// Updates the staff personal information. /// - /// Takes a [Staff] entity ID and updated fields map and persists changes - /// through the data layer. Returns the updated [Staff] entity. - Future updateStaffProfile({required String staffId, required Map data}); + /// Takes the staff member's [staffId] and updated [data] map. + /// Returns the updated [StaffPersonalInfo] entity. + Future updateStaffProfile({ + required String staffId, + required Map data, + }); /// Uploads a profile photo and returns the URL. /// /// Takes the file path of the photo to upload. - /// Returns the URL where the photo is stored. Future uploadProfilePhoto(String filePath); } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/usecases/get_personal_info_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/usecases/get_personal_info_usecase.dart index 76402f1c..da16179a 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/usecases/get_personal_info_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/usecases/get_personal_info_usecase.dart @@ -1,22 +1,19 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../repositories/personal_info_repository_interface.dart'; -/// Use case for retrieving staff profile information. +import 'package:staff_profile_info/src/domain/repositories/personal_info_repository_interface.dart'; + +/// Use case for retrieving staff personal information. /// -/// This use case fetches the complete staff profile from the repository, -/// which delegates to the data_connect layer for data access. -class GetPersonalInfoUseCase - implements NoInputUseCase { - +/// Fetches the personal info from the V2 API via the repository. +class GetPersonalInfoUseCase implements NoInputUseCase { /// Creates a [GetPersonalInfoUseCase]. - /// - /// Requires a [PersonalInfoRepositoryInterface] to fetch data. GetPersonalInfoUseCase(this._repository); + final PersonalInfoRepositoryInterface _repository; @override - Future call() { + Future call() { return _repository.getStaffProfile(); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/usecases/update_personal_info_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/usecases/update_personal_info_usecase.dart index 5092e87e..ca16bcc9 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/usecases/update_personal_info_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/usecases/update_personal_info_usecase.dart @@ -1,14 +1,16 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../repositories/personal_info_repository_interface.dart'; -/// Arguments for updating staff profile information. +import 'package:staff_profile_info/src/domain/repositories/personal_info_repository_interface.dart'; + +/// Arguments for updating staff personal information. class UpdatePersonalInfoParams extends UseCaseArgument { - + /// Creates [UpdatePersonalInfoParams]. const UpdatePersonalInfoParams({ required this.staffId, required this.data, }); + /// The staff member's ID. final String staffId; @@ -19,21 +21,16 @@ class UpdatePersonalInfoParams extends UseCaseArgument { List get props => [staffId, data]; } -/// Use case for updating staff profile information. -/// -/// This use case updates the staff profile information -/// through the repository, which delegates to the data_connect layer. +/// Use case for updating staff personal information via the V2 API. class UpdatePersonalInfoUseCase - implements UseCase { - + implements UseCase { /// Creates an [UpdatePersonalInfoUseCase]. - /// - /// Requires a [PersonalInfoRepositoryInterface] to update data. UpdatePersonalInfoUseCase(this._repository); + final PersonalInfoRepositoryInterface _repository; @override - Future call(UpdatePersonalInfoParams params) { + Future call(UpdatePersonalInfoParams params) { return _repository.updateStaffProfile( staffId: params.staffId, data: params.data, diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/usecases/upload_profile_photo_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/usecases/upload_profile_photo_usecase.dart new file mode 100644 index 00000000..5665d04f --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/usecases/upload_profile_photo_usecase.dart @@ -0,0 +1,19 @@ +import 'package:krow_core/core.dart'; + +import 'package:staff_profile_info/src/domain/repositories/personal_info_repository_interface.dart'; + +/// Use case for uploading a staff profile photo via the V2 API. +/// +/// Accepts the local file path and returns the public URL of the +/// uploaded photo after it has been stored and registered. +class UploadProfilePhotoUseCase implements UseCase { + /// Creates an [UploadProfilePhotoUseCase]. + UploadProfilePhotoUseCase(this._repository); + + final PersonalInfoRepositoryInterface _repository; + + @override + Future call(String filePath) { + return _repository.uploadProfilePhoto(filePath); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_bloc.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_bloc.dart index 6daa1b57..c75d35f0 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_bloc.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_bloc.dart @@ -1,44 +1,48 @@ -// 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:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/usecases/get_personal_info_usecase.dart'; -import '../../domain/usecases/update_personal_info_usecase.dart'; -import 'personal_info_event.dart'; -import 'personal_info_state.dart'; +import 'package:staff_profile_info/src/domain/usecases/get_personal_info_usecase.dart'; +import 'package:staff_profile_info/src/domain/usecases/update_personal_info_usecase.dart'; +import 'package:staff_profile_info/src/domain/usecases/upload_profile_photo_usecase.dart'; +import 'package:staff_profile_info/src/presentation/blocs/personal_info_event.dart'; +import 'package:staff_profile_info/src/presentation/blocs/personal_info_state.dart'; -/// BLoC responsible for managing staff profile information state. +/// BLoC responsible for managing staff personal information state. /// -/// This BLoC handles loading, updating, and saving staff profile information -/// during onboarding or profile editing. It delegates business logic to -/// use cases following Clean Architecture principles. +/// Handles loading, updating, and saving personal information +/// via V2 API use cases following Clean Architecture. class PersonalInfoBloc extends Bloc - with BlocErrorHandler, SafeBloc + with + BlocErrorHandler, + SafeBloc implements Disposable { /// Creates a [PersonalInfoBloc]. - /// - /// Requires the use cases to load and update the profile. PersonalInfoBloc({ required GetPersonalInfoUseCase getPersonalInfoUseCase, required UpdatePersonalInfoUseCase updatePersonalInfoUseCase, - }) : _getPersonalInfoUseCase = getPersonalInfoUseCase, - _updatePersonalInfoUseCase = updatePersonalInfoUseCase, - super(const PersonalInfoState.initial()) { + required UploadProfilePhotoUseCase uploadProfilePhotoUseCase, + }) : _getPersonalInfoUseCase = getPersonalInfoUseCase, + _updatePersonalInfoUseCase = updatePersonalInfoUseCase, + _uploadProfilePhotoUseCase = uploadProfilePhotoUseCase, + super(const PersonalInfoState.initial()) { on(_onLoadRequested); on(_onFieldChanged); on(_onAddressSelected); on(_onSubmitted); on(_onLocationAdded); on(_onLocationRemoved); + on(_onPhotoUploadRequested); add(const PersonalInfoLoadRequested()); } + final GetPersonalInfoUseCase _getPersonalInfoUseCase; final UpdatePersonalInfoUseCase _updatePersonalInfoUseCase; + final UploadProfilePhotoUseCase _uploadProfilePhotoUseCase; - /// Handles loading staff profile information. + /// Handles loading staff personal information. Future _onLoadRequested( PersonalInfoLoadRequested event, Emitter emit, @@ -47,25 +51,23 @@ class PersonalInfoBloc extends Bloc await handleError( emit: emit.call, action: () async { - final Staff staff = await _getPersonalInfoUseCase(); + final StaffPersonalInfo info = await _getPersonalInfoUseCase(); - // Initialize form values from staff entity - // Note: Staff entity currently stores address as a string, but we want to map it to 'preferredLocations' final Map initialValues = { - 'name': staff.name, - 'email': staff.email, - 'phone': staff.phone, + 'firstName': info.firstName ?? '', + 'lastName': info.lastName ?? '', + 'email': info.email ?? '', + 'phone': info.phone ?? '', + 'bio': info.bio ?? '', 'preferredLocations': - staff.preferredLocations != null - ? List.from(staff.preferredLocations!) - : [], - 'avatar': staff.avatar, + List.from(info.preferredLocations), + 'maxDistanceMiles': info.maxDistanceMiles, }; emit( state.copyWith( status: PersonalInfoStatus.loaded, - staff: staff, + personalInfo: info, formValues: initialValues, ), ); @@ -77,50 +79,50 @@ class PersonalInfoBloc extends Bloc ); } - /// Handles updating a field value in the current staff profile. + /// Handles updating a field value in the current form. void _onFieldChanged( PersonalInfoFieldChanged event, Emitter emit, ) { - final Map updatedValues = Map.from(state.formValues); + final Map updatedValues = + Map.from(state.formValues); updatedValues[event.field] = event.value; emit(state.copyWith(formValues: updatedValues)); } - /// Handles saving staff profile information. + /// Handles saving staff personal information. Future _onSubmitted( PersonalInfoFormSubmitted event, Emitter emit, ) async { - if (state.staff == null) return; + if (state.personalInfo == null) return; emit(state.copyWith(status: PersonalInfoStatus.saving)); await handleError( emit: emit.call, action: () async { - final Staff updatedStaff = await _updatePersonalInfoUseCase( + final StaffPersonalInfo updated = await _updatePersonalInfoUseCase( UpdatePersonalInfoParams( - staffId: state.staff!.id, + staffId: state.personalInfo!.staffId, data: state.formValues, ), ); - // Update local state with the returned staff and keep form values in sync final Map newValues = { - 'name': updatedStaff.name, - 'email': updatedStaff.email, - 'phone': updatedStaff.phone, + 'firstName': updated.firstName ?? '', + 'lastName': updated.lastName ?? '', + 'email': updated.email ?? '', + 'phone': updated.phone ?? '', + 'bio': updated.bio ?? '', 'preferredLocations': - updatedStaff.preferredLocations != null - ? List.from(updatedStaff.preferredLocations!) - : [], - 'avatar': updatedStaff.avatar, + List.from(updated.preferredLocations), + 'maxDistanceMiles': updated.maxDistanceMiles, }; emit( state.copyWith( status: PersonalInfoStatus.saved, - staff: updatedStaff, + personalInfo: updated, formValues: newValues, ), ); @@ -132,11 +134,12 @@ class PersonalInfoBloc extends Bloc ); } + /// Legacy address selected no-op. void _onAddressSelected( PersonalInfoAddressSelected event, Emitter emit, ) { - // Legacy address selected no-op; use PersonalInfoLocationAdded instead. + // No-op; use PersonalInfoLocationAdded instead. } /// Adds a location to the preferredLocations list (max 5, no duplicates). @@ -144,15 +147,18 @@ class PersonalInfoBloc extends Bloc PersonalInfoLocationAdded event, Emitter emit, ) { - final dynamic raw = state.formValues['preferredLocations']; - final List current = _toStringList(raw); + final List current = _toStringList( + state.formValues['preferredLocations'], + ); - if (current.length >= 5) return; // max guard - if (current.contains(event.location)) return; // no duplicates + if (current.length >= 5) return; + if (current.contains(event.location)) return; - final List updated = List.from(current)..add(event.location); - final Map updatedValues = Map.from(state.formValues) - ..['preferredLocations'] = updated; + final List updated = List.from(current) + ..add(event.location); + final Map updatedValues = + Map.from(state.formValues) + ..['preferredLocations'] = updated; emit(state.copyWith(formValues: updatedValues)); } @@ -162,17 +168,62 @@ class PersonalInfoBloc extends Bloc PersonalInfoLocationRemoved event, Emitter emit, ) { - final dynamic raw = state.formValues['preferredLocations']; - final List current = _toStringList(raw); + final List current = _toStringList( + state.formValues['preferredLocations'], + ); final List updated = List.from(current) ..remove(event.location); - final Map updatedValues = Map.from(state.formValues) - ..['preferredLocations'] = updated; + final Map updatedValues = + Map.from(state.formValues) + ..['preferredLocations'] = updated; emit(state.copyWith(formValues: updatedValues)); } + /// Handles uploading a profile photo via the V2 API. + Future _onPhotoUploadRequested( + PersonalInfoPhotoUploadRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: PersonalInfoStatus.uploadingPhoto)); + await handleError( + emit: emit.call, + action: () async { + final String photoUrl = + await _uploadProfilePhotoUseCase(event.filePath); + + // Update the personalInfo entity with the new photo URL. + final StaffPersonalInfo? currentInfo = state.personalInfo; + final StaffPersonalInfo updatedInfo = StaffPersonalInfo( + staffId: currentInfo?.staffId ?? '', + firstName: currentInfo?.firstName, + lastName: currentInfo?.lastName, + bio: currentInfo?.bio, + preferredLocations: currentInfo?.preferredLocations ?? const [], + maxDistanceMiles: currentInfo?.maxDistanceMiles, + industries: currentInfo?.industries ?? const [], + skills: currentInfo?.skills ?? const [], + email: currentInfo?.email, + phone: currentInfo?.phone, + photoUrl: photoUrl, + ); + + emit( + state.copyWith( + status: PersonalInfoStatus.photoUploaded, + personalInfo: updatedInfo, + ), + ); + }, + onError: (String errorKey) => state.copyWith( + status: PersonalInfoStatus.error, + errorMessage: errorKey, + ), + ); + } + + /// Safely converts a dynamic value to a string list. List _toStringList(dynamic raw) { if (raw is List) return raw; if (raw is List) return raw.map((dynamic e) => e.toString()).toList(); @@ -184,5 +235,3 @@ class PersonalInfoBloc extends Bloc close(); } } - - diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_event.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_event.dart index b6a73841..7bb731b0 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_event.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_event.dart @@ -52,9 +52,24 @@ class PersonalInfoLocationAdded extends PersonalInfoEvent { /// Event to remove a preferred location. class PersonalInfoLocationRemoved extends PersonalInfoEvent { + /// Creates a [PersonalInfoLocationRemoved]. const PersonalInfoLocationRemoved({required this.location}); + + /// The location to remove. final String location; @override List get props => [location]; } + +/// Event to upload a profile photo from the given file path. +class PersonalInfoPhotoUploadRequested extends PersonalInfoEvent { + /// Creates a [PersonalInfoPhotoUploadRequested]. + const PersonalInfoPhotoUploadRequested({required this.filePath}); + + /// The local file path of the selected photo. + final String filePath; + + @override + List get props => [filePath]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_state.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_state.dart index 0e7fbc52..17841b40 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_state.dart @@ -21,19 +21,21 @@ enum PersonalInfoStatus { /// Uploading photo. uploadingPhoto, + /// Photo uploaded successfully. + photoUploaded, + /// An error occurred. error, } /// State for the Personal Info BLoC. /// -/// Uses the shared [Staff] entity from the domain layer. +/// Uses [StaffPersonalInfo] from the V2 domain layer. class PersonalInfoState extends Equatable { - /// Creates a [PersonalInfoState]. const PersonalInfoState({ this.status = PersonalInfoStatus.initial, - this.staff, + this.personalInfo, this.formValues = const {}, this.errorMessage, }); @@ -41,14 +43,15 @@ class PersonalInfoState extends Equatable { /// Initial state. const PersonalInfoState.initial() : status = PersonalInfoStatus.initial, - staff = null, + personalInfo = null, formValues = const {}, errorMessage = null; + /// The current status of the operation. final PersonalInfoStatus status; - /// The staff profile information. - final Staff? staff; + /// The staff personal information. + final StaffPersonalInfo? personalInfo; /// The form values being edited. final Map formValues; @@ -59,18 +62,19 @@ class PersonalInfoState extends Equatable { /// Creates a copy of this state with the given fields replaced. PersonalInfoState copyWith({ PersonalInfoStatus? status, - Staff? staff, + StaffPersonalInfo? personalInfo, Map? formValues, String? errorMessage, }) { return PersonalInfoState( status: status ?? this.status, - staff: staff ?? this.staff, + personalInfo: personalInfo ?? this.personalInfo, formValues: formValues ?? this.formValues, errorMessage: errorMessage, ); } @override - List get props => [status, staff, formValues, errorMessage]; + List get props => + [status, personalInfo, formValues, errorMessage]; } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart index b450f4d7..270b117b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart @@ -9,14 +9,10 @@ import 'package:staff_profile_info/src/presentation/blocs/personal_info_state.da import 'package:staff_profile_info/src/presentation/widgets/personal_info_page/personal_info_content.dart'; import 'package:staff_profile_info/src/presentation/widgets/personal_info_skeleton/personal_info_skeleton.dart'; - /// The Personal Info page for staff onboarding. /// -/// This page allows staff members to view and edit their personal information -/// including phone number and address. Full name and email are read-only as they come from authentication. -/// -/// This page is a StatelessWidget that uses BLoC for state management, -/// following Clean Architecture and the design system guidelines. +/// Allows staff members to view and edit their personal information +/// including phone number and address. Uses V2 API via BLoC. class PersonalInfoPage extends StatelessWidget { /// Creates a [PersonalInfoPage]. const PersonalInfoPage({super.key}); @@ -37,6 +33,12 @@ class PersonalInfoPage extends StatelessWidget { type: UiSnackbarType.success, ); Modular.to.popSafe(); + } else if (state.status == PersonalInfoStatus.photoUploaded) { + UiSnackbar.show( + context, + message: i18n.photo_upload_success, + type: UiSnackbarType.success, + ); } else if (state.status == PersonalInfoStatus.error) { UiSnackbar.show( context, @@ -60,7 +62,7 @@ class PersonalInfoPage extends StatelessWidget { return const PersonalInfoSkeleton(); } - if (state.staff == null) { + if (state.personalInfo == null) { return Center( child: Text( 'Failed to load personal information', @@ -69,7 +71,9 @@ class PersonalInfoPage extends StatelessWidget { ); } - return PersonalInfoContent(staff: state.staff!); + return PersonalInfoContent( + personalInfo: state.personalInfo!, + ); }, ), ), diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/personal_info_content.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/personal_info_content.dart index 9481bac6..133c5cb2 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/personal_info_content.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/personal_info_content.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:staff_profile_info/src/presentation/blocs/personal_info_bloc.dart'; import 'package:staff_profile_info/src/presentation/blocs/personal_info_event.dart'; @@ -12,18 +14,16 @@ import 'package:staff_profile_info/src/presentation/widgets/save_button.dart'; /// Content widget that displays and manages the staff profile form. /// -/// This widget is extracted from the page to handle form state separately, -/// following Clean Architecture's separation of concerns principle and the design system guidelines. -/// Works with the shared [Staff] entity from the domain layer. +/// Works with [StaffPersonalInfo] from the V2 domain layer. class PersonalInfoContent extends StatefulWidget { - /// Creates a [PersonalInfoContent]. const PersonalInfoContent({ super.key, - required this.staff, + required this.personalInfo, }); - /// The staff profile to display and edit. - final Staff staff; + + /// The staff personal info to display and edit. + final StaffPersonalInfo personalInfo; @override State createState() => _PersonalInfoContentState(); @@ -36,10 +36,13 @@ class _PersonalInfoContentState extends State { @override void initState() { super.initState(); - _emailController = TextEditingController(text: widget.staff.email); - _phoneController = TextEditingController(text: widget.staff.phone ?? ''); + _emailController = TextEditingController( + text: widget.personalInfo.email ?? '', + ); + _phoneController = TextEditingController( + text: widget.personalInfo.phone ?? '', + ); - // Listen to changes and update BLoC _emailController.addListener(_onEmailChanged); _phoneController.addListener(_onPhoneChanged); } @@ -51,42 +54,120 @@ class _PersonalInfoContentState extends State { super.dispose(); } - void _onEmailChanged() { - context.read().add( - PersonalInfoFieldChanged( - field: 'email', - value: _emailController.text, - ), - ); + ReadContext(context).read().add( + PersonalInfoFieldChanged( + field: 'email', + value: _emailController.text, + ), + ); } void _onPhoneChanged() { - context.read().add( - PersonalInfoFieldChanged( - field: 'phone', - value: _phoneController.text, - ), - ); + ReadContext(context).read().add( + PersonalInfoFieldChanged( + field: 'phone', + value: _phoneController.text, + ), + ); } void _handleSave() { - context.read().add(const PersonalInfoFormSubmitted()); + ReadContext(context).read().add(const PersonalInfoFormSubmitted()); } - void _handlePhotoTap() { - // TODO: Implement photo picker - // context.read().add( - // PersonalInfoPhotoUploadRequested(filePath: pickedFilePath), - // ); + /// Shows a bottom sheet to choose between camera and gallery, then + /// dispatches the upload event to the BLoC. + Future _handlePhotoTap() async { + final TranslationsStaffOnboardingPersonalInfoEn i18n = + t.staff.onboarding.personal_info; + final TranslationsCommonEn common = t.common; + + final String? source = await showModalBottomSheet( + context: context, + builder: (BuildContext ctx) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space4, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only( + bottom: UiConstants.space4, + ), + child: Text( + i18n.choose_photo_source, + style: UiTypography.body1b.textPrimary, + ), + ), + ListTile( + leading: const Icon( + UiIcons.camera, + color: UiColors.primary, + ), + title: Text( + common.camera, + style: UiTypography.body1r.textPrimary, + ), + onTap: () => Navigator.pop(ctx, 'camera'), + ), + ListTile( + leading: const Icon( + UiIcons.gallery, + color: UiColors.primary, + ), + title: Text( + common.gallery, + style: UiTypography.body1r.textPrimary, + ), + onTap: () => Navigator.pop(ctx, 'gallery'), + ), + ], + ), + ), + ); + }, + ); + + if (source == null || !mounted) return; + + String? filePath; + if (source == 'camera') { + final CameraService cameraService = Modular.get(); + filePath = await cameraService.takePhoto(); + } else { + final GalleryService galleryService = Modular.get(); + filePath = await galleryService.pickImage(); + } + + if (filePath == null || !mounted) return; + + ReadContext(context).read().add( + PersonalInfoPhotoUploadRequested(filePath: filePath), + ); + } + + /// Computes the display name from personal info first/last name. + String get _displayName { + final String first = widget.personalInfo.firstName ?? ''; + final String last = widget.personalInfo.lastName ?? ''; + final String name = '$first $last'.trim(); + return name.isNotEmpty ? name : 'Staff'; } @override Widget build(BuildContext context) { - final TranslationsStaffOnboardingPersonalInfoEn i18n = t.staff.onboarding.personal_info; + final TranslationsStaffOnboardingPersonalInfoEn i18n = + t.staff.onboarding.personal_info; return BlocBuilder( builder: (BuildContext context, PersonalInfoState state) { final bool isSaving = state.status == PersonalInfoStatus.saving; + final bool isUploadingPhoto = + state.status == PersonalInfoStatus.uploadingPhoto; + final bool isBusy = isSaving || isUploadingPhoto; return Column( children: [ Expanded( @@ -96,26 +177,29 @@ class _PersonalInfoContentState extends State { crossAxisAlignment: CrossAxisAlignment.center, children: [ ProfilePhotoWidget( - photoUrl: widget.staff.avatar, - fullName: widget.staff.name, - onTap: isSaving ? null : _handlePhotoTap, + photoUrl: state.personalInfo?.photoUrl, + fullName: _displayName, + onTap: isBusy ? null : _handlePhotoTap, + isUploading: isUploadingPhoto, ), const SizedBox(height: UiConstants.space6), PersonalInfoForm( - fullName: widget.staff.name, - email: widget.staff.email, + fullName: _displayName, + email: widget.personalInfo.email ?? '', emailController: _emailController, phoneController: _phoneController, - currentLocations: _toStringList(state.formValues['preferredLocations']), - enabled: !isSaving, + currentLocations: _toStringList( + state.formValues['preferredLocations'], + ), + enabled: !isBusy, ), - const SizedBox(height: UiConstants.space16), // Space for bottom button + const SizedBox(height: UiConstants.space16), ], ), ), ), SaveButton( - onPressed: isSaving ? null : _handleSave, + onPressed: isBusy ? null : _handleSave, label: i18n.save_button, isLoading: isSaving, ), @@ -125,6 +209,7 @@ class _PersonalInfoContentState extends State { ); } + /// Safely converts a dynamic value to a string list. List _toStringList(dynamic raw) { if (raw is List) return raw; if (raw is List) return raw.map((dynamic e) => e.toString()).toList(); diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/profile_photo_widget.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/profile_photo_widget.dart index 0abb3513..1744a081 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/profile_photo_widget.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/profile_photo_widget.dart @@ -16,7 +16,9 @@ class ProfilePhotoWidget extends StatelessWidget { required this.photoUrl, required this.fullName, required this.onTap, + this.isUploading = false, }); + /// The URL of the staff member's photo. final String? photoUrl; @@ -26,6 +28,9 @@ class ProfilePhotoWidget extends StatelessWidget { /// Callback when the photo/camera button is tapped. final VoidCallback? onTap; + /// Whether a photo upload is currently in progress. + final bool isUploading; + @override Widget build(BuildContext context) { final TranslationsStaffOnboardingPersonalInfoEn i18n = @@ -44,19 +49,34 @@ class ProfilePhotoWidget extends StatelessWidget { shape: BoxShape.circle, color: UiColors.primary.withValues(alpha: 0.1), ), - child: photoUrl != null - ? ClipOval( - child: Image.network( - photoUrl!, - fit: BoxFit.cover, + child: isUploading + ? const Center( + child: SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator( + strokeWidth: 2, + color: UiColors.primary, + ), ), ) - : Center( - child: Text( - fullName.isNotEmpty ? fullName[0].toUpperCase() : '?', - style: UiTypography.displayL.primary, - ), - ), + : photoUrl != null + ? ClipOval( + child: Image.network( + photoUrl!, + width: 96, + height: 96, + fit: BoxFit.cover, + ), + ) + : Center( + child: Text( + fullName.isNotEmpty + ? fullName[0].toUpperCase() + : '?', + style: UiTypography.displayL.primary, + ), + ), ), Positioned( bottom: 0, diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/staff_profile_info_module.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/staff_profile_info_module.dart index d9617e9b..c7a47872 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/staff_profile_info_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/staff_profile_info_module.dart @@ -1,47 +1,57 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; -import 'data/repositories/personal_info_repository_impl.dart'; -import 'domain/repositories/personal_info_repository_interface.dart'; -import 'domain/usecases/get_personal_info_usecase.dart'; -import 'domain/usecases/update_personal_info_usecase.dart'; -import 'presentation/blocs/personal_info_bloc.dart'; -import 'presentation/pages/personal_info_page.dart'; -import 'presentation/pages/language_selection_page.dart'; -import 'presentation/pages/preferred_locations_page.dart'; +import 'package:staff_profile_info/src/data/repositories/personal_info_repository_impl.dart'; +import 'package:staff_profile_info/src/domain/repositories/personal_info_repository_interface.dart'; +import 'package:staff_profile_info/src/domain/usecases/get_personal_info_usecase.dart'; +import 'package:staff_profile_info/src/domain/usecases/update_personal_info_usecase.dart'; +import 'package:staff_profile_info/src/domain/usecases/upload_profile_photo_usecase.dart'; +import 'package:staff_profile_info/src/presentation/blocs/personal_info_bloc.dart'; +import 'package:staff_profile_info/src/presentation/pages/personal_info_page.dart'; +import 'package:staff_profile_info/src/presentation/pages/language_selection_page.dart'; +import 'package:staff_profile_info/src/presentation/pages/preferred_locations_page.dart'; /// The entry module for the Staff Profile Info feature. /// -/// This module provides routing and dependency injection for -/// personal information functionality following Clean Architecture. -/// -/// The module: -/// - Registers repository implementations -/// - Registers use cases that contain business logic -/// - Registers BLoC for state management -/// - Defines routes for navigation +/// Provides routing and dependency injection for personal information +/// functionality, using the V2 REST API via [BaseApiService]. class StaffProfileInfoModule extends Module { + @override + List get imports => [CoreModule()]; + @override void binds(Injector i) { // Repository i.addLazySingleton( - PersonalInfoRepositoryImpl.new, + () => PersonalInfoRepositoryImpl( + apiService: i.get(), + uploadService: i.get(), + signedUrlService: i.get(), + ), ); - // Use Cases - delegate business logic to repository + // Use Cases i.addLazySingleton( () => GetPersonalInfoUseCase(i.get()), ); i.addLazySingleton( - () => UpdatePersonalInfoUseCase(i.get()), + () => + UpdatePersonalInfoUseCase(i.get()), + ); + i.addLazySingleton( + () => UploadProfilePhotoUseCase( + i.get(), + ), ); - // BLoC - manages presentation state + // BLoC i.addLazySingleton( () => PersonalInfoBloc( getPersonalInfoUseCase: i.get(), updatePersonalInfoUseCase: i.get(), + uploadProfilePhotoUseCase: i.get(), ), ); } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/pubspec.yaml index a3853419..e8c7b321 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/pubspec.yaml +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/pubspec.yaml @@ -15,7 +15,7 @@ dependencies: bloc: ^8.1.0 flutter_modular: ^6.3.0 equatable: ^2.0.5 - + # Architecture Packages design_system: path: ../../../../../design_system @@ -25,13 +25,10 @@ dependencies: path: ../../../../../core krow_domain: path: ../../../../../domain - krow_data_connect: - path: ../../../../../data_connect - firebase_auth: any - firebase_data_connect: any google_places_flutter: ^2.1.1 http: ^1.2.2 + dev_dependencies: flutter_test: sdk: flutter diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/data/repositories_impl/faqs_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/data/repositories_impl/faqs_repository_impl.dart index 4bcc2ccd..ec200d89 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/data/repositories_impl/faqs_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/data/repositories_impl/faqs_repository_impl.dart @@ -1,58 +1,26 @@ -import 'dart:convert'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; -import 'package:flutter/services.dart'; +import 'package:staff_faqs/src/domain/entities/faq_category.dart'; +import 'package:staff_faqs/src/domain/repositories/faqs_repository_interface.dart'; -import '../../domain/entities/faq_category.dart'; -import '../../domain/entities/faq_item.dart'; -import '../../domain/repositories/faqs_repository_interface.dart'; - -/// Data layer implementation of FAQs repository +/// V2 API implementation of [FaqsRepositoryInterface]. /// -/// Handles loading FAQs from app assets (JSON file) +/// Fetches FAQ data from the V2 REST backend via [ApiService]. class FaqsRepositoryImpl implements FaqsRepositoryInterface { - /// Private cache for FAQs to avoid reloading from assets multiple times - List? _cachedFaqs; + /// Creates a [FaqsRepositoryImpl] backed by the given [apiService]. + FaqsRepositoryImpl({required ApiService apiService}) + : _apiService = apiService; + + final ApiService _apiService; @override Future> getFaqs() async { try { - // Return cached FAQs if available - if (_cachedFaqs != null) { - return _cachedFaqs!; - } - - // Load FAQs from JSON asset - final String faqsJson = await rootBundle.loadString( - 'packages/staff_faqs/lib/src/assets/faqs/faqs.json', - ); - - // Parse JSON - final List decoded = jsonDecode(faqsJson) as List; - - // Convert to domain entities - _cachedFaqs = decoded.map((dynamic item) { - final Map category = item as Map; - final String categoryName = category['category'] as String; - final List questionsData = - category['questions'] as List; - - final List questions = questionsData.map((dynamic q) { - final Map questionMap = q as Map; - return FaqItem( - question: questionMap['q'] as String, - answer: questionMap['a'] as String, - ); - }).toList(); - - return FaqCategory( - category: categoryName, - questions: questions, - ); - }).toList(); - - return _cachedFaqs!; - } catch (e) { - // Return empty list on error + final ApiResponse response = + await _apiService.get(V2ApiEndpoints.staffFaqs); + return _parseCategories(response); + } catch (_) { return []; } } @@ -60,42 +28,24 @@ class FaqsRepositoryImpl implements FaqsRepositoryInterface { @override Future> searchFaqs(String query) async { try { - // Get all FAQs first - final List allFaqs = await getFaqs(); - - if (query.isEmpty) { - return allFaqs; - } - - final String lowerQuery = query.toLowerCase(); - - // Filter categories based on matching questions - final List filtered = allFaqs - .map((FaqCategory category) { - // Filter questions that match the query - final List matchingQuestions = - category.questions.where((FaqItem item) { - final String questionLower = item.question.toLowerCase(); - final String answerLower = item.answer.toLowerCase(); - return questionLower.contains(lowerQuery) || - answerLower.contains(lowerQuery); - }).toList(); - - // Only include category if it has matching questions - if (matchingQuestions.isNotEmpty) { - return FaqCategory( - category: category.category, - questions: matchingQuestions, - ); - } - return null; - }) - .whereType() - .toList(); - - return filtered; - } catch (e) { + final ApiResponse response = await _apiService.get( + V2ApiEndpoints.staffFaqsSearch, + params: {'q': query}, + ); + return _parseCategories(response); + } catch (_) { return []; } } + + /// Parses the `items` array from a V2 API response into [FaqCategory] list. + List _parseCategories(ApiResponse response) { + final List items = response.data['items'] as List; + return items + .map( + (dynamic item) => + FaqCategory.fromJson(item as Map), + ) + .toList(); + } } diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_category.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_category.dart index c33b52de..bc973461 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_category.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_category.dart @@ -1,18 +1,35 @@ import 'package:equatable/equatable.dart'; -import 'faq_item.dart'; +import 'package:staff_faqs/src/domain/entities/faq_item.dart'; -/// Entity representing an FAQ category with its questions +/// Entity representing an FAQ category with its questions. class FaqCategory extends Equatable { - + /// Creates a [FaqCategory] with the given [category] name and [questions]. const FaqCategory({ required this.category, required this.questions, }); - /// The category name (e.g., "Getting Started", "Shifts & Work") + + /// Deserializes a [FaqCategory] from a V2 API JSON map. + /// + /// The API returns question items under the `items` key. + factory FaqCategory.fromJson(Map json) { + final List items = json['items'] as List; + return FaqCategory( + category: json['category'] as String, + questions: items + .map( + (dynamic item) => + FaqItem.fromJson(item as Map), + ) + .toList(), + ); + } + + /// The category name (e.g., "Getting Started", "Shifts & Work"). final String category; - /// List of FAQ items in this category + /// List of FAQ items in this category. final List questions; @override diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_item.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_item.dart index e00f8de1..f6c3c13c 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_item.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_item.dart @@ -1,16 +1,25 @@ import 'package:equatable/equatable.dart'; -/// Entity representing a single FAQ question and answer +/// Entity representing a single FAQ question and answer. class FaqItem extends Equatable { - + /// Creates a [FaqItem] with the given [question] and [answer]. const FaqItem({ required this.question, required this.answer, }); - /// The question text + + /// Deserializes a [FaqItem] from a JSON map. + factory FaqItem.fromJson(Map json) { + return FaqItem( + question: json['question'] as String, + answer: json['answer'] as String, + ); + } + + /// The question text. final String question; - /// The answer text + /// The answer text. final String answer; @override diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/repositories/faqs_repository_interface.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/repositories/faqs_repository_interface.dart index 887ea0d1..c81b0065 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/repositories/faqs_repository_interface.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/repositories/faqs_repository_interface.dart @@ -1,4 +1,4 @@ -import '../entities/faq_category.dart'; +import 'package:staff_faqs/src/domain/entities/faq_category.dart'; /// Interface for FAQs repository operations abstract class FaqsRepositoryInterface { diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/get_faqs_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/get_faqs_usecase.dart index 4dc83c12..3dcce265 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/get_faqs_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/get_faqs_usecase.dart @@ -1,5 +1,5 @@ -import '../entities/faq_category.dart'; -import '../repositories/faqs_repository_interface.dart'; +import 'package:staff_faqs/src/domain/entities/faq_category.dart'; +import 'package:staff_faqs/src/domain/repositories/faqs_repository_interface.dart'; /// Use case to retrieve all FAQs class GetFaqsUseCase { diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/search_faqs_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/search_faqs_usecase.dart index ef0ae5c1..97a3685b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/search_faqs_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/search_faqs_usecase.dart @@ -1,5 +1,5 @@ -import '../entities/faq_category.dart'; -import '../repositories/faqs_repository_interface.dart'; +import 'package:staff_faqs/src/domain/entities/faq_category.dart'; +import 'package:staff_faqs/src/domain/repositories/faqs_repository_interface.dart'; /// Parameters for search FAQs use case class SearchFaqsParams { diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_bloc.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_bloc.dart index 72dbb262..5620899f 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_bloc.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_bloc.dart @@ -1,9 +1,8 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; - -import '../../domain/entities/faq_category.dart'; -import '../../domain/usecases/get_faqs_usecase.dart'; -import '../../domain/usecases/search_faqs_usecase.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:staff_faqs/src/domain/entities/faq_category.dart'; +import 'package:staff_faqs/src/domain/usecases/get_faqs_usecase.dart'; +import 'package:staff_faqs/src/domain/usecases/search_faqs_usecase.dart'; part 'faqs_event.dart'; part 'faqs_state.dart'; diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/pages/faqs_page.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/pages/faqs_page.dart index b1598d5b..56ce1b45 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/pages/faqs_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/pages/faqs_page.dart @@ -4,8 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import '../blocs/faqs_bloc.dart'; -import '../widgets/faqs_widget.dart'; +import 'package:staff_faqs/src/presentation/blocs/faqs_bloc.dart'; +import 'package:staff_faqs/src/presentation/widgets/faqs_widget.dart'; /// Page displaying frequently asked questions class FaqsPage extends StatelessWidget { diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_skeleton/faqs_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_skeleton/faqs_skeleton.dart index 5ab1e2f8..5ec3861d 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_skeleton/faqs_skeleton.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_skeleton/faqs_skeleton.dart @@ -1,7 +1,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'faq_item_skeleton.dart'; +import 'package:staff_faqs/src/presentation/widgets/faqs_skeleton/faq_item_skeleton.dart'; /// Full-page shimmer skeleton shown while FAQs are loading. class FaqsSkeleton extends StatelessWidget { diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_widget.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_widget.dart index 80b1f00f..66fa95ab 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_widget.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_widget.dart @@ -3,7 +3,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:staff_faqs/src/presentation/blocs/faqs_bloc.dart'; -import 'faqs_skeleton/faqs_skeleton.dart'; +import 'package:staff_faqs/src/presentation/widgets/faqs_skeleton/faqs_skeleton.dart'; /// Widget displaying FAQs with search functionality and accordion items class FaqsWidget extends StatefulWidget { diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/staff_faqs_module.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/staff_faqs_module.dart index a7e9da46..f3da2ab6 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/staff_faqs_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/staff_faqs_module.dart @@ -1,24 +1,28 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'data/repositories_impl/faqs_repository_impl.dart'; -import 'domain/repositories/faqs_repository_interface.dart'; -import 'domain/usecases/get_faqs_usecase.dart'; -import 'domain/usecases/search_faqs_usecase.dart'; -import 'presentation/blocs/faqs_bloc.dart'; -import 'presentation/pages/faqs_page.dart'; +import 'package:staff_faqs/src/data/repositories_impl/faqs_repository_impl.dart'; +import 'package:staff_faqs/src/domain/repositories/faqs_repository_interface.dart'; +import 'package:staff_faqs/src/domain/usecases/get_faqs_usecase.dart'; +import 'package:staff_faqs/src/domain/usecases/search_faqs_usecase.dart'; +import 'package:staff_faqs/src/presentation/blocs/faqs_bloc.dart'; +import 'package:staff_faqs/src/presentation/pages/faqs_page.dart'; -/// Module for FAQs feature +/// Module for the FAQs feature. /// -/// Provides: -/// - Dependency injection for repositories, use cases, and BLoCs -/// - Route definitions delegated to core routing +/// Provides dependency injection for repositories, use cases, and BLoCs, +/// plus route definitions delegated to core routing. class FaqsModule extends Module { + @override + List get imports => [CoreModule()]; + @override void binds(Injector i) { // Repository i.addLazySingleton( - () => FaqsRepositoryImpl(), + () => FaqsRepositoryImpl( + apiService: i(), + ), ); // Use Cases diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/support/faqs/pubspec.yaml index e50b0511..92fd442c 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/faqs/pubspec.yaml +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/pubspec.yaml @@ -14,10 +14,12 @@ dependencies: flutter_bloc: ^8.1.0 flutter_modular: ^6.3.0 equatable: ^2.0.5 - + # Architecture Packages krow_core: path: ../../../../../core + krow_domain: + path: ../../../../../domain design_system: path: ../../../../../design_system core_localization: @@ -25,5 +27,3 @@ dependencies: flutter: uses-material-design: true - assets: - - lib/src/assets/faqs/ diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/data/repositories_impl/privacy_settings_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/data/repositories_impl/privacy_settings_repository_impl.dart index 66225fc4..8001306c 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/data/repositories_impl/privacy_settings_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/data/repositories_impl/privacy_settings_repository_impl.dart @@ -1,92 +1,59 @@ -import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; import 'package:flutter/services.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; -import '../../domain/repositories/privacy_settings_repository_interface.dart'; +import 'package:staff_privacy_security/src/domain/repositories/privacy_settings_repository_interface.dart'; -/// Data layer implementation of privacy settings repository +/// Implementation of [PrivacySettingsRepositoryInterface] using the V2 API +/// for privacy settings and app assets for legal documents. /// -/// Handles all backend communication for privacy settings via Data Connect, -/// and loads legal documents from app assets +/// Replaces the previous Firebase Data Connect implementation. class PrivacySettingsRepositoryImpl implements PrivacySettingsRepositoryInterface { - PrivacySettingsRepositoryImpl(this._service); + /// Creates a [PrivacySettingsRepositoryImpl]. + PrivacySettingsRepositoryImpl({required BaseApiService apiService}) + : _api = apiService; - final DataConnectService _service; + final BaseApiService _api; @override Future getProfileVisibility() async { - return _service.run(() async { - // Get current user ID - final String staffId = await _service.getStaffId(); - - // Call Data Connect query: getStaffProfileVisibility - final fdc.QueryResult< - GetStaffProfileVisibilityData, - GetStaffProfileVisibilityVariables - > - response = await _service.connector - .getStaffProfileVisibility(staffId: staffId) - .execute(); - - // Return the profile visibility status from the first result - if (response.data.staff != null) { - return response.data.staff?.isProfileVisible ?? true; - } - - // Default to visible if no staff record found - return true; - }); + final ApiResponse response = + await _api.get(V2ApiEndpoints.staffPrivacy); + final Map json = + response.data as Map; + final PrivacySettings settings = PrivacySettings.fromJson(json); + return settings.profileVisible; } @override Future updateProfileVisibility(bool isVisible) async { - return _service.run(() async { - // Get staff ID for the current user - final String staffId = await _service.getStaffId(); - - // Call Data Connect mutation: UpdateStaffProfileVisibility - await _service.connector - .updateStaffProfileVisibility( - id: staffId, - isProfileVisible: isVisible, - ) - .execute(); - - // Return the requested visibility state - return isVisible; - }); + await _api.put( + V2ApiEndpoints.staffPrivacy, + data: {'profileVisible': isVisible}, + ); + return isVisible; } @override Future getTermsOfService() async { - return _service.run(() async { - try { - // Load from package asset path - return await rootBundle.loadString( - 'packages/staff_privacy_security/lib/src/assets/legal/terms_of_service.txt', - ); - } catch (e) { - // Final fallback if asset not found - print('Error loading terms of service: $e'); - return 'Terms of Service - Content unavailable. Please contact support@krow.com'; - } - }); + try { + return await rootBundle.loadString( + 'packages/staff_privacy_security/lib/src/assets/legal/terms_of_service.txt', + ); + } catch (e) { + return 'Terms of Service - Content unavailable. Please contact support@krow.com'; + } } @override Future getPrivacyPolicy() async { - return _service.run(() async { - try { - // Load from package asset path - return await rootBundle.loadString( - 'packages/staff_privacy_security/lib/src/assets/legal/privacy_policy.txt', - ); - } catch (e) { - // Final fallback if asset not found - print('Error loading privacy policy: $e'); - return 'Privacy Policy - Content unavailable. Please contact privacy@krow.com'; - } - }); + try { + return await rootBundle.loadString( + 'packages/staff_privacy_security/lib/src/assets/legal/privacy_policy.txt', + ); + } catch (e) { + return 'Privacy Policy - Content unavailable. Please contact privacy@krow.com'; + } } } diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/staff_privacy_security_module.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/staff_privacy_security_module.dart index 81ce8a74..39bd3ed0 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/staff_privacy_security_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/staff_privacy_security_module.dart @@ -1,33 +1,35 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_domain/krow_domain.dart'; -import 'data/repositories_impl/privacy_settings_repository_impl.dart'; -import 'domain/repositories/privacy_settings_repository_interface.dart'; -import 'domain/usecases/get_privacy_policy_usecase.dart'; -import 'domain/usecases/get_profile_visibility_usecase.dart'; -import 'domain/usecases/get_terms_usecase.dart'; -import 'domain/usecases/update_profile_visibility_usecase.dart'; -import 'presentation/blocs/legal/privacy_policy_cubit.dart'; -import 'presentation/blocs/legal/terms_cubit.dart'; -import 'presentation/blocs/privacy_security_bloc.dart'; -import 'presentation/pages/legal/privacy_policy_page.dart'; -import 'presentation/pages/legal/terms_of_service_page.dart'; -import 'presentation/pages/privacy_security_page.dart'; +import 'package:staff_privacy_security/src/data/repositories_impl/privacy_settings_repository_impl.dart'; +import 'package:staff_privacy_security/src/domain/repositories/privacy_settings_repository_interface.dart'; +import 'package:staff_privacy_security/src/domain/usecases/get_privacy_policy_usecase.dart'; +import 'package:staff_privacy_security/src/domain/usecases/get_profile_visibility_usecase.dart'; +import 'package:staff_privacy_security/src/domain/usecases/get_terms_usecase.dart'; +import 'package:staff_privacy_security/src/domain/usecases/update_profile_visibility_usecase.dart'; +import 'package:staff_privacy_security/src/presentation/blocs/legal/privacy_policy_cubit.dart'; +import 'package:staff_privacy_security/src/presentation/blocs/legal/terms_cubit.dart'; +import 'package:staff_privacy_security/src/presentation/blocs/privacy_security_bloc.dart'; +import 'package:staff_privacy_security/src/presentation/pages/legal/privacy_policy_page.dart'; +import 'package:staff_privacy_security/src/presentation/pages/legal/terms_of_service_page.dart'; +import 'package:staff_privacy_security/src/presentation/pages/privacy_security_page.dart'; -/// Module for privacy security feature -/// -/// Provides: -/// - Dependency injection for repositories, use cases, and BLoCs -/// - Route definitions delegated to core routing +/// Module for the Privacy Security feature. +/// +/// Uses the V2 REST API via [BaseApiService] for privacy settings, +/// and app assets for legal document content. class PrivacySecurityModule extends Module { + @override + List get imports => [CoreModule()]; + @override void binds(Injector i) { // Repository i.addLazySingleton( () => PrivacySettingsRepositoryImpl( - Modular.get(), + apiService: i.get(), ), ); @@ -79,7 +81,6 @@ class PrivacySecurityModule extends Module { @override void routes(RouteManager r) { - // Main privacy security page r.child( StaffPaths.childRoute( StaffPaths.privacySecurity, @@ -87,8 +88,6 @@ class PrivacySecurityModule extends Module { ), child: (BuildContext context) => const PrivacySecurityPage(), ); - - // Terms of Service page r.child( StaffPaths.childRoute( StaffPaths.privacySecurity, @@ -96,8 +95,6 @@ class PrivacySecurityModule extends Module { ), child: (BuildContext context) => const TermsOfServicePage(), ); - - // Privacy Policy page r.child( StaffPaths.childRoute( StaffPaths.privacySecurity, diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/pubspec.yaml index d55e3e24..7be91509 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/pubspec.yaml +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/pubspec.yaml @@ -14,14 +14,11 @@ dependencies: flutter_bloc: ^8.1.0 flutter_modular: ^6.3.0 equatable: ^2.0.5 - firebase_data_connect: ^0.2.2+1 url_launcher: ^6.2.0 - + # Architecture Packages krow_domain: path: ../../../../../domain - krow_data_connect: - path: ../../../../../data_connect krow_core: path: ../../../../../core design_system: @@ -29,7 +26,6 @@ dependencies: core_localization: path: ../../../../../core_localization - dev_dependencies: flutter_test: sdk: flutter diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart index a41c5e1f..f891e208 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart @@ -1,103 +1,158 @@ -import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/repositories/shifts_repository_interface.dart'; -/// Implementation of [ShiftsRepositoryInterface] that delegates to [dc.ShiftsConnectorRepository]. +import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; + +/// V2 API implementation of [ShiftsRepositoryInterface]. /// -/// This implementation follows the "Buffer Layer" pattern by using a dedicated -/// connector repository from the data_connect package. +/// Uses [BaseApiService] with [V2ApiEndpoints] for all network access. class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { - final dc.ShiftsConnectorRepository _connectorRepository; - final dc.DataConnectService _service; + /// Creates a [ShiftsRepositoryImpl]. + ShiftsRepositoryImpl({required BaseApiService apiService}) + : _apiService = apiService; - ShiftsRepositoryImpl({ - dc.ShiftsConnectorRepository? connectorRepository, - dc.DataConnectService? service, - }) : _connectorRepository = connectorRepository ?? - dc.DataConnectService.instance.getShiftsRepository(), - _service = service ?? dc.DataConnectService.instance; + /// The API service used for network requests. + final BaseApiService _apiService; + + /// Extracts a list of items from the API response data. + /// + /// Handles both the V2 wrapped `{"items": [...]}` shape and a raw + /// `List` for backwards compatibility. + List _extractItems(dynamic data) { + if (data is List) { + return data; + } + if (data is Map) { + return data['items'] as List? ?? []; + } + return []; + } @override - Future> getMyShifts({ + Future> getAssignedShifts({ required DateTime start, required DateTime end, }) async { - final staffId = await _service.getStaffId(); - return _connectorRepository.getMyShifts( - staffId: staffId, - start: start, - end: end, + final ApiResponse response = await _apiService.get( + V2ApiEndpoints.staffShiftsAssigned, + params: { + 'startDate': start.toIso8601String(), + 'endDate': end.toIso8601String(), + }, ); + final List items = _extractItems(response.data); + return items + .map((dynamic json) => + AssignedShift.fromJson(json as Map)) + .toList(); } @override - Future> getPendingAssignments() async { - final staffId = await _service.getStaffId(); - return _connectorRepository.getPendingAssignments(staffId: staffId); - } - - @override - Future> getCancelledShifts() async { - final staffId = await _service.getStaffId(); - return _connectorRepository.getCancelledShifts(staffId: staffId); - } - - @override - Future> getHistoryShifts() async { - final staffId = await _service.getStaffId(); - return _connectorRepository.getHistoryShifts(staffId: staffId); - } - - @override - Future> getAvailableShifts(String query, String type) async { - final staffId = await _service.getStaffId(); - return _connectorRepository.getAvailableShifts( - staffId: staffId, - query: query, - type: type, + Future> getOpenShifts({ + String? search, + int limit = 20, + }) async { + final Map params = { + 'limit': limit, + }; + if (search != null && search.isNotEmpty) { + params['search'] = search; + } + final ApiResponse response = await _apiService.get( + V2ApiEndpoints.staffShiftsOpen, + params: params, ); + final List items = _extractItems(response.data); + return items + .map( + (dynamic json) => OpenShift.fromJson(json as Map)) + .toList(); } @override - Future getShiftDetails(String shiftId, {String? roleId}) async { - final staffId = await _service.getStaffId(); - return _connectorRepository.getShiftDetails( - shiftId: shiftId, - staffId: staffId, - roleId: roleId, - ); + Future> getPendingAssignments() async { + final ApiResponse response = + await _apiService.get(V2ApiEndpoints.staffShiftsPending); + final List items = _extractItems(response.data); + return items + .map((dynamic json) => + PendingAssignment.fromJson(json as Map)) + .toList(); + } + + @override + Future> getCancelledShifts() async { + final ApiResponse response = + await _apiService.get(V2ApiEndpoints.staffShiftsCancelled); + final List items = _extractItems(response.data); + return items + .map((dynamic json) => + CancelledShift.fromJson(json as Map)) + .toList(); + } + + @override + Future> getCompletedShifts() async { + final ApiResponse response = + await _apiService.get(V2ApiEndpoints.staffShiftsCompleted); + final List items = _extractItems(response.data); + return items + .map((dynamic json) => + CompletedShift.fromJson(json as Map)) + .toList(); + } + + @override + Future getShiftDetail(String shiftId) async { + final ApiResponse response = + await _apiService.get(V2ApiEndpoints.staffShiftDetails(shiftId)); + if (response.data == null) { + return null; + } + return ShiftDetail.fromJson(response.data as Map); } @override Future applyForShift( String shiftId, { - bool isInstantBook = false, String? roleId, + bool instantBook = false, }) async { - final staffId = await _service.getStaffId(); - return _connectorRepository.applyForShift( - shiftId: shiftId, - staffId: staffId, - isInstantBook: isInstantBook, - roleId: roleId, + await _apiService.post( + V2ApiEndpoints.staffShiftApply(shiftId), + data: { + if (roleId != null) 'roleId': roleId, + 'instantBook': instantBook, + }, ); } @override Future acceptShift(String shiftId) async { - final staffId = await _service.getStaffId(); - return _connectorRepository.acceptShift( - shiftId: shiftId, - staffId: staffId, - ); + await _apiService.post(V2ApiEndpoints.staffShiftAccept(shiftId)); } @override Future declineShift(String shiftId) async { - final staffId = await _service.getStaffId(); - return _connectorRepository.declineShift( - shiftId: shiftId, - staffId: staffId, + await _apiService.post(V2ApiEndpoints.staffShiftDecline(shiftId)); + } + + @override + Future requestSwap(String shiftId, {String? reason}) async { + await _apiService.post( + V2ApiEndpoints.staffShiftRequestSwap(shiftId), + data: { + if (reason != null) 'reason': reason, + }, ); } + + @override + Future getProfileCompletion() async { + final ApiResponse response = + await _apiService.get(V2ApiEndpoints.staffProfileCompletion); + final Map data = response.data as Map; + final ProfileCompletion completion = ProfileCompletion.fromJson(data); + return completion.completed; + } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/arguments/get_available_shifts_arguments.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/arguments/get_available_shifts_arguments.dart index 69098abb..2f2801b4 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/domain/arguments/get_available_shifts_arguments.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/arguments/get_available_shifts_arguments.dart @@ -1,19 +1,19 @@ import 'package:krow_core/core.dart'; -/// Arguments for [GetAvailableShiftsUseCase]. -class GetAvailableShiftsArguments extends UseCaseArgument { - /// The search query to filter shifts. - final String query; - - /// The job type filter (e.g., 'all', 'one-day', 'multi-day', 'long-term'). - final String type; - - /// Creates a [GetAvailableShiftsArguments] instance. - const GetAvailableShiftsArguments({ - this.query = '', - this.type = 'all', +/// Arguments for GetOpenShiftsUseCase. +class GetOpenShiftsArguments extends UseCaseArgument { + /// Creates a [GetOpenShiftsArguments] instance. + const GetOpenShiftsArguments({ + this.search, + this.limit = 20, }); + /// Optional search query to filter by role name or location. + final String? search; + + /// Maximum number of results to return. + final int limit; + @override - List get props => [query, type]; + List get props => [search, limit]; } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/arguments/get_my_shifts_arguments.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/arguments/get_my_shifts_arguments.dart index 572cd3df..ea158273 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/domain/arguments/get_my_shifts_arguments.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/arguments/get_my_shifts_arguments.dart @@ -1,11 +1,19 @@ import 'package:krow_core/core.dart'; -class GetMyShiftsArguments extends UseCaseArgument { - final DateTime start; - final DateTime end; - - const GetMyShiftsArguments({ +/// Arguments for GetAssignedShiftsUseCase. +class GetAssignedShiftsArguments extends UseCaseArgument { + /// Creates a [GetAssignedShiftsArguments] instance. + const GetAssignedShiftsArguments({ required this.start, required this.end, }); + + /// Start of the date range. + final DateTime start; + + /// End of the date range. + final DateTime end; + + @override + List get props => [start, end]; } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart index 87d363c2..8fdaa4b7 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart @@ -1,32 +1,39 @@ import 'package:krow_domain/krow_domain.dart'; -/// Interface for the Shifts Repository. +/// Contract for accessing shift-related data from the V2 API. /// -/// Defines the contract for accessing and modifying shift-related data. -/// Implementations of this interface should reside in the data layer. +/// Implementations reside in the data layer and use [BaseApiService] +/// with V2ApiEndpoints. abstract interface class ShiftsRepositoryInterface { - /// Retrieves the list of shifts assigned to the current user. - Future> getMyShifts({ + /// Retrieves assigned shifts for the current staff within a date range. + Future> getAssignedShifts({ required DateTime start, required DateTime end, }); - /// Retrieves available shifts matching the given [query] and [type]. - Future> getAvailableShifts(String query, String type); + /// Retrieves open shifts available for the staff to apply. + Future> getOpenShifts({ + String? search, + int limit, + }); - /// Retrieves shifts that are pending acceptance by the user. - Future> getPendingAssignments(); + /// Retrieves pending assignments awaiting acceptance. + Future> getPendingAssignments(); - /// Retrieves detailed information for a specific shift by [shiftId]. - Future getShiftDetails(String shiftId, {String? roleId}); + /// Retrieves cancelled shift assignments. + Future> getCancelledShifts(); - /// Applies for a specific open shift. - /// - /// [isInstantBook] determines if the application should be immediately accepted. + /// Retrieves completed shift history. + Future> getCompletedShifts(); + + /// Retrieves full details for a specific shift. + Future getShiftDetail(String shiftId); + + /// Applies for an open shift. Future applyForShift( String shiftId, { - bool isInstantBook = false, String? roleId, + bool instantBook, }); /// Accepts a pending shift assignment. @@ -35,9 +42,9 @@ abstract interface class ShiftsRepositoryInterface { /// Declines a pending shift assignment. Future declineShift(String shiftId); - /// Retrieves shifts that were cancelled for the current user. - Future> getCancelledShifts(); + /// Requests a swap for an accepted shift assignment. + Future requestSwap(String shiftId, {String? reason}); - /// Retrieves completed shifts for the current user. - Future> getHistoryShifts(); + /// Returns whether the staff profile is complete. + Future getProfileCompletion(); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/accept_shift_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/accept_shift_usecase.dart index d11ec6e6..889cb305 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/accept_shift_usecase.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/accept_shift_usecase.dart @@ -1,10 +1,14 @@ -import '../repositories/shifts_repository_interface.dart'; +import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; +/// Accepts a pending shift assignment. class AcceptShiftUseCase { - final ShiftsRepositoryInterface repository; - + /// Creates an [AcceptShiftUseCase]. AcceptShiftUseCase(this.repository); + /// The shifts repository. + final ShiftsRepositoryInterface repository; + + /// Executes the use case. Future call(String shiftId) async { return repository.acceptShift(shiftId); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/apply_for_shift_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/apply_for_shift_usecase.dart index 6f2f3c7e..57508600 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/apply_for_shift_usecase.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/apply_for_shift_usecase.dart @@ -1,18 +1,22 @@ -import '../repositories/shifts_repository_interface.dart'; +import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; +/// Applies for an open shift. class ApplyForShiftUseCase { - final ShiftsRepositoryInterface repository; - + /// Creates an [ApplyForShiftUseCase]. ApplyForShiftUseCase(this.repository); + /// The shifts repository. + final ShiftsRepositoryInterface repository; + + /// Executes the use case. Future call( String shiftId, { - bool isInstantBook = false, + bool instantBook = false, String? roleId, }) async { return repository.applyForShift( shiftId, - isInstantBook: isInstantBook, + instantBook: instantBook, roleId: roleId, ); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/decline_shift_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/decline_shift_usecase.dart index 2925ffa0..fb38b26f 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/decline_shift_usecase.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/decline_shift_usecase.dart @@ -1,10 +1,14 @@ -import '../repositories/shifts_repository_interface.dart'; +import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; +/// Declines a pending shift assignment. class DeclineShiftUseCase { - final ShiftsRepositoryInterface repository; - + /// Creates a [DeclineShiftUseCase]. DeclineShiftUseCase(this.repository); + /// The shifts repository. + final ShiftsRepositoryInterface repository; + + /// Executes the use case. Future call(String shiftId) async { return repository.declineShift(shiftId); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_available_shifts_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_available_shifts_usecase.dart index 54d0269e..78e8832a 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_available_shifts_usecase.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_available_shifts_usecase.dart @@ -1,19 +1,24 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../repositories/shifts_repository_interface.dart'; -import '../arguments/get_available_shifts_arguments.dart'; -/// Use case for retrieving available shifts with filters. -/// -/// This use case delegates to [ShiftsRepositoryInterface]. -class GetAvailableShiftsUseCase extends UseCase> { +import 'package:staff_shifts/src/domain/arguments/get_available_shifts_arguments.dart'; +import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; + +/// Retrieves open shifts available for the worker to apply. +class GetOpenShiftsUseCase + extends UseCase> { + /// Creates a [GetOpenShiftsUseCase]. + GetOpenShiftsUseCase(this.repository); + + /// The shifts repository. final ShiftsRepositoryInterface repository; - GetAvailableShiftsUseCase(this.repository); - @override - Future> call(GetAvailableShiftsArguments arguments) async { - return repository.getAvailableShifts(arguments.query, arguments.type); + Future> call(GetOpenShiftsArguments arguments) async { + return repository.getOpenShifts( + search: arguments.search, + limit: arguments.limit, + ); } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_cancelled_shifts_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_cancelled_shifts_usecase.dart index 47b82182..b1f4f35c 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_cancelled_shifts_usecase.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_cancelled_shifts_usecase.dart @@ -1,12 +1,17 @@ import 'package:krow_domain/krow_domain.dart'; -import '../repositories/shifts_repository_interface.dart'; +import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; + +/// Retrieves cancelled shift assignments. class GetCancelledShiftsUseCase { - final ShiftsRepositoryInterface repository; - + /// Creates a [GetCancelledShiftsUseCase]. GetCancelledShiftsUseCase(this.repository); - Future> call() async { + /// The shifts repository. + final ShiftsRepositoryInterface repository; + + /// Executes the use case. + Future> call() async { return repository.getCancelledShifts(); } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_history_shifts_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_history_shifts_usecase.dart index 7cb4066d..88de5c3a 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_history_shifts_usecase.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_history_shifts_usecase.dart @@ -1,12 +1,17 @@ import 'package:krow_domain/krow_domain.dart'; -import '../repositories/shifts_repository_interface.dart'; -class GetHistoryShiftsUseCase { +import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; + +/// Retrieves completed shift history. +class GetCompletedShiftsUseCase { + /// Creates a [GetCompletedShiftsUseCase]. + GetCompletedShiftsUseCase(this.repository); + + /// The shifts repository. final ShiftsRepositoryInterface repository; - GetHistoryShiftsUseCase(this.repository); - - Future> call() async { - return repository.getHistoryShifts(); + /// Executes the use case. + Future> call() async { + return repository.getCompletedShifts(); } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_my_shifts_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_my_shifts_usecase.dart index bcfea64c..02af1424 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_my_shifts_usecase.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_my_shifts_usecase.dart @@ -1,19 +1,21 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../arguments/get_my_shifts_arguments.dart'; -import '../repositories/shifts_repository_interface.dart'; -/// Use case for retrieving the user's assigned shifts. -/// -/// This use case delegates to [ShiftsRepositoryInterface]. -class GetMyShiftsUseCase extends UseCase> { +import 'package:staff_shifts/src/domain/arguments/get_my_shifts_arguments.dart'; +import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; + +/// Retrieves assigned shifts within a date range. +class GetAssignedShiftsUseCase + extends UseCase> { + /// Creates a [GetAssignedShiftsUseCase]. + GetAssignedShiftsUseCase(this.repository); + + /// The shifts repository. final ShiftsRepositoryInterface repository; - GetMyShiftsUseCase(this.repository); - @override - Future> call(GetMyShiftsArguments arguments) async { - return repository.getMyShifts( + Future> call(GetAssignedShiftsArguments arguments) async { + return repository.getAssignedShifts( start: arguments.start, end: arguments.end, ); diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_pending_assignments_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_pending_assignments_usecase.dart index e4747c36..afedc112 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_pending_assignments_usecase.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_pending_assignments_usecase.dart @@ -1,17 +1,19 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../repositories/shifts_repository_interface.dart'; -/// Use case for retrieving pending shift assignments. -/// -/// This use case delegates to [ShiftsRepositoryInterface]. -class GetPendingAssignmentsUseCase extends NoInputUseCase> { - final ShiftsRepositoryInterface repository; +import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; +/// Retrieves pending assignments awaiting acceptance. +class GetPendingAssignmentsUseCase + extends NoInputUseCase> { + /// Creates a [GetPendingAssignmentsUseCase]. GetPendingAssignmentsUseCase(this.repository); + /// The shifts repository. + final ShiftsRepositoryInterface repository; + @override - Future> call() async { + Future> call() async { return repository.getPendingAssignments(); } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_profile_completion_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_profile_completion_usecase.dart new file mode 100644 index 00000000..df3ae944 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_profile_completion_usecase.dart @@ -0,0 +1,17 @@ +import 'package:krow_core/core.dart'; + +import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; + +/// Checks whether the staff member's profile is complete. +class GetProfileCompletionUseCase extends NoInputUseCase { + /// Creates a [GetProfileCompletionUseCase]. + GetProfileCompletionUseCase(this.repository); + + /// The shifts repository. + final ShiftsRepositoryInterface repository; + + @override + Future call() { + return repository.getProfileCompletion(); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_shift_details_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_shift_details_usecase.dart index c7b38473..684ef532 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_shift_details_usecase.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_shift_details_usecase.dart @@ -1,18 +1,18 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../arguments/get_shift_details_arguments.dart'; -import '../repositories/shifts_repository_interface.dart'; -class GetShiftDetailsUseCase extends UseCase { +import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; + +/// Retrieves full details for a specific shift. +class GetShiftDetailUseCase extends UseCase { + /// Creates a [GetShiftDetailUseCase]. + GetShiftDetailUseCase(this.repository); + + /// The shifts repository. final ShiftsRepositoryInterface repository; - GetShiftDetailsUseCase(this.repository); - @override - Future call(GetShiftDetailsArguments params) { - return repository.getShiftDetails( - params.shiftId, - roleId: params.roleId, - ); + Future call(String shiftId) { + return repository.getShiftDetail(shiftId); } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart index 3f5357b3..3067440c 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart @@ -1,22 +1,21 @@ import 'package:bloc/bloc.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; -import '../../../domain/usecases/apply_for_shift_usecase.dart'; -import '../../../domain/usecases/decline_shift_usecase.dart'; -import '../../../domain/usecases/get_shift_details_usecase.dart'; -import '../../../domain/arguments/get_shift_details_arguments.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_shifts/src/domain/usecases/apply_for_shift_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/decline_shift_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_shift_details_usecase.dart'; + import 'shift_details_event.dart'; import 'shift_details_state.dart'; +/// Manages the state for the shift details page. class ShiftDetailsBloc extends Bloc with BlocErrorHandler { - final GetShiftDetailsUseCase getShiftDetails; - final ApplyForShiftUseCase applyForShift; - final DeclineShiftUseCase declineShift; - final GetProfileCompletionUseCase getProfileCompletion; - + /// Creates a [ShiftDetailsBloc]. ShiftDetailsBloc({ - required this.getShiftDetails, + required this.getShiftDetail, required this.applyForShift, required this.declineShift, required this.getProfileCompletion, @@ -26,6 +25,18 @@ class ShiftDetailsBloc extends Bloc on(_onDeclineShift); } + /// Use case for fetching shift details. + final GetShiftDetailUseCase getShiftDetail; + + /// Use case for applying to a shift. + final ApplyForShiftUseCase applyForShift; + + /// Use case for declining a shift. + final DeclineShiftUseCase declineShift; + + /// Use case for checking profile completion. + final GetProfileCompletionUseCase getProfileCompletion; + Future _onLoadDetails( LoadShiftDetailsEvent event, Emitter emit, @@ -34,14 +45,15 @@ class ShiftDetailsBloc extends Bloc await handleError( emit: emit.call, action: () async { - final shift = await getShiftDetails( - GetShiftDetailsArguments(shiftId: event.shiftId, roleId: event.roleId), - ); - final isProfileComplete = await getProfileCompletion(); - if (shift != null) { - emit(ShiftDetailsLoaded(shift, isProfileComplete: isProfileComplete)); + final ShiftDetail? detail = await getShiftDetail(event.shiftId); + final bool isProfileComplete = await getProfileCompletion(); + if (detail != null) { + emit(ShiftDetailsLoaded( + detail, + isProfileComplete: isProfileComplete, + )); } else { - emit(const ShiftDetailsError("Shift not found")); + emit(const ShiftDetailsError('Shift not found')); } }, onError: (String errorKey) => ShiftDetailsError(errorKey), @@ -57,11 +69,14 @@ class ShiftDetailsBloc extends Bloc action: () async { await applyForShift( event.shiftId, - isInstantBook: true, + instantBook: true, roleId: event.roleId, ); emit( - ShiftActionSuccess("Shift successfully booked!", shiftDate: event.date), + ShiftActionSuccess( + 'Shift successfully booked!', + shiftDate: event.date, + ), ); }, onError: (String errorKey) => ShiftDetailsError(errorKey), @@ -76,7 +91,7 @@ class ShiftDetailsBloc extends Bloc emit: emit.call, action: () async { await declineShift(event.shiftId); - emit(const ShiftActionSuccess("Shift declined")); + emit(const ShiftActionSuccess('Shift declined')); }, onError: (String errorKey) => ShiftDetailsError(errorKey), ); diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_state.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_state.dart index b9a0fbeb..b9d138b0 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_state.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_state.dart @@ -1,39 +1,59 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +/// Base class for shift details states. abstract class ShiftDetailsState extends Equatable { + /// Creates a [ShiftDetailsState]. const ShiftDetailsState(); @override - List get props => []; + List get props => []; } +/// Initial state before any data is loaded. class ShiftDetailsInitial extends ShiftDetailsState {} +/// Loading state while fetching shift details. class ShiftDetailsLoading extends ShiftDetailsState {} +/// Loaded state containing the full shift detail. class ShiftDetailsLoaded extends ShiftDetailsState { - final Shift shift; + /// Creates a [ShiftDetailsLoaded]. + const ShiftDetailsLoaded(this.detail, {this.isProfileComplete = false}); + + /// The full shift detail from the V2 API. + final ShiftDetail detail; + + /// Whether the staff profile is complete. final bool isProfileComplete; - const ShiftDetailsLoaded(this.shift, {this.isProfileComplete = false}); @override - List get props => [shift, isProfileComplete]; + List get props => [detail, isProfileComplete]; } +/// Error state with a message key. class ShiftDetailsError extends ShiftDetailsState { - final String message; + /// Creates a [ShiftDetailsError]. const ShiftDetailsError(this.message); + /// The error message key. + final String message; + @override - List get props => [message]; + List get props => [message]; } +/// Success state after a shift action (apply, accept, decline). class ShiftActionSuccess extends ShiftDetailsState { - final String message; - final DateTime? shiftDate; + /// Creates a [ShiftActionSuccess]. const ShiftActionSuccess(this.message, {this.shiftDate}); + /// Success message. + final String message; + + /// The date of the shift for navigation. + final DateTime? shiftDate; + @override - List get props => [message, shiftDate]; + List get props => [message, shiftDate]; } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart index fa398224..9db418d2 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart @@ -1,47 +1,72 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:meta/meta.dart'; -import '../../../domain/arguments/get_available_shifts_arguments.dart'; -import '../../../domain/arguments/get_my_shifts_arguments.dart'; -import '../../../domain/usecases/get_available_shifts_usecase.dart'; -import '../../../domain/usecases/get_cancelled_shifts_usecase.dart'; -import '../../../domain/usecases/get_history_shifts_usecase.dart'; -import '../../../domain/usecases/get_my_shifts_usecase.dart'; -import '../../../domain/usecases/get_pending_assignments_usecase.dart'; +import 'package:staff_shifts/src/domain/arguments/get_available_shifts_arguments.dart'; +import 'package:staff_shifts/src/domain/arguments/get_my_shifts_arguments.dart'; +import 'package:staff_shifts/src/domain/usecases/accept_shift_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/decline_shift_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_available_shifts_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_cancelled_shifts_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_history_shifts_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_my_shifts_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_pending_assignments_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart'; part 'shifts_event.dart'; part 'shifts_state.dart'; +/// Manages the state for the shifts listing page (My Shifts / Find / History). class ShiftsBloc extends Bloc with BlocErrorHandler { - final GetMyShiftsUseCase getMyShifts; - final GetAvailableShiftsUseCase getAvailableShifts; - final GetPendingAssignmentsUseCase getPendingAssignments; - final GetCancelledShiftsUseCase getCancelledShifts; - final GetHistoryShiftsUseCase getHistoryShifts; - final GetProfileCompletionUseCase getProfileCompletion; - + /// Creates a [ShiftsBloc]. ShiftsBloc({ - required this.getMyShifts, - required this.getAvailableShifts, + required this.getAssignedShifts, + required this.getOpenShifts, required this.getPendingAssignments, required this.getCancelledShifts, - required this.getHistoryShifts, + required this.getCompletedShifts, required this.getProfileCompletion, + required this.acceptShift, + required this.declineShift, }) : super(const ShiftsState()) { on(_onLoadShifts); on(_onLoadHistoryShifts); on(_onLoadAvailableShifts); on(_onLoadFindFirst); on(_onLoadShiftsForRange); - on(_onFilterAvailableShifts); + on(_onSearchOpenShifts); on(_onCheckProfileCompletion); + on(_onAcceptShift); + on(_onDeclineShift); } + /// Use case for assigned shifts. + final GetAssignedShiftsUseCase getAssignedShifts; + + /// Use case for open shifts. + final GetOpenShiftsUseCase getOpenShifts; + + /// Use case for pending assignments. + final GetPendingAssignmentsUseCase getPendingAssignments; + + /// Use case for cancelled shifts. + final GetCancelledShiftsUseCase getCancelledShifts; + + /// Use case for completed shifts. + final GetCompletedShiftsUseCase getCompletedShifts; + + /// Use case for profile completion. + final GetProfileCompletionUseCase getProfileCompletion; + + /// Use case for accepting a shift. + final AcceptShiftUseCase acceptShift; + + /// Use case for declining a shift. + final DeclineShiftUseCase declineShift; + Future _onLoadShifts( LoadShiftsEvent event, Emitter emit, @@ -54,25 +79,24 @@ class ShiftsBloc extends Bloc emit: emit.call, action: () async { final List days = _getCalendarDaysForOffset(0); - final myShiftsResult = await getMyShifts( - GetMyShiftsArguments(start: days.first, end: days.last), + final List myShiftsResult = await getAssignedShifts( + GetAssignedShiftsArguments(start: days.first, end: days.last), ); emit( state.copyWith( status: ShiftsStatus.loaded, myShifts: myShiftsResult, - pendingShifts: const [], - cancelledShifts: const [], - availableShifts: const [], - historyShifts: const [], + pendingShifts: const [], + cancelledShifts: const [], + availableShifts: const [], + historyShifts: const [], availableLoading: false, availableLoaded: false, historyLoading: false, historyLoaded: false, myShiftsLoaded: true, searchQuery: '', - jobType: 'all', ), ); }, @@ -92,7 +116,7 @@ class ShiftsBloc extends Bloc await handleError( emit: emit.call, action: () async { - final historyResult = await getHistoryShifts(); + final List historyResult = await getCompletedShifts(); emit( state.copyWith( myShiftsLoaded: true, @@ -125,12 +149,12 @@ class ShiftsBloc extends Bloc await handleError( emit: emit.call, action: () async { - final availableResult = await getAvailableShifts( - const GetAvailableShiftsArguments(), + final List availableResult = await getOpenShifts( + const GetOpenShiftsArguments(), ); emit( state.copyWith( - availableShifts: _filterPastShifts(availableResult), + availableShifts: _filterPastOpenShifts(availableResult), availableLoading: false, availableLoaded: true, ), @@ -154,18 +178,17 @@ class ShiftsBloc extends Bloc emit( state.copyWith( status: ShiftsStatus.loading, - myShifts: const [], - pendingShifts: const [], - cancelledShifts: const [], - availableShifts: const [], - historyShifts: const [], + myShifts: const [], + pendingShifts: const [], + cancelledShifts: const [], + availableShifts: const [], + historyShifts: const [], availableLoading: false, availableLoaded: false, historyLoading: false, historyLoaded: false, myShiftsLoaded: false, searchQuery: '', - jobType: 'all', ), ); } @@ -177,13 +200,13 @@ class ShiftsBloc extends Bloc await handleError( emit: emit.call, action: () async { - final availableResult = await getAvailableShifts( - const GetAvailableShiftsArguments(), + final List availableResult = await getOpenShifts( + const GetOpenShiftsArguments(), ); emit( state.copyWith( status: ShiftsStatus.loaded, - availableShifts: _filterPastShifts(availableResult), + availableShifts: _filterPastOpenShifts(availableResult), availableLoading: false, availableLoaded: true, ), @@ -206,8 +229,8 @@ class ShiftsBloc extends Bloc await handleError( emit: emit.call, action: () async { - final myShiftsResult = await getMyShifts( - GetMyShiftsArguments(start: event.start, end: event.end), + final List myShiftsResult = await getAssignedShifts( + GetAssignedShiftsArguments(start: event.start, end: event.end), ); emit( @@ -223,8 +246,8 @@ class ShiftsBloc extends Bloc ); } - Future _onFilterAvailableShifts( - FilterAvailableShiftsEvent event, + Future _onSearchOpenShifts( + SearchOpenShiftsEvent event, Emitter emit, ) async { if (state.status == ShiftsStatus.loaded) { @@ -236,18 +259,17 @@ class ShiftsBloc extends Bloc await handleError( emit: emit.call, action: () async { - final result = await getAvailableShifts( - GetAvailableShiftsArguments( - query: event.query ?? state.searchQuery, - type: event.jobType ?? state.jobType, + final String search = event.query ?? state.searchQuery; + final List result = await getOpenShifts( + GetOpenShiftsArguments( + search: search.isEmpty ? null : search, ), ); emit( state.copyWith( - availableShifts: _filterPastShifts(result), - searchQuery: event.query ?? state.searchQuery, - jobType: event.jobType ?? state.jobType, + availableShifts: _filterPastOpenShifts(result), + searchQuery: search, ), ); }, @@ -277,33 +299,60 @@ class ShiftsBloc extends Bloc ); } - List _getCalendarDaysForOffset(int weekOffset) { - final now = DateTime.now(); - final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday; - final int daysSinceFriday = (reactDayIndex + 2) % 7; - final start = now - .subtract(Duration(days: daysSinceFriday)) - .add(Duration(days: weekOffset * 7)); - final startDate = DateTime(start.year, start.month, start.day); - return List.generate(7, (index) => startDate.add(Duration(days: index))); + Future _onAcceptShift( + AcceptShiftEvent event, + Emitter emit, + ) async { + await handleError( + emit: emit.call, + action: () async { + await acceptShift(event.shiftId); + add(LoadShiftsEvent()); + }, + onError: (String errorKey) => + state.copyWith(status: ShiftsStatus.error, errorMessage: errorKey), + ); } - List _filterPastShifts(List shifts) { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - return shifts.where((shift) { - if (shift.date.isEmpty) return false; - try { - final shiftDate = DateTime.parse(shift.date).toLocal(); - final dateOnly = DateTime( - shiftDate.year, - shiftDate.month, - shiftDate.day, - ); - return !dateOnly.isBefore(today); - } catch (_) { - return false; - } + Future _onDeclineShift( + DeclineShiftEvent event, + Emitter emit, + ) async { + await handleError( + emit: emit.call, + action: () async { + await declineShift(event.shiftId); + add(LoadShiftsEvent()); + }, + onError: (String errorKey) => + state.copyWith(status: ShiftsStatus.error, errorMessage: errorKey), + ); + } + + /// Gets calendar days for the given week offset (Friday-based week). + List _getCalendarDaysForOffset(int weekOffset) { + final DateTime now = DateTime.now(); + final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday; + final int daysSinceFriday = (reactDayIndex + 2) % 7; + final DateTime start = now + .subtract(Duration(days: daysSinceFriday)) + .add(Duration(days: weekOffset * 7)); + final DateTime startDate = DateTime(start.year, start.month, start.day); + return List.generate( + 7, (int index) => startDate.add(Duration(days: index))); + } + + /// Filters out open shifts whose date is in the past. + List _filterPastOpenShifts(List shifts) { + final DateTime now = DateTime.now(); + final DateTime today = DateTime(now.year, now.month, now.day); + return shifts.where((OpenShift shift) { + final DateTime dateOnly = DateTime( + shift.date.year, + shift.date.month, + shift.date.day, + ); + return !dateOnly.isBefore(today); }).toList(); } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart index 7e1632d2..ac14d74e 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart @@ -1,69 +1,95 @@ part of 'shifts_bloc.dart'; +/// Base class for all shifts events. @immutable sealed class ShiftsEvent extends Equatable { + /// Creates a [ShiftsEvent]. const ShiftsEvent(); @override - List get props => []; + List get props => []; } +/// Triggers initial load of assigned shifts for the current week. class LoadShiftsEvent extends ShiftsEvent {} +/// Triggers lazy load of completed shift history. class LoadHistoryShiftsEvent extends ShiftsEvent {} +/// Triggers load of open shifts available to apply. class LoadAvailableShiftsEvent extends ShiftsEvent { - final bool force; + /// Creates a [LoadAvailableShiftsEvent]. const LoadAvailableShiftsEvent({this.force = false}); + /// Whether to force reload even if already loaded. + final bool force; + @override - List get props => [force]; + List get props => [force]; } +/// Loads open shifts first (for when Find tab is the initial tab). class LoadFindFirstEvent extends ShiftsEvent {} +/// Loads assigned shifts for a specific date range. class LoadShiftsForRangeEvent extends ShiftsEvent { - final DateTime start; - final DateTime end; - + /// Creates a [LoadShiftsForRangeEvent]. const LoadShiftsForRangeEvent({ required this.start, required this.end, }); + /// Start of the date range. + final DateTime start; + + /// End of the date range. + final DateTime end; + @override - List get props => [start, end]; + List get props => [start, end]; } -class FilterAvailableShiftsEvent extends ShiftsEvent { +/// Triggers a server-side search for open shifts. +class SearchOpenShiftsEvent extends ShiftsEvent { + /// Creates a [SearchOpenShiftsEvent]. + const SearchOpenShiftsEvent({this.query}); + + /// The search query string. final String? query; - final String? jobType; - - const FilterAvailableShiftsEvent({this.query, this.jobType}); @override - List get props => [query, jobType]; + List get props => [query]; } +/// Accepts a pending shift assignment. class AcceptShiftEvent extends ShiftsEvent { - final String shiftId; + /// Creates an [AcceptShiftEvent]. const AcceptShiftEvent(this.shiftId); + /// The shift row id to accept. + final String shiftId; + @override - List get props => [shiftId]; + List get props => [shiftId]; } +/// Declines a pending shift assignment. class DeclineShiftEvent extends ShiftsEvent { - final String shiftId; + /// Creates a [DeclineShiftEvent]. const DeclineShiftEvent(this.shiftId); + /// The shift row id to decline. + final String shiftId; + @override - List get props => [shiftId]; + List get props => [shiftId]; } +/// Triggers a profile completion check. class CheckProfileCompletionEvent extends ShiftsEvent { + /// Creates a [CheckProfileCompletionEvent]. const CheckProfileCompletionEvent(); @override - List get props => []; + List get props => []; } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart index f9e108d5..3b7a1de9 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart @@ -1,56 +1,84 @@ part of 'shifts_bloc.dart'; +/// Lifecycle status for the shifts page. enum ShiftsStatus { initial, loading, loaded, error } +/// State for the shifts listing page. class ShiftsState extends Equatable { - final ShiftsStatus status; - final List myShifts; - final List pendingShifts; - final List cancelledShifts; - final List availableShifts; - final List historyShifts; - final bool availableLoading; - final bool availableLoaded; - final bool historyLoading; - final bool historyLoaded; - final bool myShiftsLoaded; - final String searchQuery; - final String jobType; - final bool? profileComplete; - final String? errorMessage; - + /// Creates a [ShiftsState]. const ShiftsState({ this.status = ShiftsStatus.initial, - this.myShifts = const [], - this.pendingShifts = const [], - this.cancelledShifts = const [], - this.availableShifts = const [], - this.historyShifts = const [], + this.myShifts = const [], + this.pendingShifts = const [], + this.cancelledShifts = const [], + this.availableShifts = const [], + this.historyShifts = const [], this.availableLoading = false, this.availableLoaded = false, this.historyLoading = false, this.historyLoaded = false, this.myShiftsLoaded = false, this.searchQuery = '', - this.jobType = 'all', this.profileComplete, this.errorMessage, }); + /// Current lifecycle status. + final ShiftsStatus status; + + /// Assigned shifts for the selected week. + final List myShifts; + + /// Pending assignments awaiting acceptance. + final List pendingShifts; + + /// Cancelled shift assignments. + final List cancelledShifts; + + /// Open shifts available for application. + final List availableShifts; + + /// Completed shift history. + final List historyShifts; + + /// Whether open shifts are currently loading. + final bool availableLoading; + + /// Whether open shifts have been loaded at least once. + final bool availableLoaded; + + /// Whether history is currently loading. + final bool historyLoading; + + /// Whether history has been loaded at least once. + final bool historyLoaded; + + /// Whether assigned shifts have been loaded at least once. + final bool myShiftsLoaded; + + /// Current search query for open shifts. + final String searchQuery; + + /// Whether the staff profile is complete. + final bool? profileComplete; + + /// Error message key for display. + final String? errorMessage; + + /// Creates a copy with the given fields replaced. ShiftsState copyWith({ ShiftsStatus? status, - List? myShifts, - List? pendingShifts, - List? cancelledShifts, - List? availableShifts, - List? historyShifts, + List? myShifts, + List? pendingShifts, + List? cancelledShifts, + List? availableShifts, + List? historyShifts, bool? availableLoading, bool? availableLoaded, bool? historyLoading, bool? historyLoaded, bool? myShiftsLoaded, String? searchQuery, - String? jobType, bool? profileComplete, String? errorMessage, }) { @@ -67,28 +95,26 @@ class ShiftsState extends Equatable { historyLoaded: historyLoaded ?? this.historyLoaded, myShiftsLoaded: myShiftsLoaded ?? this.myShiftsLoaded, searchQuery: searchQuery ?? this.searchQuery, - jobType: jobType ?? this.jobType, profileComplete: profileComplete ?? this.profileComplete, errorMessage: errorMessage ?? this.errorMessage, ); } @override - List get props => [ - status, - myShifts, - pendingShifts, - cancelledShifts, - availableShifts, - historyShifts, - availableLoading, - availableLoaded, - historyLoading, - historyLoaded, - myShiftsLoaded, - searchQuery, - jobType, - profileComplete, - errorMessage, - ]; + List get props => [ + status, + myShifts, + pendingShifts, + cancelledShifts, + availableShifts, + historyShifts, + availableLoading, + availableLoaded, + historyLoading, + historyLoaded, + myShiftsLoaded, + searchQuery, + profileComplete, + errorMessage, + ]; } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart index 15b28f85..5eb65bc6 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart @@ -7,29 +7,30 @@ import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../blocs/shift_details/shift_details_bloc.dart'; -import '../blocs/shift_details/shift_details_event.dart'; -import '../blocs/shift_details/shift_details_state.dart'; -import '../widgets/shift_details/shift_break_section.dart'; -import '../widgets/shift_details/shift_date_time_section.dart'; -import '../widgets/shift_details/shift_description_section.dart'; -import '../widgets/shift_details/shift_details_bottom_bar.dart'; -import '../widgets/shift_details/shift_details_header.dart'; -import '../widgets/shift_details_page_skeleton.dart'; -import '../widgets/shift_details/shift_location_section.dart'; -import '../widgets/shift_details/shift_schedule_summary_section.dart'; -import '../widgets/shift_details/shift_stats_row.dart'; +import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_bloc.dart'; +import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_event.dart'; +import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_state.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_date_time_section.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_description_section.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_details_header.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_details_page_skeleton.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_location_section.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_details/shift_stats_row.dart'; +/// Page displaying full details for a single shift. +/// +/// Loads data via [ShiftDetailsBloc] from the V2 API. class ShiftDetailsPage extends StatefulWidget { - final String shiftId; - final Shift shift; - + /// Creates a [ShiftDetailsPage]. const ShiftDetailsPage({ super.key, required this.shiftId, - required this.shift, }); + /// The shift row ID to load details for. + final String shiftId; + @override State createState() => _ShiftDetailsPageState(); } @@ -38,53 +39,27 @@ class _ShiftDetailsPageState extends State { bool _actionDialogOpen = false; bool _isApplying = false; - String _formatTime(String time) { - if (time.isEmpty) return ''; - try { - final parts = time.split(':'); - final hour = int.parse(parts[0]); - final minute = int.parse(parts[1]); - final dt = DateTime(2022, 1, 1, hour, minute); - return DateFormat('h:mm a').format(dt); - } catch (e) { - return time; - } + String _formatTime(DateTime dt) { + return DateFormat('h:mm a').format(dt); } - String _formatDate(String dateStr) { - if (dateStr.isEmpty) return ''; - try { - final date = DateTime.parse(dateStr); - return DateFormat('EEEE, MMMM d, y').format(date); - } catch (e) { - return dateStr; - } + String _formatDate(DateTime dt) { + return DateFormat('EEEE, MMMM d, y').format(dt); } - double _calculateDuration(Shift shift) { - if (shift.startTime.isEmpty || shift.endTime.isEmpty) { - return 0; - } - try { - final s = shift.startTime.split(':').map(int.parse).toList(); - final e = shift.endTime.split(':').map(int.parse).toList(); - double hours = ((e[0] * 60 + e[1]) - (s[0] * 60 + s[1])) / 60; - if (hours < 0) hours += 24; - return hours.roundToDouble(); - } catch (_) { - return 0; - } + double _calculateDuration(ShiftDetail detail) { + final int minutes = detail.endTime.difference(detail.startTime).inMinutes; + final double hours = minutes / 60; + return hours < 0 ? hours + 24 : hours; } @override Widget build(BuildContext context) { return BlocProvider( create: (_) => Modular.get() - ..add( - LoadShiftDetailsEvent(widget.shiftId, roleId: widget.shift.roleId), - ), + ..add(LoadShiftDetailsEvent(widget.shiftId)), child: BlocConsumer( - listener: (context, state) { + listener: (BuildContext context, ShiftDetailsState state) { if (state is ShiftActionSuccess || state is ShiftDetailsError) { _closeActionDialog(context); } @@ -117,20 +92,19 @@ class _ShiftDetailsPageState extends State { _isApplying = false; } }, - builder: (context, state) { - if (state is ShiftDetailsLoading) { + builder: (BuildContext context, ShiftDetailsState state) { + if (state is! ShiftDetailsLoaded) { return const ShiftDetailsPageSkeleton(); } - final Shift displayShift = widget.shift; - final i18n = Translations.of(context).staff_shifts.shift_details; - final isProfileComplete = state is ShiftDetailsLoaded - ? state.isProfileComplete - : false; + final ShiftDetail detail = state.detail; + final dynamic i18n = + Translations.of(context).staff_shifts.shift_details; + final bool isProfileComplete = state.isProfileComplete; - final duration = _calculateDuration(displayShift); - final estimatedTotal = - displayShift.totalValue ?? (displayShift.hourlyRate * duration); + final double duration = _calculateDuration(detail); + final double hourlyRate = detail.hourlyRateCents / 100; + final double estimatedTotal = hourlyRate * duration; return Scaffold( appBar: UiAppBar( @@ -138,12 +112,12 @@ class _ShiftDetailsPageState extends State { onLeadingPressed: () => Modular.to.toShifts(), ), body: Column( - children: [ + children: [ Expanded( child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ if (!isProfileComplete) Padding( padding: const EdgeInsets.all(UiConstants.space6), @@ -154,56 +128,38 @@ class _ShiftDetailsPageState extends State { icon: UiIcons.sparkles, ), ), - ShiftDetailsHeader(shift: displayShift), - + ShiftDetailsHeader(detail: detail), const Divider(height: 1, thickness: 0.5), - ShiftStatsRow( estimatedTotal: estimatedTotal, - hourlyRate: displayShift.hourlyRate, + hourlyRate: hourlyRate, duration: duration, totalLabel: i18n.est_total, hourlyRateLabel: i18n.hourly_rate, hoursLabel: i18n.hours, ), - const Divider(height: 1, thickness: 0.5), - ShiftDateTimeSection( - date: displayShift.date, - endDate: displayShift.endDate, - startTime: displayShift.startTime, - endTime: displayShift.endTime, + date: detail.date, + startTime: detail.startTime, + endTime: detail.endTime, shiftDateLabel: i18n.shift_date, clockInLabel: i18n.start_time, clockOutLabel: i18n.end_time, ), const Divider(height: 1, thickness: 0.5), - ShiftScheduleSummarySection(shift: displayShift), - const Divider(height: 1, thickness: 0.5), - if (displayShift.breakInfo != null && - displayShift.breakInfo!.duration != - BreakDuration.none) ...[ - ShiftBreakSection( - breakInfo: displayShift.breakInfo!, - breakTitle: i18n.break_title, - paidLabel: i18n.paid, - unpaidLabel: i18n.unpaid, - minLabel: i18n.min, - ), - const Divider(height: 1, thickness: 0.5), - ], ShiftLocationSection( - shift: displayShift, + location: detail.location, + address: detail.address ?? '', locationLabel: i18n.location, tbdLabel: i18n.tbd, getDirectionLabel: i18n.get_direction, ), const Divider(height: 1, thickness: 0.5), - if (displayShift.description != null && - displayShift.description!.isNotEmpty) + if (detail.description != null && + detail.description!.isNotEmpty) ShiftDescriptionSection( - description: displayShift.description!, + description: detail.description!, descriptionLabel: i18n.job_description, ), ], @@ -212,18 +168,18 @@ class _ShiftDetailsPageState extends State { ), if (isProfileComplete) ShiftDetailsBottomBar( - shift: displayShift, - onApply: () => _bookShift(context, displayShift), + detail: detail, + onApply: () => _bookShift(context, detail), onDecline: () => BlocProvider.of( context, - ).add(DeclineShiftDetailsEvent(displayShift.id)), + ).add(DeclineShiftDetailsEvent(detail.shiftId)), onAccept: () => BlocProvider.of(context).add( - BookShiftDetailsEvent( - displayShift.id, - roleId: displayShift.roleId, - ), - ), + BookShiftDetailsEvent( + detail.shiftId, + roleId: detail.roleId, + ), + ), ), ], ), @@ -233,16 +189,15 @@ class _ShiftDetailsPageState extends State { ); } - void _bookShift(BuildContext context, Shift shift) { - final i18n = Translations.of( - context, - ).staff_shifts.shift_details.book_dialog; - showDialog( + void _bookShift(BuildContext context, ShiftDetail detail) { + final dynamic i18n = + Translations.of(context).staff_shifts.shift_details.book_dialog; + showDialog( context: context, - builder: (ctx) => AlertDialog( - title: Text(i18n.title), - content: Text(i18n.message), - actions: [ + builder: (BuildContext ctx) => AlertDialog( + title: Text(i18n.title as String), + content: Text(i18n.message as String), + actions: [ TextButton( onPressed: () => Modular.to.popSafe(), child: Text(Translations.of(context).common.cancel), @@ -250,12 +205,12 @@ class _ShiftDetailsPageState extends State { TextButton( onPressed: () { Modular.to.popSafe(); - _showApplyingDialog(context, shift); + _showApplyingDialog(context, detail); BlocProvider.of(context).add( BookShiftDetailsEvent( - shift.id, - roleId: shift.roleId, - date: DateTime.tryParse(shift.date), + detail.shiftId, + roleId: detail.roleId, + date: detail.date, ), ); }, @@ -269,22 +224,21 @@ class _ShiftDetailsPageState extends State { ); } - void _showApplyingDialog(BuildContext context, Shift shift) { + void _showApplyingDialog(BuildContext context, ShiftDetail detail) { if (_actionDialogOpen) return; _actionDialogOpen = true; _isApplying = true; - final i18n = Translations.of( - context, - ).staff_shifts.shift_details.applying_dialog; - showDialog( + final dynamic i18n = + Translations.of(context).staff_shifts.shift_details.applying_dialog; + showDialog( context: context, useRootNavigator: true, barrierDismissible: false, - builder: (ctx) => AlertDialog( - title: Text(i18n.title), + builder: (BuildContext ctx) => AlertDialog( + title: Text(i18n.title as String), content: Column( mainAxisSize: MainAxisSize.min, - children: [ + children: [ const SizedBox( height: 36, width: 36, @@ -292,24 +246,16 @@ class _ShiftDetailsPageState extends State { ), const SizedBox(height: UiConstants.space4), Text( - shift.title, + detail.title, style: UiTypography.body2b.textPrimary, textAlign: TextAlign.center, ), const SizedBox(height: 6), Text( - '${_formatDate(shift.date)} • ${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}', + '${_formatDate(detail.date)} \u2022 ${_formatTime(detail.startTime)} - ${_formatTime(detail.endTime)}', style: UiTypography.body3r.textSecondary, textAlign: TextAlign.center, ), - if (shift.clientName.isNotEmpty) ...[ - const SizedBox(height: 6), - Text( - shift.clientName, - style: UiTypography.body3r.textSecondary, - textAlign: TextAlign.center, - ), - ], ], ), ), @@ -325,14 +271,14 @@ class _ShiftDetailsPageState extends State { } void _showEligibilityErrorDialog(BuildContext context) { - showDialog( + showDialog( context: context, builder: (BuildContext ctx) => AlertDialog( backgroundColor: UiColors.bgPopup, shape: RoundedRectangleBorder(borderRadius: UiConstants.radiusLg), title: Row( spacing: UiConstants.space2, - children: [ + children: [ const Icon(UiIcons.warning, color: UiColors.error), Expanded( child: Text( @@ -342,16 +288,16 @@ class _ShiftDetailsPageState extends State { ], ), content: Text( - "You are missing required certifications or documents to claim this shift. Please upload them to continue.", + 'You are missing required certifications or documents to claim this shift. Please upload them to continue.', style: UiTypography.body2r.textSecondary, ), - actions: [ + actions: [ UiButton.secondary( - text: "Cancel", + text: 'Cancel', onPressed: () => Navigator.of(ctx).pop(), ), UiButton.primary( - text: "Go to Certificates", + text: 'Go to Certificates', onPressed: () { Modular.to.popSafe(); Modular.to.toCertificates(); diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart index 6f6a3a6d..e61c9558 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart @@ -4,12 +4,13 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:design_system/design_system.dart'; import 'package:core_localization/core_localization.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../blocs/shifts/shifts_bloc.dart'; -import '../utils/shift_tab_type.dart'; -import '../widgets/shifts_page_skeleton.dart'; -import '../widgets/tabs/my_shifts_tab.dart'; -import '../widgets/tabs/find_shifts_tab.dart'; -import '../widgets/tabs/history_shifts_tab.dart'; + +import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; +import 'package:staff_shifts/src/presentation/utils/shift_tab_type.dart'; +import 'package:staff_shifts/src/presentation/widgets/shifts_page_skeleton.dart'; +import 'package:staff_shifts/src/presentation/widgets/tabs/my_shifts_tab.dart'; +import 'package:staff_shifts/src/presentation/widgets/tabs/find_shifts_tab.dart'; +import 'package:staff_shifts/src/presentation/widgets/tabs/history_shifts_tab.dart'; class ShiftsPage extends StatefulWidget { final ShiftTabType? initialTab; @@ -102,13 +103,13 @@ class _ShiftsPageState extends State { _bloc.add(const LoadAvailableShiftsEvent(force: true)); } final bool baseLoaded = state.status == ShiftsStatus.loaded; - final List myShifts = state.myShifts; - final List availableJobs = state.availableShifts; + final List myShifts = state.myShifts; + final List availableJobs = state.availableShifts; final bool availableLoading = state.availableLoading; final bool availableLoaded = state.availableLoaded; - final List pendingAssignments = state.pendingShifts; - final List cancelledShifts = state.cancelledShifts; - final List historyShifts = state.historyShifts; + final List pendingAssignments = state.pendingShifts; + final List cancelledShifts = state.cancelledShifts; + final List historyShifts = state.historyShifts; final bool historyLoading = state.historyLoading; final bool historyLoaded = state.historyLoaded; final bool myShiftsLoaded = state.myShiftsLoaded; @@ -235,11 +236,11 @@ class _ShiftsPageState extends State { Widget _buildTabContent( ShiftsState state, - List myShifts, - List pendingAssignments, - List cancelledShifts, - List availableJobs, - List historyShifts, + List myShifts, + List pendingAssignments, + List cancelledShifts, + List availableJobs, + List historyShifts, bool availableLoading, bool historyLoading, ) { diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart index ad898f00..f531f2c6 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart @@ -4,24 +4,31 @@ import 'package:intl/intl.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:design_system/design_system.dart'; import 'package:core_localization/core_localization.dart'; -import 'package:krow_core/core.dart'; // For modular navigation +import 'package:krow_core/core.dart'; +/// Card widget displaying an assigned shift summary. class MyShiftCard extends StatefulWidget { - final Shift shift; - final bool historyMode; - final VoidCallback? onAccept; - final VoidCallback? onDecline; - final VoidCallback? onRequestSwap; - + /// Creates a [MyShiftCard]. const MyShiftCard({ super.key, required this.shift, - this.historyMode = false, this.onAccept, this.onDecline, this.onRequestSwap, }); + /// The assigned shift entity. + final AssignedShift shift; + + /// Callback when the shift is accepted. + final VoidCallback? onAccept; + + /// Callback when the shift is declined. + final VoidCallback? onDecline; + + /// Callback when a swap is requested. + final VoidCallback? onRequestSwap; + @override State createState() => _MyShiftCardState(); } @@ -29,141 +36,88 @@ class MyShiftCard extends StatefulWidget { class _MyShiftCardState extends State { bool _isSubmitted = false; - String _formatTime(String time) { - if (time.isEmpty) return ''; - try { - final parts = time.split(':'); - final hour = int.parse(parts[0]); - final minute = int.parse(parts[1]); - // Date doesn't matter for time formatting - final dt = DateTime(2022, 1, 1, hour, minute); - return DateFormat('h:mm a').format(dt); - } catch (e) { - return time; - } - } + String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt); - String _formatDate(String dateStr) { - if (dateStr.isEmpty) return ''; - try { - final date = DateTime.parse(dateStr); - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - final tomorrow = today.add(const Duration(days: 1)); - final d = DateTime(date.year, date.month, date.day); - - if (d == today) return 'Today'; - if (d == tomorrow) return 'Tomorrow'; - return DateFormat('EEE, MMM d').format(date); - } catch (e) { - return dateStr; - } + String _formatDate(DateTime date) { + final DateTime now = DateTime.now(); + final DateTime today = DateTime(now.year, now.month, now.day); + final DateTime tomorrow = today.add(const Duration(days: 1)); + final DateTime d = DateTime(date.year, date.month, date.day); + if (d == today) return 'Today'; + if (d == tomorrow) return 'Tomorrow'; + return DateFormat('EEE, MMM d').format(date); } double _calculateDuration() { - if (widget.shift.startTime.isEmpty || widget.shift.endTime.isEmpty) { - return 0; - } - try { - final s = widget.shift.startTime.split(':').map(int.parse).toList(); - final e = widget.shift.endTime.split(':').map(int.parse).toList(); - double hours = ((e[0] * 60 + e[1]) - (s[0] * 60 + s[1])) / 60; - if (hours < 0) hours += 24; - return hours.roundToDouble(); - } catch (_) { - return 0; - } + final int minutes = + widget.shift.endTime.difference(widget.shift.startTime).inMinutes; + double hours = minutes / 60; + if (hours < 0) hours += 24; + return hours.roundToDouble(); } String _getShiftType() { - // Handling potential localization key availability try { - final String orderType = (widget.shift.orderType ?? '').toUpperCase(); - if (orderType == 'PERMANENT') { - return t.staff_shifts.filter.long_term; + switch (widget.shift.orderType) { + case OrderType.permanent: + return t.staff_shifts.filter.long_term; + case OrderType.recurring: + return t.staff_shifts.filter.multi_day; + case OrderType.oneTime: + default: + return t.staff_shifts.filter.one_day; } - if (orderType == 'RECURRING') { - return t.staff_shifts.filter.multi_day; - } - if (widget.shift.durationDays != null && - widget.shift.durationDays! > 30) { - return t.staff_shifts.filter.long_term; - } - if (widget.shift.durationDays != null && widget.shift.durationDays! > 1) { - return t.staff_shifts.filter.multi_day; - } - return t.staff_shifts.filter.one_day; } catch (_) { - return "One Day"; + return 'One Day'; } } @override Widget build(BuildContext context) { - final duration = _calculateDuration(); - final estimatedTotal = (widget.shift.hourlyRate) * duration; + final double duration = _calculateDuration(); + final double hourlyRate = widget.shift.hourlyRateCents / 100; + final double estimatedTotal = hourlyRate * duration; // Status Logic - String? status = widget.shift.status; + final AssignmentStatus status = widget.shift.status; Color statusColor = UiColors.primary; Color statusBg = UiColors.primary; String statusText = ''; IconData? statusIcon; - // Fallback localization if keys missing try { - if (status == 'confirmed') { - statusText = t.staff_shifts.status.confirmed; - statusColor = UiColors.textLink; - statusBg = UiColors.primary; - } else if (status == 'checked_in') { - statusText = context.t.staff_shifts.my_shift_card.checked_in; - statusColor = UiColors.textSuccess; - statusBg = UiColors.iconSuccess; - } else if (status == 'pending' || status == 'open') { - statusText = t.staff_shifts.status.act_now; - statusColor = UiColors.destructive; - statusBg = UiColors.destructive; - } else if (status == 'swap') { - statusText = t.staff_shifts.status.swap_requested; - statusColor = UiColors.textWarning; - statusBg = UiColors.textWarning; - statusIcon = UiIcons.swap; - } else if (status == 'completed') { - statusText = t.staff_shifts.status.completed; - statusColor = UiColors.textSuccess; - statusBg = UiColors.iconSuccess; - } else if (status == 'no_show') { - statusText = t.staff_shifts.status.no_show; - statusColor = UiColors.destructive; - statusBg = UiColors.destructive; + switch (status) { + case AssignmentStatus.accepted: + statusText = t.staff_shifts.status.confirmed; + statusColor = UiColors.textLink; + statusBg = UiColors.primary; + case AssignmentStatus.checkedIn: + statusText = context.t.staff_shifts.my_shift_card.checked_in; + statusColor = UiColors.textSuccess; + statusBg = UiColors.iconSuccess; + case AssignmentStatus.assigned: + statusText = t.staff_shifts.status.act_now; + statusColor = UiColors.destructive; + statusBg = UiColors.destructive; + case AssignmentStatus.swapRequested: + statusText = t.staff_shifts.status.swap_requested; + statusColor = UiColors.textWarning; + statusBg = UiColors.textWarning; + statusIcon = UiIcons.swap; + case AssignmentStatus.completed: + statusText = t.staff_shifts.status.completed; + statusColor = UiColors.textSuccess; + statusBg = UiColors.iconSuccess; + default: + statusText = status.toJson().toUpperCase(); } } catch (_) { - statusText = status?.toUpperCase() ?? ""; + statusText = status.toJson().toUpperCase(); } - final schedules = widget.shift.schedules ?? []; - final hasSchedules = schedules.isNotEmpty; - final List visibleSchedules = schedules.length <= 5 - ? schedules - : schedules.take(3).toList(); - final int remainingSchedules = schedules.length <= 5 - ? 0 - : schedules.length - 3; - final String scheduleRange = hasSchedules - ? () { - final first = schedules.first.date; - final last = schedules.last.date; - if (first == last) { - return _formatDate(first); - } - return '${_formatDate(first)} – ${_formatDate(last)}'; - }() - : ''; - return GestureDetector( onTap: () { - Modular.to.toShiftDetails(widget.shift); + Modular.to.toShiftDetailsById(widget.shift.shiftId); }, child: Container( margin: const EdgeInsets.only(bottom: UiConstants.space3), @@ -265,23 +219,13 @@ class _MyShiftCardState extends State { color: UiColors.primary.withValues(alpha: 0.09), ), ), - child: widget.shift.logoUrl != null - ? ClipRRect( - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, - ), - child: Image.network( - widget.shift.logoUrl!, - fit: BoxFit.contain, - ), - ) - : const Center( - child: Icon( - UiIcons.briefcase, - color: UiColors.primary, - size: UiConstants.iconMd, - ), - ), + child: const Center( + child: Icon( + UiIcons.briefcase, + color: UiColors.primary, + size: UiConstants.iconMd, + ), + ), ), const SizedBox(width: UiConstants.space3), @@ -298,12 +242,12 @@ class _MyShiftCardState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - widget.shift.title, + widget.shift.roleName, style: UiTypography.body2m.textPrimary, overflow: TextOverflow.ellipsis, ), Text( - widget.shift.clientName, + widget.shift.location, style: UiTypography.body3r.textSecondary, overflow: TextOverflow.ellipsis, ), @@ -315,11 +259,11 @@ class _MyShiftCardState extends State { crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - "\$${estimatedTotal.toStringAsFixed(0)}", + '\$${estimatedTotal.toStringAsFixed(0)}', style: UiTypography.title1m.textPrimary, ), Text( - "\$${widget.shift.hourlyRate.toInt()}/hr · ${duration.toInt()}h", + '\$${hourlyRate.toInt()}/hr \u00b7 ${duration.toInt()}h', style: UiTypography.footnote2r.textSecondary, ), ], @@ -329,134 +273,36 @@ class _MyShiftCardState extends State { const SizedBox(height: UiConstants.space2), // Date & Time - if (hasSchedules) ...[ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Icon( - UiIcons.clock, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space1), - Text( - scheduleRange, - style: - UiTypography.footnote2r.textSecondary, - ), - ], - ), - - const SizedBox(height: UiConstants.space2), - - Text( - '${schedules.length} schedules', - style: UiTypography.footnote2m.copyWith( - color: UiColors.primary, - ), - ), - const SizedBox(height: UiConstants.space1), - ...visibleSchedules.map( - (schedule) => Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Text( - '${_formatDate(schedule.date)}, ${_formatTime(schedule.startTime)} – ${_formatTime(schedule.endTime)}', - style: UiTypography.footnote2r.copyWith( - color: UiColors.primary, - ), - ), - ), - ), - if (remainingSchedules > 0) - Text( - '+$remainingSchedules more schedules', - style: UiTypography.footnote2r.copyWith( - color: UiColors.primary.withValues( - alpha: 0.7, - ), - ), - ), - ], - ), - ] else if (widget.shift.durationDays != null && - widget.shift.durationDays! > 1) ...[ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon( - UiIcons.clock, - size: UiConstants.iconXs, - color: UiColors.primary, - ), - const SizedBox(width: UiConstants.space1), - Text( - t.staff_shifts.details.days( - days: widget.shift.durationDays!, - ), - style: UiTypography.footnote2m.copyWith( - color: UiColors.primary, - ), - ), - ], - ), - const SizedBox(height: UiConstants.space1), - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Text( - '${_formatDate(widget.shift.date)}, ${_formatTime(widget.shift.startTime)} – ${_formatTime(widget.shift.endTime)}', - style: UiTypography.footnote2r.copyWith( - color: UiColors.primary, - ), - ), - ), - if (widget.shift.durationDays! > 1) - Text( - '... +${widget.shift.durationDays! - 1} more days', - style: UiTypography.footnote2r.copyWith( - color: UiColors.primary.withValues( - alpha: 0.7, - ), - ), - ), - ], - ), - ] else ...[ - Row( - children: [ - const Icon( - UiIcons.calendar, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space1), - Text( - _formatDate(widget.shift.date), - style: UiTypography.footnote1r.textSecondary, - ), - const SizedBox(width: UiConstants.space3), - const Icon( - UiIcons.clock, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space1), - Text( - "${_formatTime(widget.shift.startTime)} - ${_formatTime(widget.shift.endTime)}", - style: UiTypography.footnote1r.textSecondary, - ), - ], - ), - ], + Row( + children: [ + const Icon( + UiIcons.calendar, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Text( + _formatDate(widget.shift.date), + style: UiTypography.footnote1r.textSecondary, + ), + const SizedBox(width: UiConstants.space3), + const Icon( + UiIcons.clock, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Text( + '${_formatTime(widget.shift.startTime)} - ${_formatTime(widget.shift.endTime)}', + style: UiTypography.footnote1r.textSecondary, + ), + ], + ), const SizedBox(height: UiConstants.space1), // Location Row( - children: [ + children: [ const Icon( UiIcons.mapPin, size: UiConstants.iconXs, @@ -465,9 +311,7 @@ class _MyShiftCardState extends State { const SizedBox(width: UiConstants.space1), Expanded( child: Text( - widget.shift.locationAddress.isNotEmpty - ? widget.shift.locationAddress - : widget.shift.location, + widget.shift.location, style: UiTypography.footnote1r.textSecondary, overflow: TextOverflow.ellipsis, ), @@ -479,7 +323,7 @@ class _MyShiftCardState extends State { ), ], ), - if (status == 'completed') ...[ + if (status == AssignmentStatus.completed) ...[ const SizedBox(height: UiConstants.space4), const Divider(), const SizedBox(height: UiConstants.space2), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_assignment_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_assignment_card.dart index 086571e2..5482707f 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_assignment_card.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_assignment_card.dart @@ -3,72 +3,49 @@ import 'package:intl/intl.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:design_system/design_system.dart'; +/// Card displaying a pending assignment with accept/decline actions. class ShiftAssignmentCard extends StatelessWidget { - final Shift shift; - final VoidCallback onConfirm; - final VoidCallback onDecline; - final bool isConfirming; - + /// Creates a [ShiftAssignmentCard]. const ShiftAssignmentCard({ super.key, - required this.shift, + required this.assignment, required this.onConfirm, required this.onDecline, this.isConfirming = false, }); - String _formatTime(String time) { - if (time.isEmpty) return ''; - try { - final parts = time.split(':'); - final hour = int.parse(parts[0]); - final minute = int.parse(parts[1]); - final dt = DateTime(2022, 1, 1, hour, minute); - return DateFormat('h:mm a').format(dt); - } catch (e) { - return time; - } - } + /// The pending assignment entity. + final PendingAssignment assignment; - String _formatDate(String dateStr) { - if (dateStr.isEmpty) return ''; - try { - final date = DateTime.parse(dateStr); - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - final tomorrow = today.add(const Duration(days: 1)); - final d = DateTime(date.year, date.month, date.day); + /// Callback for accepting the assignment. + final VoidCallback onConfirm; - if (d == today) return 'Today'; - if (d == tomorrow) return 'Tomorrow'; - return DateFormat('EEE, MMM d').format(date); - } catch (e) { - return dateStr; - } - } + /// Callback for declining the assignment. + final VoidCallback onDecline; - double _calculateHours(String start, String end) { - if (start.isEmpty || end.isEmpty) return 0; - try { - final s = start.split(':').map(int.parse).toList(); - final e = end.split(':').map(int.parse).toList(); - return ((e[0] * 60 + e[1]) - (s[0] * 60 + s[1])) / 60; - } catch (_) { - return 0; - } + /// Whether the confirm action is in progress. + final bool isConfirming; + + String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt); + + String _formatDate(DateTime date) { + final DateTime now = DateTime.now(); + final DateTime today = DateTime(now.year, now.month, now.day); + final DateTime tomorrow = today.add(const Duration(days: 1)); + final DateTime d = DateTime(date.year, date.month, date.day); + if (d == today) return 'Today'; + if (d == tomorrow) return 'Tomorrow'; + return DateFormat('EEE, MMM d').format(date); } @override Widget build(BuildContext context) { - final hours = _calculateHours(shift.startTime, shift.endTime); - final totalPay = shift.hourlyRate * hours; - return Container( decoration: BoxDecoration( color: UiColors.white, borderRadius: UiConstants.radiusLg, border: Border.all(color: UiColors.border), - boxShadow: [ + boxShadow: [ BoxShadow( color: UiColors.black.withValues(alpha: 0.05), blurRadius: 2, @@ -77,155 +54,95 @@ class ShiftAssignmentCard extends StatelessWidget { ], ), child: Column( - children: [ - // Header + children: [ Padding( padding: const EdgeInsets.all(UiConstants.space4), - child: Column( + child: Row( crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Card content starts directly as per prototype - - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Logo - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - UiColors.primary.withValues(alpha: 0.09), - UiColors.primary.withValues(alpha: 0.03), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all( - color: UiColors.primary.withValues(alpha: 0.09), - ), - ), - child: shift.logoUrl != null - ? ClipRRect( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - child: Image.network( - shift.logoUrl!, - fit: BoxFit.contain, - ), - ) - : const Center( - child: Icon( - UiIcons.briefcase, - color: UiColors.primary, - size: 20, - ), - ), + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + UiColors.primary.withValues(alpha: 0.09), + UiColors.primary.withValues(alpha: 0.03), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, ), - const SizedBox(width: UiConstants.space3), - - // Details - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - shift.title, - style: UiTypography.body2m.textPrimary, - overflow: TextOverflow.ellipsis, - ), - Text( - shift.clientName, - style: UiTypography.body3r.textSecondary, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - const SizedBox(width: UiConstants.space2), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - "\$${totalPay.toStringAsFixed(0)}", - style: UiTypography.title1m.textPrimary, - ), - Text( - "\$${shift.hourlyRate.toInt()}/hr · ${hours.toInt()}h", - style: UiTypography.footnote2r.textSecondary, - ), - ], - ), - ], + borderRadius: + BorderRadius.circular(UiConstants.radiusBase), + border: Border.all( + color: UiColors.primary.withValues(alpha: 0.09), + ), + ), + child: const Center( + child: Icon( + UiIcons.briefcase, + color: UiColors.primary, + size: 20, + ), + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + assignment.roleName, + style: UiTypography.body2m.textPrimary, + overflow: TextOverflow.ellipsis, + ), + if (assignment.title.isNotEmpty) + Text( + assignment.title, + style: UiTypography.body3r.textSecondary, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: UiConstants.space3), + Row( + children: [ + const Icon(UiIcons.calendar, + size: 12, color: UiColors.iconSecondary), + const SizedBox(width: 4), + Text( + _formatDate(assignment.startTime), + style: UiTypography.footnote1r.textSecondary, ), - const SizedBox(height: UiConstants.space3), - - // Date & Time - Row( - children: [ - const Icon( - UiIcons.calendar, - size: 12, - color: UiColors.iconSecondary, - ), - const SizedBox(width: 4), - Text( - _formatDate(shift.date), - style: UiTypography.footnote1r.textSecondary, - ), - const SizedBox(width: UiConstants.space3), - const Icon( - UiIcons.clock, - size: 12, - color: UiColors.iconSecondary, - ), - const SizedBox(width: 4), - Text( - "${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}", - style: UiTypography.footnote1r.textSecondary, - ), - ], - ), - const SizedBox(height: 4), - - // Location - Row( - children: [ - const Icon( - UiIcons.mapPin, - size: 12, - color: UiColors.iconSecondary, - ), - const SizedBox(width: 4), - Expanded( - child: Text( - shift.locationAddress.isNotEmpty - ? shift.locationAddress - : shift.location, - style: UiTypography.footnote1r.textSecondary, - overflow: TextOverflow.ellipsis, - ), - ), - ], + const SizedBox(width: UiConstants.space3), + const Icon(UiIcons.clock, + size: 12, color: UiColors.iconSecondary), + const SizedBox(width: 4), + Text( + '${_formatTime(assignment.startTime)} - ${_formatTime(assignment.endTime)}', + style: UiTypography.footnote1r.textSecondary, ), ], ), - ), - ], + const SizedBox(height: 4), + Row( + children: [ + const Icon(UiIcons.mapPin, + size: 12, color: UiColors.iconSecondary), + const SizedBox(width: 4), + Expanded( + child: Text( + assignment.location, + style: UiTypography.footnote1r.textSecondary, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), ), ], ), ), - - // Actions Container( padding: const EdgeInsets.all(UiConstants.space2), decoration: const BoxDecoration( @@ -236,17 +153,14 @@ class ShiftAssignmentCard extends StatelessWidget { ), ), child: Row( - children: [ + children: [ Expanded( child: TextButton( onPressed: onDecline, style: TextButton.styleFrom( foregroundColor: UiColors.destructive, ), - child: Text( - "Decline", // Fallback if translation is broken - style: UiTypography.body2m.textError, - ), + child: Text('Decline', style: UiTypography.body2m.textError), ), ), const SizedBox(width: UiConstants.space2), @@ -258,7 +172,8 @@ class ShiftAssignmentCard extends StatelessWidget { foregroundColor: UiColors.white, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + borderRadius: BorderRadius.circular( + UiConstants.radiusMdValue), ), ), child: isConfirming @@ -270,10 +185,7 @@ class ShiftAssignmentCard extends StatelessWidget { color: UiColors.white, ), ) - : Text( - "Accept", // Fallback - style: UiTypography.body2m.white, - ), + : Text('Accept', style: UiTypography.body2m.white), ), ), ], diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_break_section.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_break_section.dart deleted file mode 100644 index 50288460..00000000 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_break_section.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; -import 'package:krow_domain/krow_domain.dart'; - -/// A section displaying shift break details (duration and payment status). -class ShiftBreakSection extends StatelessWidget { - /// The break information. - final Break breakInfo; - - /// Localization string for break section title. - final String breakTitle; - - /// Localization string for paid status. - final String paidLabel; - - /// Localization string for unpaid status. - final String unpaidLabel; - - /// Localization string for minutes ("min"). - final String minLabel; - - /// Creates a [ShiftBreakSection]. - const ShiftBreakSection({ - super.key, - required this.breakInfo, - required this.breakTitle, - required this.paidLabel, - required this.unpaidLabel, - required this.minLabel, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - breakTitle, - style: UiTypography.titleUppercase4b.textSecondary, - ), - const SizedBox(height: UiConstants.space2), - Row( - children: [ - const Icon( - UiIcons.breakIcon, - size: 20, - color: UiColors.primary, - ), - const SizedBox(width: UiConstants.space2), - Text( - "${breakInfo.duration.minutes} $minLabel (${breakInfo.isBreakPaid ? paidLabel : unpaidLabel})", - style: UiTypography.headline5m.textPrimary, - ), - ], - ), - ], - ), - ); - } -} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_date_time_section.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_date_time_section.dart index 67e8b4b5..3e38f151 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_date_time_section.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_date_time_section.dart @@ -4,17 +4,25 @@ import 'package:intl/intl.dart'; /// A section displaying the date and the shift's start/end times. class ShiftDateTimeSection extends StatelessWidget { - /// The ISO string of the date. - final String date; + /// Creates a [ShiftDateTimeSection]. + const ShiftDateTimeSection({ + super.key, + required this.date, + required this.startTime, + required this.endTime, + required this.shiftDateLabel, + required this.clockInLabel, + required this.clockOutLabel, + }); - /// The end date string (ISO). - final String? endDate; + /// The shift date. + final DateTime date; - /// The start time string (HH:mm). - final String startTime; + /// Scheduled start time. + final DateTime startTime; - /// The end time string (HH:mm). - final String endTime; + /// Scheduled end time. + final DateTime endTime; /// Localization string for shift date. final String shiftDateLabel; @@ -25,40 +33,9 @@ class ShiftDateTimeSection extends StatelessWidget { /// Localization string for clock out time. final String clockOutLabel; - /// Creates a [ShiftDateTimeSection]. - const ShiftDateTimeSection({ - super.key, - required this.date, - required this.endDate, - required this.startTime, - required this.endTime, - required this.shiftDateLabel, - required this.clockInLabel, - required this.clockOutLabel, - }); + String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt); - String _formatTime(String time) { - if (time.isEmpty) return ''; - try { - final parts = time.split(':'); - final hour = int.parse(parts[0]); - final minute = int.parse(parts[1]); - final dt = DateTime(2022, 1, 1, hour, minute); - return DateFormat('h:mm a').format(dt); - } catch (e) { - return time; - } - } - - String _formatDate(String dateStr) { - if (dateStr.isEmpty) return ''; - try { - final date = DateTime.parse(dateStr); - return DateFormat('EEEE, MMMM d, y').format(date); - } catch (e) { - return dateStr; - } - } + String _formatDate(DateTime dt) => DateFormat('EEEE, MMMM d, y').format(dt); @override Widget build(BuildContext context) { @@ -66,17 +43,17 @@ class ShiftDateTimeSection extends StatelessWidget { padding: const EdgeInsets.all(UiConstants.space5), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ Text( shiftDateLabel, style: UiTypography.titleUppercase4b.textSecondary, ), const SizedBox(height: UiConstants.space2), Row( - children: [ + children: [ const Icon( UiIcons.calendar, size: 20, @@ -91,36 +68,9 @@ class ShiftDateTimeSection extends StatelessWidget { ), ], ), - if (endDate != null) ...[ - const SizedBox(height: UiConstants.space6), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'SHIFT END DATE', - style: UiTypography.titleUppercase4b.textSecondary, - ), - const SizedBox(height: UiConstants.space2), - Row( - children: [ - const Icon( - UiIcons.calendar, - size: 20, - color: UiColors.primary, - ), - const SizedBox(width: UiConstants.space2), - Text( - _formatDate(endDate!), - style: UiTypography.headline5m.textPrimary, - ), - ], - ), - ], - ), - ], const SizedBox(height: UiConstants.space6), Row( - children: [ + children: [ Expanded(child: _buildTimeBox(clockInLabel, startTime)), const SizedBox(width: UiConstants.space4), Expanded(child: _buildTimeBox(clockOutLabel, endTime)), @@ -131,7 +81,7 @@ class ShiftDateTimeSection extends StatelessWidget { ); } - Widget _buildTimeBox(String label, String time) { + Widget _buildTimeBox(String label, DateTime time) { return Container( padding: const EdgeInsets.all(UiConstants.space3), decoration: BoxDecoration( @@ -139,7 +89,7 @@ class ShiftDateTimeSection extends StatelessWidget { borderRadius: BorderRadius.circular(UiConstants.radiusBase), ), child: Column( - children: [ + children: [ Text( label, style: UiTypography.footnote2b.copyWith( diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart index 4ad8cba7..b272adf5 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart @@ -7,8 +7,17 @@ import 'package:krow_domain/krow_domain.dart'; /// A bottom action bar containing contextual buttons based on shift status. class ShiftDetailsBottomBar extends StatelessWidget { - /// The current shift. - final Shift shift; + /// Creates a [ShiftDetailsBottomBar]. + const ShiftDetailsBottomBar({ + super.key, + required this.detail, + required this.onApply, + required this.onDecline, + required this.onAccept, + }); + + /// The shift detail entity. + final ShiftDetail detail; /// Callback for applying/booking a shift. final VoidCallback onApply; @@ -19,19 +28,9 @@ class ShiftDetailsBottomBar extends StatelessWidget { /// Callback for accepting a shift. final VoidCallback onAccept; - /// Creates a [ShiftDetailsBottomBar]. - const ShiftDetailsBottomBar({ - super.key, - required this.shift, - required this.onApply, - required this.onDecline, - required this.onAccept, - }); - @override Widget build(BuildContext context) { - final String status = shift.status ?? 'open'; - final i18n = Translations.of(context).staff_shifts.shift_details; + final dynamic i18n = Translations.of(context).staff_shifts.shift_details; return Container( padding: EdgeInsets.fromLTRB( @@ -40,16 +39,17 @@ class ShiftDetailsBottomBar extends StatelessWidget { UiConstants.space5, MediaQuery.of(context).padding.bottom + UiConstants.space4, ), - decoration: BoxDecoration( + decoration: const BoxDecoration( color: UiColors.white, border: Border(top: BorderSide(color: UiColors.border)), ), - child: _buildButtons(status, i18n, context), + child: _buildButtons(i18n, context), ); } - Widget _buildButtons(String status, dynamic i18n, BuildContext context) { - if (status == 'confirmed') { + Widget _buildButtons(dynamic i18n, BuildContext context) { + // If worker has an accepted assignment, show clock-in + if (detail.assignmentStatus == AssignmentStatus.accepted) { return UiButton.primary( onPressed: () => Modular.to.toClockIn(), fullWidth: true, @@ -57,9 +57,10 @@ class ShiftDetailsBottomBar extends StatelessWidget { ); } - if (status == 'pending') { + // If worker has a pending (assigned) assignment, show accept/decline + if (detail.assignmentStatus == AssignmentStatus.assigned) { return Row( - children: [ + children: [ Expanded( child: UiButton.secondary( onPressed: onDecline, @@ -70,14 +71,17 @@ class ShiftDetailsBottomBar extends StatelessWidget { Expanded( child: UiButton.primary( onPressed: onAccept, - child: Text(i18n.accept_shift, style: UiTypography.body2b.white), + child: + Text(i18n.accept_shift, style: UiTypography.body2b.white), ), ), ], ); } - if (status == 'open' || status == 'available') { + // If worker has no assignment and no pending application, show apply + if (detail.assignmentStatus == null && + detail.applicationStatus == null) { return UiButton.primary( onPressed: onApply, fullWidth: true, diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart index ea594220..c822d5e2 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart @@ -2,13 +2,16 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart'; -/// A header widget for the shift details page displaying the role, client name, and address. -class ShiftDetailsHeader extends StatelessWidget { - /// The shift entity containing the header information. - final Shift shift; +/// Size of the role icon container in the shift details header. +const double _kIconContainerSize = 68.0; +/// A header widget for the shift details page displaying the role and address. +class ShiftDetailsHeader extends StatelessWidget { /// Creates a [ShiftDetailsHeader]. - const ShiftDetailsHeader({super.key, required this.shift}); + const ShiftDetailsHeader({super.key, required this.detail}); + + /// The shift detail entity. + final ShiftDetail detail; @override Widget build(BuildContext context) { @@ -17,15 +20,14 @@ class ShiftDetailsHeader extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: UiConstants.space4, - children: [ - // Icon + role name + client name + children: [ Row( crossAxisAlignment: CrossAxisAlignment.center, spacing: UiConstants.space4, - children: [ + children: [ Container( - width: 68, - height: 68, + width: _kIconContainerSize, + height: _kIconContainerSize, decoration: BoxDecoration( color: UiColors.primary.withAlpha(20), borderRadius: UiConstants.radiusLg, @@ -42,19 +44,17 @@ class ShiftDetailsHeader extends StatelessWidget { Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(shift.title, style: UiTypography.headline1b.textPrimary), - Text(shift.clientName, style: UiTypography.body1m.textSecondary), + children: [ + Text(detail.title, style: UiTypography.headline1b.textPrimary), + Text(detail.roleName, style: UiTypography.body1m.textSecondary), ], ), ), ], ), - - // Location address Row( spacing: UiConstants.space1, - children: [ + children: [ const Icon( UiIcons.mapPin, size: 16, @@ -62,7 +62,7 @@ class ShiftDetailsHeader extends StatelessWidget { ), Expanded( child: Text( - shift.locationAddress, + detail.address ?? detail.location, style: UiTypography.body2r.textSecondary, ), ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_map.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_map.dart deleted file mode 100644 index 95cf174a..00000000 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_map.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:design_system/design_system.dart'; -import 'package:krow_domain/krow_domain.dart'; -import 'package:google_maps_flutter/google_maps_flutter.dart'; - -/// A widget that displays the shift location on an interactive Google Map. -class ShiftLocationMap extends StatefulWidget { - /// The shift entity containing location and coordinates. - final Shift shift; - - /// The height of the map widget. - final double height; - - /// The border radius for the map container. - final double borderRadius; - - /// Creates a [ShiftLocationMap]. - const ShiftLocationMap({ - super.key, - required this.shift, - this.height = 120, - this.borderRadius = 8, - }); - - @override - State createState() => _ShiftLocationMapState(); -} - -class _ShiftLocationMapState extends State { - late final CameraPosition _initialPosition; - final Set _markers = {}; - - @override - void initState() { - super.initState(); - - // Default to a fallback coordinate if latitude/longitude are null. - // In a real app, you might want to geocode the address if coordinates are missing. - final double lat = widget.shift.latitude ?? 0.0; - final double lng = widget.shift.longitude ?? 0.0; - - final LatLng position = LatLng(lat, lng); - - _initialPosition = CameraPosition( - target: position, - zoom: 15, - ); - - _markers.add( - Marker( - markerId: MarkerId(widget.shift.id), - position: position, - infoWindow: InfoWindow( - title: widget.shift.location, - snippet: widget.shift.locationAddress, - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - // If coordinates are missing, we show a placeholder. - if (widget.shift.latitude == null || widget.shift.longitude == null) { - return _buildPlaceholder(context, "Coordinates unavailable"); - } - - return Container( - height: widget.height * 1.25, // Slightly taller to accommodate map controls - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(widget.borderRadius), - border: Border.all(color: UiColors.border), - ), - clipBehavior: Clip.antiAlias, - child: GoogleMap( - initialCameraPosition: _initialPosition, - markers: _markers, - liteModeEnabled: true, // Optimized for static-like display in details page - scrollGesturesEnabled: false, - zoomGesturesEnabled: true, - tiltGesturesEnabled: false, - rotateGesturesEnabled: false, - myLocationButtonEnabled: false, - mapToolbarEnabled: false, - compassEnabled: false, - ), - ); - } - - Widget _buildPlaceholder(BuildContext context, String message) { - return Container( - height: widget.height, - width: double.infinity, - decoration: BoxDecoration( - color: UiColors.bgThird, - borderRadius: BorderRadius.circular(widget.borderRadius), - border: Border.all(color: UiColors.border), - ), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - UiIcons.mapPin, - size: 32, - color: UiColors.primary, - ), - if (message.isNotEmpty) ...[ - const SizedBox(height: UiConstants.space2), - Text( - message, - style: UiTypography.footnote2r.textSecondary, - ), - ], - ], - ), - ), - ); - } -} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_section.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_section.dart index c9d557cb..e85910b6 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_section.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_section.dart @@ -1,14 +1,25 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'package:krow_domain/krow_domain.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'shift_location_map.dart'; -/// A section displaying the shift's location, address, map, and "Get direction" action. +/// A section displaying the shift's location, address, and "Get direction" action. class ShiftLocationSection extends StatelessWidget { - /// The shift entity containing location data. - final Shift shift; + /// Creates a [ShiftLocationSection]. + const ShiftLocationSection({ + super.key, + required this.location, + required this.address, + required this.locationLabel, + required this.tbdLabel, + required this.getDirectionLabel, + }); + + /// Human-readable location label. + final String location; + + /// Street address. + final String address; /// Localization string for location section title. final String locationLabel; @@ -19,15 +30,6 @@ class ShiftLocationSection extends StatelessWidget { /// Localization string for "Get direction". final String getDirectionLabel; - /// Creates a [ShiftLocationSection]. - const ShiftLocationSection({ - super.key, - required this.shift, - required this.locationLabel, - required this.tbdLabel, - required this.getDirectionLabel, - }); - @override Widget build(BuildContext context) { return Padding( @@ -36,33 +38,32 @@ class ShiftLocationSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, spacing: UiConstants.space4, - children: [ + children: [ Column( spacing: UiConstants.space2, crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, - children: [ + children: [ Text( locationLabel, style: UiTypography.titleUppercase4b.textSecondary, ), - Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, spacing: UiConstants.space4, - children: [ + children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ Text( - shift.location.isEmpty ? tbdLabel : shift.location, + location.isEmpty ? tbdLabel : location, style: UiTypography.title1m.textPrimary, overflow: TextOverflow.ellipsis, ), - if (shift.locationAddress.isNotEmpty) + if (address.isNotEmpty) Text( - shift.locationAddress, + address, style: UiTypography.body2r.textSecondary, maxLines: 2, overflow: TextOverflow.ellipsis, @@ -96,28 +97,19 @@ class ShiftLocationSection extends StatelessWidget { ), ], ), - - ShiftLocationMap( - shift: shift, - borderRadius: UiConstants.radiusBase, - ), ], ), ); } Future _openDirections(BuildContext context) async { - final destination = (shift.latitude != null && shift.longitude != null) - ? '${shift.latitude},${shift.longitude}' - : Uri.encodeComponent( - shift.locationAddress.isNotEmpty - ? shift.locationAddress - : shift.location, - ); + final String destination = Uri.encodeComponent( + address.isNotEmpty ? address : location, + ); final String url = 'https://www.google.com/maps/dir/?api=1&destination=$destination'; - final uri = Uri.parse(url); + final Uri uri = Uri.parse(url); if (await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication); diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_schedule_summary_section.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_schedule_summary_section.dart deleted file mode 100644 index 2600c302..00000000 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_schedule_summary_section.dart +++ /dev/null @@ -1,162 +0,0 @@ -import 'package:core_localization/core_localization.dart'; -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:krow_domain/krow_domain.dart'; - -/// A section displaying the shift type, date range, and weekday schedule summary. -class ShiftScheduleSummarySection extends StatelessWidget { - /// The shift entity. - final Shift shift; - - /// Creates a [ShiftScheduleSummarySection]. - const ShiftScheduleSummarySection({super.key, required this.shift}); - - String _getShiftTypeLabel(Translations t) { - final String type = (shift.orderType ?? '').toUpperCase(); - if (type == 'PERMANENT') { - return t.staff_shifts.filter.long_term; - } - if (type == 'RECURRING') { - return t.staff_shifts.filter.multi_day; - } - return t.staff_shifts.filter.one_day; - } - - bool _isMultiDayOrLongTerm() { - final String type = (shift.orderType ?? '').toUpperCase(); - return type == 'RECURRING' || type == 'PERMANENT'; - } - - @override - Widget build(BuildContext context) { - final t = Translations.of(context); - final isMultiDay = _isMultiDayOrLongTerm(); - final typeLabel = _getShiftTypeLabel(t); - final String orderType = (shift.orderType ?? '').toUpperCase(); - - return Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Shift Type Title - UiChip(label: typeLabel, variant: UiChipVariant.secondary), - const SizedBox(height: UiConstants.space2), - - if (isMultiDay) ...[ - // Date Range - if (shift.startDate != null && shift.endDate != null) - Row( - children: [ - const Icon( - UiIcons.calendar, - size: 16, - color: UiColors.textSecondary, - ), - const SizedBox(width: UiConstants.space2), - Text( - '${_formatDate(shift.startDate!)} – ${_formatDate(shift.endDate!)}', - style: UiTypography.body2m.textPrimary, - ), - ], - ), - - const SizedBox(height: UiConstants.space4), - - // Weekday Circles - _buildWeekdaySchedule(context), - - // Available Shifts Count (Only for RECURRING/Multi-Day) - if (orderType == 'RECURRING' && shift.schedules != null) ...[ - const SizedBox(height: UiConstants.space4), - Row( - children: [ - Container( - width: 8, - height: 8, - decoration: const BoxDecoration( - color: UiColors.success, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: UiConstants.space2), - Text( - '${shift.schedules!.length} available shifts', - style: UiTypography.body2b.copyWith( - color: UiColors.textSuccess, - ), - ), - ], - ), - ], - ], - ], - ), - ); - } - - String _formatDate(String dateStr) { - try { - final date = DateTime.parse(dateStr); - return DateFormat('MMM d, y').format(date); - } catch (_) { - return dateStr; - } - } - - Widget _buildWeekdaySchedule(BuildContext context) { - final List weekDays = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; - final Set activeDays = _getActiveWeekdayIndices(); - - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: List.generate(weekDays.length, (index) { - final bool isActive = activeDays.contains(index); // 1-7 (Mon-Sun) - return Container( - width: 38, - height: 38, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isActive ? UiColors.primaryInverse : UiColors.bgThird, - border: Border.all( - color: isActive ? UiColors.primary : UiColors.border, - ), - ), - child: Center( - child: Text( - weekDays[index], - style: UiTypography.body2b.copyWith( - color: isActive ? UiColors.primary : UiColors.textSecondary, - ), - ), - ), - ); - }), - ); - } - - Set _getActiveWeekdayIndices() { - final List days = shift.recurringDays ?? shift.permanentDays ?? []; - return days.map((day) { - switch (day.toUpperCase()) { - case 'MON': - return DateTime.monday; - case 'TUE': - return DateTime.tuesday; - case 'WED': - return DateTime.wednesday; - case 'THU': - return DateTime.thursday; - case 'FRI': - return DateTime.friday; - case 'SAT': - return DateTime.saturday; - case 'SUN': - return DateTime.sunday; - default: - return -1; - } - }).toSet(); - } -} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart index a9468691..60f0b0d2 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart @@ -3,27 +3,28 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:geolocator/geolocator.dart'; +import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../blocs/shifts/shifts_bloc.dart'; -import '../my_shift_card.dart'; -import '../shared/empty_state_view.dart'; +import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; +import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart'; +/// Tab showing open shifts available for the worker to browse and apply. class FindShiftsTab extends StatefulWidget { - final List availableJobs; - - /// Whether the worker's profile is complete. When false, shows incomplete - /// profile banner and disables apply actions. - final bool profileComplete; - + /// Creates a [FindShiftsTab]. const FindShiftsTab({ super.key, required this.availableJobs, this.profileComplete = true, }); + /// Open shifts loaded from the V2 API. + final List availableJobs; + + /// Whether the worker's profile is complete. + final bool profileComplete; + @override State createState() => _FindShiftsTabState(); } @@ -31,230 +32,21 @@ class FindShiftsTab extends StatefulWidget { class _FindShiftsTabState extends State { String _searchQuery = ''; String _jobType = 'all'; - double? _maxDistance; // miles - Position? _currentPosition; - @override - void initState() { - super.initState(); - _initLocation(); - } + String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt); - Future _initLocation() async { - try { - final LocationPermission permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.always || - permission == LocationPermission.whileInUse) { - final Position pos = await Geolocator.getCurrentPosition(); - if (mounted) { - setState(() => _currentPosition = pos); - } - } - } catch (_) {} - } - - double _calculateDistance(double lat, double lng) { - if (_currentPosition == null) return -1; - final double distMeters = Geolocator.distanceBetween( - _currentPosition!.latitude, - _currentPosition!.longitude, - lat, - lng, - ); - return distMeters / 1609.34; // meters to miles - } - - void _showDistanceFilter() { - showModalBottomSheet( - context: context, - backgroundColor: UiColors.bgPopup, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (BuildContext context) { - return StatefulBuilder( - builder: (BuildContext context, StateSetter setModalState) { - return Container( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.t.staff_shifts.find_shifts.radius_filter_title, - style: UiTypography.headline4m.textPrimary, - ), - const SizedBox(height: 16), - Text( - _maxDistance == null - ? context.t.staff_shifts.find_shifts.unlimited_distance - : context.t.staff_shifts.find_shifts.within_miles( - miles: _maxDistance!.round().toString(), - ), - style: UiTypography.body2m.textSecondary, - ), - Slider( - value: _maxDistance ?? 100, - min: 5, - max: 100, - divisions: 19, - activeColor: UiColors.primary, - onChanged: (double val) { - setModalState(() => _maxDistance = val); - setState(() => _maxDistance = val); - }, - ), - const SizedBox(height: 24), - Row( - children: [ - Expanded( - child: UiButton.secondary( - text: context.t.staff_shifts.find_shifts.clear, - onPressed: () { - setModalState(() => _maxDistance = null); - setState(() => _maxDistance = null); - Navigator.pop(context); - }, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UiButton.primary( - text: context.t.staff_shifts.find_shifts.apply, - onPressed: () => Navigator.pop(context), - ), - ), - ], - ), - ], - ), - ); - }, - ); - }, - ); - } - - bool _isRecurring(Shift shift) => - (shift.orderType ?? '').toUpperCase() == 'RECURRING'; - - bool _isPermanent(Shift shift) => - (shift.orderType ?? '').toUpperCase() == 'PERMANENT'; - - DateTime? _parseShiftDate(String date) { - if (date.isEmpty) return null; - try { - return DateTime.parse(date); - } catch (_) { - return null; - } - } - - List _groupMultiDayShifts(List shifts) { - final Map> grouped = >{}; - for (final shift in shifts) { - if (!_isRecurring(shift) && !_isPermanent(shift)) { - continue; - } - final orderId = shift.orderId; - final roleId = shift.roleId; - if (orderId == null || roleId == null) { - continue; - } - final key = '$orderId::$roleId'; - grouped.putIfAbsent(key, () => []).add(shift); - } - - final Set addedGroups = {}; - final List result = []; - - for (final shift in shifts) { - if (!_isRecurring(shift) && !_isPermanent(shift)) { - result.add(shift); - continue; - } - final orderId = shift.orderId; - final roleId = shift.roleId; - if (orderId == null || roleId == null) { - result.add(shift); - continue; - } - final key = '$orderId::$roleId'; - if (addedGroups.contains(key)) { - continue; - } - addedGroups.add(key); - final List group = grouped[key] ?? []; - if (group.isEmpty) { - result.add(shift); - continue; - } - group.sort((a, b) { - final ad = _parseShiftDate(a.date); - final bd = _parseShiftDate(b.date); - if (ad == null && bd == null) return 0; - if (ad == null) return 1; - if (bd == null) return -1; - return ad.compareTo(bd); - }); - - final Shift first = group.first; - final List schedules = group - .map( - (s) => ShiftSchedule( - date: s.date, - startTime: s.startTime, - endTime: s.endTime, - ), - ) - .toList(); - - result.add( - Shift( - id: first.id, - roleId: first.roleId, - title: first.title, - clientName: first.clientName, - logoUrl: first.logoUrl, - hourlyRate: first.hourlyRate, - location: first.location, - locationAddress: first.locationAddress, - date: first.date, - endDate: first.endDate, - startTime: first.startTime, - endTime: first.endTime, - createdDate: first.createdDate, - tipsAvailable: first.tipsAvailable, - travelTime: first.travelTime, - mealProvided: first.mealProvided, - parkingAvailable: first.parkingAvailable, - gasCompensation: first.gasCompensation, - description: first.description, - instructions: first.instructions, - managers: first.managers, - latitude: first.latitude, - longitude: first.longitude, - status: first.status, - durationDays: schedules.length, - requiredSlots: first.requiredSlots, - filledSlots: first.filledSlots, - hasApplied: first.hasApplied, - totalValue: first.totalValue, - breakInfo: first.breakInfo, - orderId: first.orderId, - orderType: first.orderType, - schedules: schedules, - recurringDays: first.recurringDays, - permanentDays: first.permanentDays, - ), - ); - } - - return result; + String _formatDate(DateTime date) { + final DateTime now = DateTime.now(); + final DateTime today = DateTime(now.year, now.month, now.day); + final DateTime tomorrow = today.add(const Duration(days: 1)); + final DateTime d = DateTime(date.year, date.month, date.day); + if (d == today) return 'Today'; + if (d == tomorrow) return 'Tomorrow'; + return DateFormat('EEE, MMM d').format(date); } Widget _buildFilterTab(String id, String label) { - final isSelected = _jobType == id; + final bool isSelected = _jobType == id; return GestureDetector( onTap: () => setState(() => _jobType = id), child: Container( @@ -280,43 +72,184 @@ class _FindShiftsTabState extends State { ); } + List _filterByType(List shifts) { + if (_jobType == 'all') return shifts; + return shifts.where((OpenShift s) { + if (_jobType == 'one-day') return s.orderType == OrderType.oneTime; + if (_jobType == 'multi-day') return s.orderType == OrderType.recurring; + if (_jobType == 'long-term') return s.orderType == OrderType.permanent; + return true; + }).toList(); + } + + /// Builds an open shift card. + Widget _buildOpenShiftCard(BuildContext context, OpenShift shift) { + final double hourlyRate = shift.hourlyRateCents / 100; + final int minutes = shift.endTime.difference(shift.startTime).inMinutes; + final double duration = minutes / 60; + final double estimatedTotal = hourlyRate * duration; + + String typeLabel; + switch (shift.orderType) { + case OrderType.permanent: + typeLabel = t.staff_shifts.filter.long_term; + case OrderType.recurring: + typeLabel = t.staff_shifts.filter.multi_day; + case OrderType.oneTime: + default: + typeLabel = t.staff_shifts.filter.one_day; + } + + return GestureDetector( + onTap: () => Modular.to.toShiftDetailsById(shift.shiftId), + child: Container( + margin: const EdgeInsets.only(bottom: UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + ), + child: Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Type badge + Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space2), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: UiColors.background, + borderRadius: UiConstants.radiusSm, + border: Border.all(color: UiColors.border), + ), + child: Text( + typeLabel, + style: UiTypography.footnote2m + .copyWith(color: UiColors.textSecondary), + ), + ), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + UiColors.primary.withValues(alpha: 0.09), + UiColors.primary.withValues(alpha: 0.03), + ], + ), + borderRadius: + BorderRadius.circular(UiConstants.radiusBase), + ), + child: const Center( + child: Icon(UiIcons.briefcase, + color: UiColors.primary, size: UiConstants.iconMd), + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(shift.roleName, + style: UiTypography.body2m.textPrimary, + overflow: TextOverflow.ellipsis), + Text(shift.location, + style: UiTypography.body3r.textSecondary, + overflow: TextOverflow.ellipsis), + ], + ), + ), + const SizedBox(width: UiConstants.space2), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('\$${estimatedTotal.toStringAsFixed(0)}', + style: UiTypography.title1m.textPrimary), + Text( + '\$${hourlyRate.toInt()}/hr \u00b7 ${duration.toInt()}h', + style: + UiTypography.footnote2r.textSecondary), + ], + ), + ], + ), + const SizedBox(height: UiConstants.space2), + Row( + children: [ + const Icon(UiIcons.calendar, + size: UiConstants.iconXs, + color: UiColors.iconSecondary), + const SizedBox(width: UiConstants.space1), + Text(_formatDate(shift.date), + style: UiTypography.footnote1r.textSecondary), + const SizedBox(width: UiConstants.space3), + const Icon(UiIcons.clock, + size: UiConstants.iconXs, + color: UiColors.iconSecondary), + const SizedBox(width: UiConstants.space1), + Text( + '${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}', + style: UiTypography.footnote1r.textSecondary), + ], + ), + const SizedBox(height: UiConstants.space1), + Row( + children: [ + const Icon(UiIcons.mapPin, + size: UiConstants.iconXs, + color: UiColors.iconSecondary), + const SizedBox(width: UiConstants.space1), + Expanded( + child: Text(shift.location, + style: UiTypography.footnote1r.textSecondary, + overflow: TextOverflow.ellipsis), + ), + ], + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { - final groupedJobs = _groupMultiDayShifts(widget.availableJobs); - - // Filter logic - final filteredJobs = groupedJobs.where((s) { - final matchesSearch = - s.title.toLowerCase().contains(_searchQuery.toLowerCase()) || - s.location.toLowerCase().contains(_searchQuery.toLowerCase()) || - s.clientName.toLowerCase().contains(_searchQuery.toLowerCase()); - - if (!matchesSearch) return false; - - if (_maxDistance != null && s.latitude != null && s.longitude != null) { - final double dist = _calculateDistance(s.latitude!, s.longitude!); - if (dist > _maxDistance!) return false; - } - - if (_jobType == 'all') return true; - if (_jobType == 'one-day') { - if (_isRecurring(s) || _isPermanent(s)) return false; - return s.durationDays == null || s.durationDays! <= 1; - } - if (_jobType == 'multi-day') { - return _isRecurring(s) || - (s.durationDays != null && s.durationDays! > 1); - } - if (_jobType == 'long-term') { - return _isPermanent(s); - } - return true; + // Client-side filter by order type + final List filteredJobs = + _filterByType(widget.availableJobs).where((OpenShift s) { + if (_searchQuery.isEmpty) return true; + final String q = _searchQuery.toLowerCase(); + return s.roleName.toLowerCase().contains(q) || + s.location.toLowerCase().contains(q); }).toList(); return Column( - children: [ + children: [ // Incomplete profile banner - if (!widget.profileComplete) ...[ + if (!widget.profileComplete) GestureDetector( onTap: () => Modular.to.toProfile(), child: Container( @@ -324,19 +257,12 @@ class _FindShiftsTabState extends State { child: UiNoticeBanner( icon: UiIcons.sparkles, title: context - .t - .staff_shifts - .find_shifts - .incomplete_profile_banner_title, - description: context - .t - .staff_shifts - .find_shifts + .t.staff_shifts.find_shifts.incomplete_profile_banner_title, + description: context.t.staff_shifts.find_shifts .incomplete_profile_banner_message, ), ), ), - ], // Search and Filters Container( color: UiColors.white, @@ -345,151 +271,76 @@ class _FindShiftsTabState extends State { vertical: UiConstants.space4, ), child: Column( - children: [ - // Search Bar - Row( - children: [ - Expanded( - child: Container( - height: 48, - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space3, - ), - decoration: BoxDecoration( - color: UiColors.background, - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, + children: [ + Container( + height: 48, + padding: + const EdgeInsets.symmetric(horizontal: UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.background, + borderRadius: + BorderRadius.circular(UiConstants.radiusBase), + border: Border.all(color: UiColors.border), + ), + child: Row( + children: [ + const Icon(UiIcons.search, + size: 20, color: UiColors.textInactive), + const SizedBox(width: UiConstants.space2), + Expanded( + child: TextField( + onChanged: (String v) => + setState(() => _searchQuery = v), + decoration: InputDecoration( + border: InputBorder.none, + hintText: + context.t.staff_shifts.find_shifts.search_hint, + hintStyle: UiTypography.body2r.textPlaceholder, ), - border: Border.all(color: UiColors.border), - ), - child: Row( - children: [ - const Icon( - UiIcons.search, - size: 20, - color: UiColors.textInactive, - ), - const SizedBox(width: UiConstants.space2), - Expanded( - child: TextField( - onChanged: (v) => - setState(() => _searchQuery = v), - decoration: InputDecoration( - border: InputBorder.none, - hintText: context - .t - .staff_shifts - .find_shifts - .search_hint, - hintStyle: UiTypography.body2r.textPlaceholder, - ), - ), - ), - ], ), ), - ), - const SizedBox(width: UiConstants.space2), - GestureDetector( - onTap: _showDistanceFilter, - child: Container( - height: 48, - width: 48, - decoration: BoxDecoration( - color: _maxDistance != null - ? UiColors.primary.withValues(alpha: 0.1) - : UiColors.white, - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, - ), - border: Border.all( - color: _maxDistance != null - ? UiColors.primary - : UiColors.border, - ), - ), - child: Icon( - UiIcons.filter, - size: 18, - color: _maxDistance != null - ? UiColors.primary - : UiColors.textSecondary, - ), - ), - ), - ], + ], + ), ), const SizedBox(height: UiConstants.space4), - // Filter Tabs SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( - children: [ + children: [ _buildFilterTab( - 'all', - context.t.staff_shifts.find_shifts.filter_all, - ), + 'all', context.t.staff_shifts.find_shifts.filter_all), const SizedBox(width: UiConstants.space2), - _buildFilterTab( - 'one-day', - context.t.staff_shifts.find_shifts.filter_one_day, - ), + _buildFilterTab('one-day', + context.t.staff_shifts.find_shifts.filter_one_day), const SizedBox(width: UiConstants.space2), - _buildFilterTab( - 'multi-day', - context.t.staff_shifts.find_shifts.filter_multi_day, - ), + _buildFilterTab('multi-day', + context.t.staff_shifts.find_shifts.filter_multi_day), const SizedBox(width: UiConstants.space2), - _buildFilterTab( - 'long-term', - context.t.staff_shifts.find_shifts.filter_long_term, - ), + _buildFilterTab('long-term', + context.t.staff_shifts.find_shifts.filter_long_term), ], ), ), ], ), ), - Expanded( child: filteredJobs.isEmpty ? EmptyStateView( icon: UiIcons.search, title: context.t.staff_shifts.find_shifts.no_jobs_title, - subtitle: context.t.staff_shifts.find_shifts.no_jobs_subtitle, + subtitle: + context.t.staff_shifts.find_shifts.no_jobs_subtitle, ) : SingleChildScrollView( padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space5, - ), + horizontal: UiConstants.space5), child: Column( - children: [ + children: [ const SizedBox(height: UiConstants.space5), ...filteredJobs.map( - (shift) => Padding( - padding: const EdgeInsets.only( - bottom: UiConstants.space3, - ), - child: MyShiftCard( - shift: shift, - onAccept: widget.profileComplete - ? () { - BlocProvider.of( - context, - ).add(AcceptShiftEvent(shift.id)); - UiSnackbar.show( - context, - message: context - .t - .staff_shifts - .find_shifts - .application_submitted, - type: UiSnackbarType.success, - ); - } - : null, - ), - ), + (OpenShift shift) => + _buildOpenShiftCard(context, shift), ), const SizedBox(height: UiConstants.space32), ], diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/history_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/history_shifts_tab.dart index bc24669a..639cc291 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/history_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/history_shifts_tab.dart @@ -1,37 +1,41 @@ import 'package:flutter/material.dart'; -import 'package:design_system/design_system.dart'; -import 'package:krow_domain/krow_domain.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:intl/intl.dart'; +import 'package:design_system/design_system.dart'; import 'package:krow_core/core.dart'; -import '../my_shift_card.dart'; -import '../shared/empty_state_view.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart'; + +/// Tab displaying completed shift history. class HistoryShiftsTab extends StatelessWidget { - final List historyShifts; - + /// Creates a [HistoryShiftsTab]. const HistoryShiftsTab({super.key, required this.historyShifts}); + /// Completed shifts. + final List historyShifts; + @override Widget build(BuildContext context) { if (historyShifts.isEmpty) { return const EmptyStateView( icon: UiIcons.clock, - title: "No shift history", - subtitle: "Completed shifts appear here", + title: 'No shift history', + subtitle: 'Completed shifts appear here', ); } return SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), child: Column( - children: [ + children: [ const SizedBox(height: UiConstants.space5), ...historyShifts.map( - (shift) => Padding( + (CompletedShift shift) => Padding( padding: const EdgeInsets.only(bottom: UiConstants.space3), child: GestureDetector( - onTap: () => Modular.to.toShiftDetails(shift), - child: MyShiftCard(shift: shift), + onTap: () => Modular.to.toShiftDetailsById(shift.shiftId), + child: _CompletedShiftCard(shift: shift), ), ), ), @@ -41,3 +45,89 @@ class HistoryShiftsTab extends StatelessWidget { ); } } + +/// Card displaying a completed shift summary. +class _CompletedShiftCard extends StatelessWidget { + const _CompletedShiftCard({required this.shift}); + + final CompletedShift shift; + + @override + Widget build(BuildContext context) { + final int hours = shift.minutesWorked ~/ 60; + final int mins = shift.minutesWorked % 60; + final String workedLabel = + mins > 0 ? '${hours}h ${mins}m' : '${hours}h'; + + return Container( + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + ), + child: Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: UiColors.primary.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + child: const Center( + child: Icon(UiIcons.briefcase, + color: UiColors.primary, size: UiConstants.iconMd), + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(shift.title, + style: UiTypography.body2m.textPrimary, + overflow: TextOverflow.ellipsis), + const SizedBox(height: UiConstants.space1), + Row( + children: [ + const Icon(UiIcons.calendar, + size: UiConstants.iconXs, + color: UiColors.iconSecondary), + const SizedBox(width: UiConstants.space1), + Text(DateFormat('EEE, MMM d').format(shift.date), + style: UiTypography.footnote1r.textSecondary), + const SizedBox(width: UiConstants.space3), + const Icon(UiIcons.clock, + size: UiConstants.iconXs, + color: UiColors.iconSecondary), + const SizedBox(width: UiConstants.space1), + Text(workedLabel, + style: UiTypography.footnote1r.textSecondary), + ], + ), + const SizedBox(height: UiConstants.space1), + Row( + children: [ + const Icon(UiIcons.mapPin, + size: UiConstants.iconXs, + color: UiColors.iconSecondary), + const SizedBox(width: UiConstants.space1), + Expanded( + child: Text(shift.location, + style: UiTypography.footnote1r.textSecondary, + overflow: TextOverflow.ellipsis), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart index 1075b341..3914292c 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart @@ -4,17 +4,15 @@ import 'package:intl/intl.dart'; import 'package:design_system/design_system.dart'; import 'package:core_localization/core_localization.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../blocs/shifts/shifts_bloc.dart'; -import '../my_shift_card.dart'; -import '../shift_assignment_card.dart'; -import '../shared/empty_state_view.dart'; +import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; +import 'package:staff_shifts/src/presentation/widgets/my_shift_card.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_assignment_card.dart'; +import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart'; + +/// Tab displaying the worker's assigned, pending, and cancelled shifts. class MyShiftsTab extends StatefulWidget { - final List myShifts; - final List pendingAssignments; - final List cancelledShifts; - final DateTime? initialDate; - + /// Creates a [MyShiftsTab]. const MyShiftsTab({ super.key, required this.myShifts, @@ -23,6 +21,18 @@ class MyShiftsTab extends StatefulWidget { this.initialDate, }); + /// Assigned shifts for the current week. + final List myShifts; + + /// Pending assignments awaiting acceptance. + final List pendingAssignments; + + /// Cancelled shift assignments. + final List cancelledShifts; + + /// Initial date to select in the calendar. + final DateTime? initialDate; + @override State createState() => _MyShiftsTabState(); } @@ -165,17 +175,16 @@ class _MyShiftsTabState extends State { ); } - String _formatDateStr(String dateStr) { - try { - final date = DateTime.parse(dateStr); - final now = DateTime.now(); - if (_isSameDay(date, now)) return context.t.staff_shifts.my_shifts_tab.date.today; - final tomorrow = now.add(const Duration(days: 1)); - if (_isSameDay(date, tomorrow)) return context.t.staff_shifts.my_shifts_tab.date.tomorrow; - return DateFormat('EEE, MMM d').format(date); - } catch (_) { - return dateStr; + String _formatDateFromDateTime(DateTime date) { + final DateTime now = DateTime.now(); + if (_isSameDay(date, now)) { + return context.t.staff_shifts.my_shifts_tab.date.today; } + final DateTime tomorrow = now.add(const Duration(days: 1)); + if (_isSameDay(date, tomorrow)) { + return context.t.staff_shifts.my_shifts_tab.date.tomorrow; + } + return DateFormat('EEE, MMM d').format(date); } @override @@ -184,25 +193,15 @@ class _MyShiftsTabState extends State { final weekStartDate = calendarDays.first; final weekEndDate = calendarDays.last; - final visibleMyShifts = widget.myShifts.where((s) { - try { - final date = DateTime.parse(s.date); - return _isSameDay(date, _selectedDate); - } catch (_) { - return false; - } - }).toList(); + final List visibleMyShifts = widget.myShifts.where( + (AssignedShift s) => _isSameDay(s.date, _selectedDate), + ).toList(); - final visibleCancelledShifts = widget.cancelledShifts.where((s) { - try { - final date = DateTime.parse(s.date); - return date.isAfter( - weekStartDate.subtract(const Duration(seconds: 1)), - ) && - date.isBefore(weekEndDate.add(const Duration(days: 1))); - } catch (_) { - return false; - } + final List visibleCancelledShifts = + widget.cancelledShifts.where((CancelledShift s) { + return s.date.isAfter( + weekStartDate.subtract(const Duration(seconds: 1))) && + s.date.isBefore(weekEndDate.add(const Duration(days: 1))); }).toList(); return Column( @@ -263,13 +262,9 @@ class _MyShiftsTabState extends State { final isSelected = _isSameDay(date, _selectedDate); // ignore: unused_local_variable final dateStr = DateFormat('yyyy-MM-dd').format(date); - final hasShifts = widget.myShifts.any((s) { - try { - return _isSameDay(DateTime.parse(s.date), date); - } catch (_) { - return false; - } - }); + final bool hasShifts = widget.myShifts.any( + (AssignedShift s) => _isSameDay(s.date, date), + ); return GestureDetector( onTap: () => setState(() => _selectedDate = date), @@ -342,12 +337,12 @@ class _MyShiftsTabState extends State { UiColors.textWarning, ), ...widget.pendingAssignments.map( - (shift) => Padding( + (PendingAssignment assignment) => Padding( padding: const EdgeInsets.only(bottom: UiConstants.space4), child: ShiftAssignmentCard( - shift: shift, - onConfirm: () => _confirmShift(shift.id), - onDecline: () => _declineShift(shift.id), + assignment: assignment, + onConfirm: () => _confirmShift(assignment.shiftId), + onDecline: () => _declineShift(assignment.shiftId), isConfirming: true, ), ), @@ -358,17 +353,13 @@ class _MyShiftsTabState extends State { if (visibleCancelledShifts.isNotEmpty) ...[ _buildSectionHeader(context.t.staff_shifts.my_shifts_tab.sections.cancelled, UiColors.textSecondary), ...visibleCancelledShifts.map( - (shift) => Padding( + (CancelledShift cs) => Padding( padding: const EdgeInsets.only(bottom: UiConstants.space4), child: _buildCancelledCard( - title: shift.title, - client: shift.clientName, - pay: "\$${(shift.hourlyRate * 8).toStringAsFixed(0)}", - rate: "\$${shift.hourlyRate}/hr · 8h", - date: _formatDateStr(shift.date), - time: "${shift.startTime} - ${shift.endTime}", - address: shift.locationAddress, - isLastMinute: true, + title: cs.title, + location: cs.location, + date: DateFormat('EEE, MMM d').format(cs.date), + reason: cs.cancellationReason, onTap: () {}, ), ), @@ -380,11 +371,11 @@ class _MyShiftsTabState extends State { if (visibleMyShifts.isNotEmpty) ...[ _buildSectionHeader(context.t.staff_shifts.my_shifts_tab.sections.confirmed, UiColors.textSecondary), ...visibleMyShifts.map( - (shift) => Padding( + (AssignedShift shift) => Padding( padding: const EdgeInsets.only(bottom: UiConstants.space3), child: MyShiftCard( shift: shift, - onDecline: () => _declineShift(shift.id), + onDecline: () => _declineShift(shift.shiftId), onRequestSwap: () { UiSnackbar.show( context, @@ -439,13 +430,9 @@ class _MyShiftsTabState extends State { Widget _buildCancelledCard({ required String title, - required String client, - required String pay, - required String rate, + required String location, required String date, - required String time, - required String address, - required bool isLastMinute, + String? reason, required VoidCallback onTap, }) { return GestureDetector( @@ -459,9 +446,9 @@ class _MyShiftsTabState extends State { ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ Row( - children: [ + children: [ Container( width: 6, height: 6, @@ -475,25 +462,19 @@ class _MyShiftsTabState extends State { context.t.staff_shifts.my_shifts_tab.card.cancelled, style: UiTypography.footnote2b.textError, ), - if (isLastMinute) ...[ - const SizedBox(width: 4), - Text( - context.t.staff_shifts.my_shifts_tab.card.compensation, - style: UiTypography.footnote2m.textSuccess, - ), - ], ], ), const SizedBox(height: UiConstants.space3), Row( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ Container( width: 44, height: 44, decoration: BoxDecoration( color: UiColors.primary.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderRadius: + BorderRadius.circular(UiConstants.radiusBase), ), child: const Center( child: Icon( @@ -507,84 +488,42 @@ class _MyShiftsTabState extends State { Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: UiTypography.body2b.textPrimary, - ), - Text( - client, - style: UiTypography.footnote1r.textSecondary, - ), - ], - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - pay, - style: UiTypography.headline4m.textPrimary, - ), - Text( - rate, - style: UiTypography.footnote2r.textSecondary, - ), - ], - ), - ], - ), + children: [ + Text(title, style: UiTypography.body2b.textPrimary), const SizedBox(height: UiConstants.space2), Row( - children: [ - const Icon( - UiIcons.calendar, - size: 12, - color: UiColors.textSecondary, - ), + children: [ + const Icon(UiIcons.calendar, + size: 12, color: UiColors.textSecondary), const SizedBox(width: 4), - Text( - date, - style: UiTypography.footnote1r.textSecondary, - ), - const SizedBox(width: UiConstants.space3), - const Icon( - UiIcons.clock, - size: 12, - color: UiColors.textSecondary, - ), - const SizedBox(width: 4), - Text( - time, - style: UiTypography.footnote1r.textSecondary, - ), + Text(date, + style: UiTypography.footnote1r.textSecondary), ], ), const SizedBox(height: 4), Row( - children: [ - const Icon( - UiIcons.mapPin, - size: 12, - color: UiColors.textSecondary, - ), + children: [ + const Icon(UiIcons.mapPin, + size: 12, color: UiColors.textSecondary), const SizedBox(width: 4), Expanded( child: Text( - address, + location, style: UiTypography.footnote1r.textSecondary, overflow: TextOverflow.ellipsis, ), ), ], ), + if (reason != null && reason.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + reason, + style: UiTypography.footnote2r.textSecondary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], ], ), ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/shift_details_module.dart b/apps/mobile/packages/features/staff/shifts/lib/src/shift_details_module.dart index fba55262..12b958b3 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/shift_details_module.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/shift_details_module.dart @@ -1,46 +1,55 @@ import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; -import 'domain/repositories/shifts_repository_interface.dart'; -import 'data/repositories_impl/shifts_repository_impl.dart'; -import 'domain/usecases/get_shift_details_usecase.dart'; -import 'domain/usecases/accept_shift_usecase.dart'; -import 'domain/usecases/decline_shift_usecase.dart'; -import 'domain/usecases/apply_for_shift_usecase.dart'; -import 'presentation/blocs/shift_details/shift_details_bloc.dart'; -import 'presentation/pages/shift_details_page.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_shifts/src/data/repositories_impl/shifts_repository_impl.dart'; +import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; +import 'package:staff_shifts/src/domain/usecases/apply_for_shift_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/decline_shift_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_shift_details_usecase.dart'; +import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_bloc.dart'; +import 'package:staff_shifts/src/presentation/pages/shift_details_page.dart'; + +/// DI module for the shift details page. +/// +/// Registers the detail-specific repository, use cases, and BLoC using +/// the V2 API via [BaseApiService]. class ShiftDetailsModule extends Module { + @override + List get imports => [CoreModule()]; + @override void binds(Injector i) { // Repository - i.add(ShiftsRepositoryImpl.new); - - // StaffConnectorRepository for profile completion - i.addLazySingleton( - () => StaffConnectorRepositoryImpl(), + i.add( + () => ShiftsRepositoryImpl(apiService: i.get()), ); - // UseCases - i.add(GetShiftDetailsUseCase.new); - i.add(AcceptShiftUseCase.new); - i.add(DeclineShiftUseCase.new); + // Use cases + i.add(GetShiftDetailUseCase.new); i.add(ApplyForShiftUseCase.new); - i.addLazySingleton( - () => GetProfileCompletionUseCase( - repository: i.get(), + i.add(DeclineShiftUseCase.new); + i.add(GetProfileCompletionUseCase.new); + + // BLoC + i.add( + () => ShiftDetailsBloc( + getShiftDetail: i.get(), + applyForShift: i.get(), + declineShift: i.get(), + getProfileCompletion: i.get(), ), ); - - // Bloc - i.add(ShiftDetailsBloc.new); } @override void routes(RouteManager r) { r.child( '/:id', - child: (_) => - ShiftDetailsPage(shiftId: r.args.params['id'], shift: r.args.data), + child: (_) => ShiftDetailsPage( + shiftId: r.args.params['id'] ?? '', + ), ); } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart b/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart index 09866f32..e35cf7cb 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart @@ -1,62 +1,72 @@ import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; -import 'domain/repositories/shifts_repository_interface.dart'; -import 'data/repositories_impl/shifts_repository_impl.dart'; -import 'domain/usecases/get_my_shifts_usecase.dart'; -import 'domain/usecases/get_available_shifts_usecase.dart'; -import 'domain/usecases/get_pending_assignments_usecase.dart'; -import 'domain/usecases/get_cancelled_shifts_usecase.dart'; -import 'domain/usecases/get_history_shifts_usecase.dart'; -import 'domain/usecases/accept_shift_usecase.dart'; -import 'domain/usecases/decline_shift_usecase.dart'; -import 'domain/usecases/apply_for_shift_usecase.dart'; -import 'domain/usecases/get_shift_details_usecase.dart'; -import 'presentation/blocs/shifts/shifts_bloc.dart'; -import 'presentation/blocs/shift_details/shift_details_bloc.dart'; -import 'presentation/utils/shift_tab_type.dart'; -import 'presentation/pages/shifts_page.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_shifts/src/data/repositories_impl/shifts_repository_impl.dart'; +import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; +import 'package:staff_shifts/src/domain/usecases/apply_for_shift_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_available_shifts_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_cancelled_shifts_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_history_shifts_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_my_shifts_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_pending_assignments_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/get_shift_details_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/accept_shift_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/decline_shift_usecase.dart'; +import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; +import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_bloc.dart'; +import 'package:staff_shifts/src/presentation/utils/shift_tab_type.dart'; +import 'package:staff_shifts/src/presentation/pages/shifts_page.dart'; + +/// DI module for the staff shifts feature. +/// +/// Registers repository, use cases, and BLoCs using the V2 API +/// via [BaseApiService]. class StaffShiftsModule extends Module { + @override + List get imports => [CoreModule()]; + @override void binds(Injector i) { - // StaffConnectorRepository for profile completion - i.addLazySingleton( - () => StaffConnectorRepositoryImpl(), - ); - - // Profile completion use case - i.addLazySingleton( - () => GetProfileCompletionUseCase( - repository: i.get(), - ), - ); - // Repository - i.addLazySingleton(ShiftsRepositoryImpl.new); + i.addLazySingleton( + () => ShiftsRepositoryImpl(apiService: i.get()), + ); - // UseCases - i.addLazySingleton(GetMyShiftsUseCase.new); - i.addLazySingleton(GetAvailableShiftsUseCase.new); + // Use cases + i.addLazySingleton(GetAssignedShiftsUseCase.new); + i.addLazySingleton(GetOpenShiftsUseCase.new); i.addLazySingleton(GetPendingAssignmentsUseCase.new); i.addLazySingleton(GetCancelledShiftsUseCase.new); - i.addLazySingleton(GetHistoryShiftsUseCase.new); + i.addLazySingleton(GetCompletedShiftsUseCase.new); i.addLazySingleton(AcceptShiftUseCase.new); i.addLazySingleton(DeclineShiftUseCase.new); i.addLazySingleton(ApplyForShiftUseCase.new); - i.addLazySingleton(GetShiftDetailsUseCase.new); + i.addLazySingleton(GetShiftDetailUseCase.new); + i.addLazySingleton(GetProfileCompletionUseCase.new); - // Bloc + // BLoC i.add( () => ShiftsBloc( - getMyShifts: i.get(), - getAvailableShifts: i.get(), + getAssignedShifts: i.get(), + getOpenShifts: i.get(), getPendingAssignments: i.get(), getCancelledShifts: i.get(), - getHistoryShifts: i.get(), + getCompletedShifts: i.get(), + getProfileCompletion: i.get(), + acceptShift: i.get(), + declineShift: i.get(), + ), + ); + i.add( + () => ShiftDetailsBloc( + getShiftDetail: i.get(), + applyForShift: i.get(), + declineShift: i.get(), getProfileCompletion: i.get(), ), ); - i.add(ShiftDetailsBloc.new); } @override @@ -64,12 +74,14 @@ class StaffShiftsModule extends Module { r.child( '/', child: (_) { - final args = r.args.data as Map?; - final queryParams = r.args.queryParams; - final initialTabStr = queryParams['tab'] ?? args?['initialTab']; + final Map? args = + r.args.data as Map?; + final Map queryParams = r.args.queryParams; + final dynamic initialTabStr = + queryParams['tab'] ?? args?['initialTab']; return ShiftsPage( initialTab: ShiftTabType.fromString(initialTabStr), - selectedDate: args?['selectedDate'], + selectedDate: args?['selectedDate'] as DateTime?, refreshAvailable: args?['refreshAvailable'] == true, ); }, diff --git a/apps/mobile/packages/features/staff/shifts/pubspec.yaml b/apps/mobile/packages/features/staff/shifts/pubspec.yaml index 0f23b89c..3d50a8ad 100644 --- a/apps/mobile/packages/features/staff/shifts/pubspec.yaml +++ b/apps/mobile/packages/features/staff/shifts/pubspec.yaml @@ -12,15 +12,13 @@ dependencies: flutter: sdk: flutter - # Internal packages + # Architecture packages krow_core: path: ../../../core design_system: path: ../../../design_system krow_domain: path: ../../../domain - krow_data_connect: - path: ../../../data_connect core_localization: path: ../../../core_localization @@ -28,11 +26,7 @@ dependencies: flutter_bloc: ^8.1.3 equatable: ^2.0.5 intl: ^0.20.2 - google_maps_flutter: ^2.14.2 url_launcher: ^6.3.1 - firebase_auth: ^6.1.4 - firebase_data_connect: ^0.2.2+2 - meta: ^1.17.0 bloc: ^8.1.4 dev_dependencies: diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/data/repositories/staff_main_repository_impl.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/data/repositories/staff_main_repository_impl.dart new file mode 100644 index 00000000..0cd9e379 --- /dev/null +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/data/repositories/staff_main_repository_impl.dart @@ -0,0 +1,42 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../../domain/repositories/staff_main_repository_interface.dart'; + +/// V2 API implementation of [StaffMainRepositoryInterface]. +/// +/// Calls `GET /staff/profile-completion` and parses the response into a +/// [ProfileCompletion] entity to determine completion status. +class StaffMainRepositoryImpl implements StaffMainRepositoryInterface { + /// Creates a [StaffMainRepositoryImpl]. + StaffMainRepositoryImpl({required BaseApiService apiService}) + : _apiService = apiService; + + /// The API service used for network requests. + final BaseApiService _apiService; + + /// Fetches profile completion from the V2 API. + /// + /// Returns `true` when all required profile sections are complete. + /// Defaults to `true` on error so that navigation is not blocked. + @override + Future getProfileCompletion() async { + try { + final ApiResponse response = await _apiService.get( + V2ApiEndpoints.staffProfileCompletion, + ); + + if (response.data is Map) { + final ProfileCompletion completion = ProfileCompletion.fromJson( + response.data as Map, + ); + return completion.completed; + } + + return true; + } catch (_) { + // Allow full access on error to avoid blocking navigation. + return true; + } + } +} diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/domain/repositories/staff_main_repository_interface.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/domain/repositories/staff_main_repository_interface.dart new file mode 100644 index 00000000..d8f06c96 --- /dev/null +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/domain/repositories/staff_main_repository_interface.dart @@ -0,0 +1,7 @@ +/// Repository interface for staff main shell data access. +/// +/// Provides profile-completion status used to gate bottom-bar tabs. +abstract interface class StaffMainRepositoryInterface { + /// Returns `true` when all required profile sections are complete. + Future getProfileCompletion(); +} diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/domain/usecases/get_profile_completion_usecase.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/domain/usecases/get_profile_completion_usecase.dart new file mode 100644 index 00000000..4e4a26cc --- /dev/null +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/domain/usecases/get_profile_completion_usecase.dart @@ -0,0 +1,20 @@ +import 'package:krow_core/core.dart'; +import 'package:staff_main/src/domain/repositories/staff_main_repository_interface.dart'; + +/// Use case for retrieving staff profile completion status. +/// +/// Delegates to [StaffMainRepositoryInterface] for backend access and +/// returns `true` when all required profile sections are complete. +class GetProfileCompletionUseCase extends NoInputUseCase { + /// Creates a [GetProfileCompletionUseCase]. + GetProfileCompletionUseCase({ + required StaffMainRepositoryInterface repository, + }) : _repository = repository; + + /// The repository used for data access. + final StaffMainRepositoryInterface _repository; + + /// Fetches whether the staff profile is complete. + @override + Future call() => _repository.getProfileCompletion(); +} diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart index 6e10209b..2e6d5d0f 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart @@ -1,26 +1,36 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:staff_main/src/domain/usecases/get_profile_completion_usecase.dart'; import 'package:staff_main/src/presentation/blocs/staff_main_state.dart'; +/// Cubit that manages the staff main shell state. +/// +/// Tracks the active bottom-bar tab index, profile completion status, and +/// bottom bar visibility based on the current route. class StaffMainCubit extends Cubit implements Disposable { + /// Creates a [StaffMainCubit]. StaffMainCubit({ required GetProfileCompletionUseCase getProfileCompletionUsecase, - }) : _getProfileCompletionUsecase = getProfileCompletionUsecase, - super(const StaffMainState()) { + }) : _getProfileCompletionUsecase = getProfileCompletionUsecase, + super(const StaffMainState()) { Modular.to.addListener(_onRouteChanged); _onRouteChanged(); } + /// The use case for checking profile completion. final GetProfileCompletionUseCase _getProfileCompletionUsecase; + + /// Guard flag to prevent concurrent profile-completion fetches. bool _isLoadingCompletion = false; + /// Routes that should hide the bottom navigation bar. static const List _hideBottomPaths = [ StaffPaths.benefits, ]; + /// Listener invoked whenever the Modular route changes. void _onRouteChanged() { if (isClosed) return; @@ -46,18 +56,19 @@ class StaffMainCubit extends Cubit implements Disposable { final bool showBottomBar = !_hideBottomPaths.any(path.contains); - if (newIndex != state.currentIndex || showBottomBar != state.showBottomBar) { + if (newIndex != state.currentIndex || + showBottomBar != state.showBottomBar) { emit(state.copyWith(currentIndex: newIndex, showBottomBar: showBottomBar)); } } - /// Loads the profile completion status. + /// Loads the profile completion status from the V2 API. Future refreshProfileCompletion() async { if (_isLoadingCompletion || isClosed) return; _isLoadingCompletion = true; try { - final isComplete = await _getProfileCompletionUsecase(); + final bool isComplete = await _getProfileCompletionUsecase(); if (!isClosed) { emit(state.copyWith(isProfileComplete: isComplete)); } @@ -72,6 +83,7 @@ class StaffMainCubit extends Cubit implements Disposable { } } + /// Navigates to the tab at [index]. void navigateToTab(int index) { if (index == state.currentIndex) return; diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart index a479da35..32aa3711 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_domain/krow_domain.dart' show BaseApiService; import 'package:staff_attire/staff_attire.dart'; import 'package:staff_availability/staff_availability.dart'; import 'package:staff_bank_account/staff_bank_account.dart'; @@ -11,6 +11,9 @@ import 'package:staff_documents/staff_documents.dart'; import 'package:staff_emergency_contact/staff_emergency_contact.dart'; import 'package:staff_faqs/staff_faqs.dart'; import 'package:staff_home/staff_home.dart'; +import 'package:staff_main/src/data/repositories/staff_main_repository_impl.dart'; +import 'package:staff_main/src/domain/repositories/staff_main_repository_interface.dart'; +import 'package:staff_main/src/domain/usecases/get_profile_completion_usecase.dart'; import 'package:staff_main/src/presentation/blocs/staff_main_cubit.dart'; import 'package:staff_main/src/presentation/pages/staff_main_page.dart'; import 'package:staff_payments/staff_payements.dart'; @@ -22,22 +25,32 @@ import 'package:staff_shifts/staff_shifts.dart'; import 'package:staff_tax_forms/staff_tax_forms.dart'; import 'package:staff_time_card/staff_time_card.dart'; +/// The main module for the staff app shell. +/// +/// Registers navigation routes for all staff features and provides +/// profile-completion gating via [StaffMainCubit]. class StaffMainModule extends Module { + @override + List get imports => [CoreModule()]; + @override void binds(Injector i) { - // Register the StaffConnectorRepository from data_connect - i.addLazySingleton( - StaffConnectorRepositoryImpl.new, - ); - - // Register the use case from data_connect - i.addLazySingleton( - () => GetProfileCompletionUseCase( - repository: i.get(), + // Repository backed by V2 REST API + i.addLazySingleton( + () => StaffMainRepositoryImpl( + apiService: i.get(), ), ); - - i.add( + + // Use case for profile completion check + i.addLazySingleton( + () => GetProfileCompletionUseCase( + repository: i.get(), + ), + ); + + // Main shell cubit + i.add( () => StaffMainCubit( getProfileCompletionUsecase: i.get(), ), diff --git a/apps/mobile/packages/features/staff/staff_main/pubspec.yaml b/apps/mobile/packages/features/staff/staff_main/pubspec.yaml index 91c0b8a4..767076a0 100644 --- a/apps/mobile/packages/features/staff/staff_main/pubspec.yaml +++ b/apps/mobile/packages/features/staff/staff_main/pubspec.yaml @@ -22,8 +22,8 @@ dependencies: path: ../../../core_localization krow_core: path: ../../../core - krow_data_connect: - path: ../../../data_connect + krow_domain: + path: ../../../domain # Features staff_home: diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index e28d4536..8a97848c 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -257,14 +257,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" - csslib: - dependency: transitive - description: - name: csslib - sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" - url: "https://pub.dev" - source: hosted - version: "1.0.2" csv: dependency: transitive description: @@ -393,30 +385,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.3+5" - firebase_app_check: - dependency: transitive - description: - name: firebase_app_check - sha256: "45f0d279ea7ae4eac1867a4c85aa225761e3ac0ccf646386a860b2bc16581f76" - url: "https://pub.dev" - source: hosted - version: "0.4.1+4" - firebase_app_check_platform_interface: - dependency: transitive - description: - name: firebase_app_check_platform_interface - sha256: e32b4e6adeaac207a6f7afe0906d97c0811de42fb200d9b6317a09155de65e2b - url: "https://pub.dev" - source: hosted - version: "0.2.1+4" - firebase_app_check_web: - dependency: transitive - description: - name: firebase_app_check_web - sha256: "2cbc8a18a34813a7e31d7b30f989973087421cd5d0e397b4dd88a90289aa2bed" - url: "https://pub.dev" - source: hosted - version: "0.2.2+2" firebase_auth: dependency: transitive description: @@ -465,14 +433,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.0" - firebase_data_connect: - dependency: transitive - description: - name: firebase_data_connect - sha256: "01d0f8e33c520a6e6f59cf5ac6ff281d1927f7837f094fa8eb5fdb0b1b328ad8" - url: "https://pub.dev" - source: hosted - version: "0.2.2+2" fixnum: dependency: transitive description: @@ -661,14 +621,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.5" - get_it: - dependency: transitive - description: - name: get_it - sha256: d85128a5dae4ea777324730dc65edd9c9f43155c109d5cc0a69cab74139fbac1 - url: "https://pub.dev" - source: hosted - version: "7.7.0" glob: dependency: transitive description: @@ -685,62 +637,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" - google_identity_services_web: - dependency: transitive - description: - name: google_identity_services_web - sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" - url: "https://pub.dev" - source: hosted - version: "0.3.3+1" - google_maps: - dependency: transitive - description: - name: google_maps - sha256: "5d410c32112d7c6eb7858d359275b2aa04778eed3e36c745aeae905fb2fa6468" - url: "https://pub.dev" - source: hosted - version: "8.2.0" - google_maps_flutter: - dependency: transitive - description: - name: google_maps_flutter - sha256: "9b0d6dab3de6955837575dc371dd772fcb5d0a90f6a4954e8c066472f9938550" - url: "https://pub.dev" - source: hosted - version: "2.14.2" - google_maps_flutter_android: - dependency: transitive - description: - name: google_maps_flutter_android - sha256: "98d7f5354f770f3e993db09fc798d40aeb6a254f04c1c468a94818ec2086e83e" - url: "https://pub.dev" - source: hosted - version: "2.18.12" - google_maps_flutter_ios: - dependency: transitive - description: - name: google_maps_flutter_ios - sha256: "38f0a9ee858b0de3a5105e7efe200f154eea8397eb0c36bea6b3810429fbc0e4" - url: "https://pub.dev" - source: hosted - version: "2.17.3" - google_maps_flutter_platform_interface: - dependency: transitive - description: - name: google_maps_flutter_platform_interface - sha256: e8b1232419fcdd35c1fdafff96843f5a40238480365599d8ca661dde96d283dd - url: "https://pub.dev" - source: hosted - version: "2.14.1" - google_maps_flutter_web: - dependency: transitive - description: - name: google_maps_flutter_web - sha256: d416602944e1859f3cbbaa53e34785c223fa0a11eddb34a913c964c5cbb5d8cf - url: "https://pub.dev" - source: hosted - version: "0.5.14+3" google_places_flutter: dependency: transitive description: @@ -749,14 +645,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" - googleapis_auth: - dependency: transitive - description: - name: googleapis_auth - sha256: befd71383a955535060acde8792e7efc11d2fccd03dd1d3ec434e85b68775938 - url: "https://pub.dev" - source: hosted - version: "1.6.0" graphs: dependency: transitive description: @@ -765,14 +653,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" - grpc: - dependency: transitive - description: - name: grpc - sha256: e93ee3bce45c134bf44e9728119102358c7cd69de7832d9a874e2e74eb8cab40 - url: "https://pub.dev" - source: hosted - version: "3.2.4" gsettings: dependency: transitive description: @@ -789,14 +669,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.5" - html: - dependency: transitive - description: - name: html - sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" - url: "https://pub.dev" - source: hosted - version: "0.15.6" http: dependency: transitive description: @@ -805,14 +677,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" - http2: - dependency: transitive - description: - name: http2 - sha256: "382d3aefc5bd6dc68c6b892d7664f29b5beb3251611ae946a98d35158a82bbfa" - url: "https://pub.dev" - source: hosted - version: "2.3.1" http_multi_server: dependency: transitive description: @@ -1205,14 +1069,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - protobuf: - dependency: transitive - description: - name: protobuf - sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" - url: "https://pub.dev" - source: hosted - version: "3.1.0" provider: dependency: transitive description: @@ -1333,14 +1189,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" - sanitize_html: - dependency: transitive - description: - name: sanitize_html - sha256: "12669c4a913688a26555323fb9cec373d8f9fbe091f2d01c40c723b33caa8989" - url: "https://pub.dev" - source: hosted - version: "2.1.0" shared_preferences: dependency: transitive description: diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index 350e842f..c752becc 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -8,7 +8,6 @@ workspace: - packages/design_system - packages/core - packages/domain - - packages/data_connect - packages/core_localization - packages/features/staff/authentication - packages/features/staff/home