From 4834266986af8ee817e15d08c1e80b91cc7868f6 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Mar 2026 15:59:22 -0400 Subject: [PATCH 01/25] feat: add entities for staff personal info, reports, shifts, and user sessions - Implemented StaffPersonalInfo entity for staff profile data. - Created ReportSummary entity for summarizing report metrics. - Added SpendReport and SpendDataPoint entities for spend reporting. - Introduced AssignedShift, CancelledShift, CompletedShift, OpenShift, PendingAssignment, ShiftDetail, TodayShift entities for shift management. - Developed ClientSession and StaffSession entities for user session management. --- .claude/agents/mobile-builder.md | 82 ++++- apps/mobile/packages/core/lib/core.dart | 5 + .../core/lib/src/config/app_config.dart | 6 + .../src/services/api_service/api_service.dart | 24 +- .../core_api_services/v2_api_endpoints.dart | 340 ++++++++++++++++++ .../src/services/api_service/dio_client.dart | 8 +- .../inspectors/idempotency_interceptor.dart | 24 ++ .../api_service/mixins/api_error_handler.dart | 135 +++++++ .../mixins/session_handler_mixin.dart | 244 +++++++++++++ apps/mobile/packages/core/pubspec.yaml | 1 + .../packages/domain/lib/krow_domain.dart | 157 ++++---- .../availability/availability_adapter.dart | 33 -- .../adapters/clock_in/clock_in_adapter.dart | 27 -- .../bank_account/bank_account_adapter.dart | 21 -- .../adapters/financial/payment_adapter.dart | 19 - .../adapters/financial/time_card_adapter.dart | 49 --- .../profile/bank_account_adapter.dart | 53 --- .../profile/emergency_contact_adapter.dart | 19 - .../adapters/profile/experience_adapter.dart | 18 - .../adapters/profile/tax_form_adapter.dart | 104 ------ .../adapters/shifts/break/break_adapter.dart | 39 -- .../src/adapters/shifts/shift_adapter.dart | 59 --- .../api_services/base_api_service.dart | 7 + .../availability/availability_day.dart | 86 +++++ .../availability/availability_slot.dart | 33 -- .../availability/day_availability.dart | 31 -- .../src/entities/availability/time_slot.dart | 39 ++ .../lib/src/entities/benefits/benefit.dart | 71 +++- .../src/entities/business/biz_contract.dart | 36 -- .../lib/src/entities/business/business.dart | 124 +++++-- .../entities/business/business_setting.dart | 41 --- .../src/entities/business/cost_center.dart | 33 +- .../domain/lib/src/entities/business/hub.dart | 122 +++++-- .../src/entities/business/hub_department.dart | 24 -- .../src/entities/business/hub_manager.dart | 54 +++ .../src/entities/business/team_member.dart | 61 ++++ .../lib/src/entities/business/vendor.dart | 83 ++++- .../src/entities/business/vendor_role.dart | 49 +++ .../entities/clock_in/attendance_status.dart | 57 ++- .../coverage_domain/assigned_worker.dart | 65 ++++ .../coverage_domain/core_team_member.dart | 68 ++++ .../coverage_domain/coverage_shift.dart | 57 --- .../coverage_domain/coverage_stats.dart | 78 ++-- .../coverage_domain/coverage_worker.dart | 55 --- .../coverage_domain/shift_with_workers.dart | 80 +++++ .../entities/coverage_domain/time_range.dart | 37 ++ .../lib/src/entities/enums/account_type.dart | 30 ++ .../entities/enums/application_status.dart | 48 +++ .../src/entities/enums/assignment_status.dart | 48 +++ .../enums/attendance_status_type.dart | 36 ++ .../entities/enums/availability_status.dart | 34 ++ .../src/entities/enums/benefit_status.dart | 34 ++ .../src/entities/enums/business_status.dart | 34 ++ .../src/entities/enums/invoice_status.dart | 48 +++ .../src/entities/enums/onboarding_status.dart | 33 ++ .../lib/src/entities/enums/order_type.dart | 36 ++ .../src/entities/enums/payment_status.dart | 36 ++ .../lib/src/entities/enums/shift_status.dart | 45 +++ .../lib/src/entities/enums/staff_status.dart | 36 ++ .../lib/src/entities/events/assignment.dart | 58 --- .../domain/lib/src/entities/events/event.dart | 70 ---- .../lib/src/entities/events/event_shift.dart | 28 -- .../entities/events/event_shift_position.dart | 53 --- .../lib/src/entities/events/work_session.dart | 32 -- .../src/entities/financial/bank_account.dart | 70 ++++ .../financial/bank_account/bank_account.dart | 27 -- .../bank_account/business_bank_account.dart | 26 -- .../bank_account/staff_bank_account.dart | 48 --- .../entities/financial/billing_account.dart | 77 ++++ .../entities/financial/billing_period.dart | 8 - .../src/entities/financial/current_bill.dart | 29 ++ .../lib/src/entities/financial/invoice.dart | 190 ++++------ .../entities/financial/invoice_decline.dart | 26 -- .../src/entities/financial/invoice_item.dart | 36 -- .../financial/payment_chart_point.dart | 37 ++ .../entities/financial/payment_summary.dart | 39 +- .../lib/src/entities/financial/savings.dart | 29 ++ .../src/entities/financial/spend_item.dart | 43 +++ .../src/entities/financial/staff_payment.dart | 130 ++++--- .../lib/src/entities/financial/time_card.dart | 136 +++---- .../src/entities/home/client_dashboard.dart | 75 ++++ .../src/entities/home/coverage_metrics.dart | 42 +++ .../entities/home/home_dashboard_data.dart | 45 --- .../entities/home/live_activity_metrics.dart | 43 +++ .../lib/src/entities/home/reorder_item.dart | 51 --- .../src/entities/home/spending_summary.dart | 37 ++ .../src/entities/home/staff_dashboard.dart | 80 +++++ .../orders/assigned_worker_summary.dart | 58 +++ .../src/entities/orders/one_time_order.dart | 98 ----- .../orders/one_time_order_position.dart | 62 ---- .../lib/src/entities/orders/order_item.dart | 192 +++++----- .../src/entities/orders/order_preview.dart | 235 ++++++++++++ .../lib/src/entities/orders/order_type.dart | 30 -- .../src/entities/orders/permanent_order.dart | 41 --- .../orders/permanent_order_position.dart | 60 ---- .../lib/src/entities/orders/recent_order.dart | 65 ++++ .../src/entities/orders/recurring_order.dart | 106 ------ .../orders/recurring_order_position.dart | 60 ---- .../lib/src/entities/orders/reorder_data.dart | 76 ---- .../entities/profile/attire_checklist.dart | 138 +++++++ .../lib/src/entities/profile/attire_item.dart | 86 ----- .../profile/attire_verification_status.dart | 39 -- .../lib/src/entities/profile/certificate.dart | 147 ++++++++ .../src/entities/profile/compliance_type.dart | 25 -- .../profile/document_verification_status.dart | 39 -- .../entities/profile/emergency_contact.dart | 102 +++--- .../entities/profile/experience_skill.dart | 30 -- .../lib/src/entities/profile/industry.dart | 21 -- .../entities/profile/privacy_settings.dart | 32 ++ .../entities/profile/profile_completion.dart | 81 +++++ .../entities/profile/profile_document.dart | 183 ++++++++++ .../profile/profile_section_status.dart | 76 ++++ .../entities/profile/relationship_type.dart | 6 - .../lib/src/entities/profile/schedule.dart | 32 -- .../entities/profile/staff_certificate.dart | 127 ------- .../profile/staff_certificate_status.dart | 23 -- .../staff_certificate_validation_status.dart | 22 -- .../src/entities/profile/staff_document.dart | 66 ---- .../entities/profile/staff_personal_info.dart | 105 ++++++ .../lib/src/entities/profile/tax_form.dart | 167 +++++---- .../ratings/business_staff_preference.dart | 35 -- .../lib/src/entities/ratings/penalty_log.dart | 36 -- .../src/entities/ratings/staff_rating.dart | 97 ++++- .../src/entities/reports/coverage_report.dart | 112 ++++-- .../entities/reports/daily_ops_report.dart | 115 +++--- .../src/entities/reports/forecast_report.dart | 163 ++++++--- .../src/entities/reports/no_show_report.dart | 181 ++++++++-- .../entities/reports/performance_report.dart | 99 +++-- .../src/entities/reports/report_summary.dart | 71 ++++ .../src/entities/reports/reports_summary.dart | 31 -- .../entities/reports/spend_data_point.dart | 95 +++++ .../src/entities/reports/spend_report.dart | 85 ----- .../src/entities/shifts/assigned_shift.dart | 100 ++++++ .../lib/src/entities/shifts/break/break.dart | 47 --- .../src/entities/shifts/cancelled_shift.dart | 69 ++++ .../src/entities/shifts/completed_shift.dart | 78 ++++ .../lib/src/entities/shifts/open_shift.dart | 106 ++++++ .../entities/shifts/pending_assignment.dart | 83 +++++ .../domain/lib/src/entities/shifts/shift.dart | 252 +++++++------ .../lib/src/entities/shifts/shift_detail.dart | 149 ++++++++ .../lib/src/entities/shifts/today_shift.dart | 88 +++++ .../lib/src/entities/skills/certificate.dart | 24 -- .../domain/lib/src/entities/skills/skill.dart | 29 -- .../src/entities/skills/skill_category.dart | 18 - .../lib/src/entities/skills/skill_kit.dart | 32 -- .../lib/src/entities/skills/staff_skill.dart | 58 --- .../lib/src/entities/support/addon.dart | 26 -- .../lib/src/entities/support/media.dart | 24 -- .../domain/lib/src/entities/support/tag.dart | 18 - .../src/entities/support/working_area.dart | 30 -- .../lib/src/entities/users/biz_member.dart | 174 ++++++++- .../src/entities/users/client_session.dart | 128 +++++++ .../lib/src/entities/users/hub_member.dart | 28 -- .../lib/src/entities/users/membership.dart | 32 -- .../domain/lib/src/entities/users/staff.dart | 235 ++++++++---- .../lib/src/entities/users/staff_session.dart | 177 +++++++++ .../domain/lib/src/entities/users/user.dart | 108 +++++- .../presentation/blocs/view_orders_cubit.dart | 8 +- .../presentation/widgets/view_order_card.dart | 2 +- 159 files changed, 6857 insertions(+), 3937 deletions(-) create mode 100644 apps/mobile/packages/core/lib/src/services/api_service/core_api_services/v2_api_endpoints.dart create mode 100644 apps/mobile/packages/core/lib/src/services/api_service/inspectors/idempotency_interceptor.dart create mode 100644 apps/mobile/packages/core/lib/src/services/api_service/mixins/api_error_handler.dart create mode 100644 apps/mobile/packages/core/lib/src/services/api_service/mixins/session_handler_mixin.dart delete mode 100644 apps/mobile/packages/domain/lib/src/adapters/availability/availability_adapter.dart delete mode 100644 apps/mobile/packages/domain/lib/src/adapters/clock_in/clock_in_adapter.dart delete mode 100644 apps/mobile/packages/domain/lib/src/adapters/financial/bank_account/bank_account_adapter.dart delete mode 100644 apps/mobile/packages/domain/lib/src/adapters/financial/payment_adapter.dart delete mode 100644 apps/mobile/packages/domain/lib/src/adapters/financial/time_card_adapter.dart delete mode 100644 apps/mobile/packages/domain/lib/src/adapters/profile/bank_account_adapter.dart delete mode 100644 apps/mobile/packages/domain/lib/src/adapters/profile/emergency_contact_adapter.dart delete mode 100644 apps/mobile/packages/domain/lib/src/adapters/profile/experience_adapter.dart delete mode 100644 apps/mobile/packages/domain/lib/src/adapters/profile/tax_form_adapter.dart delete mode 100644 apps/mobile/packages/domain/lib/src/adapters/shifts/break/break_adapter.dart delete mode 100644 apps/mobile/packages/domain/lib/src/adapters/shifts/shift_adapter.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/availability/availability_day.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/availability/availability_slot.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/availability/day_availability.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/availability/time_slot.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/business/biz_contract.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/business/business_setting.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/business/hub_department.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/business/hub_manager.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/business/team_member.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/business/vendor_role.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/coverage_domain/core_team_member.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/coverage_domain/coverage_shift.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/coverage_domain/coverage_worker.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/coverage_domain/shift_with_workers.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/coverage_domain/time_range.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/enums/account_type.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/enums/application_status.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/enums/assignment_status.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/enums/attendance_status_type.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/enums/availability_status.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/enums/benefit_status.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/enums/business_status.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/enums/invoice_status.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/enums/onboarding_status.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/enums/order_type.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/enums/payment_status.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/enums/shift_status.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/enums/staff_status.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/events/assignment.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/events/event.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/events/event_shift.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/events/event_shift_position.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/events/work_session.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/financial/bank_account.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/financial/bank_account/bank_account.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/financial/bank_account/business_bank_account.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/financial/bank_account/staff_bank_account.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/financial/billing_account.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/financial/billing_period.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/financial/current_bill.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/financial/invoice_decline.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/financial/invoice_item.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/financial/payment_chart_point.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/financial/savings.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/financial/spend_item.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/home/client_dashboard.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/home/coverage_metrics.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/home/home_dashboard_data.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/home/live_activity_metrics.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/home/reorder_item.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/home/spending_summary.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/home/staff_dashboard.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/orders/assigned_worker_summary.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/orders/one_time_order.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/orders/one_time_order_position.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/orders/order_preview.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/orders/order_type.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/orders/permanent_order_position.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/orders/recent_order.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/orders/recurring_order_position.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/orders/reorder_data.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/attire_checklist.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/attire_verification_status.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/certificate.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/compliance_type.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/document_verification_status.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/experience_skill.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/industry.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/privacy_settings.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/profile_completion.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/profile_document.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/profile_section_status.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/relationship_type.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/schedule.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate_status.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate_validation_status.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/staff_document.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/staff_personal_info.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/ratings/business_staff_preference.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/ratings/penalty_log.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/reports/report_summary.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/reports/reports_summary.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/reports/spend_data_point.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/reports/spend_report.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/shifts/assigned_shift.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/shifts/break/break.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/shifts/cancelled_shift.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/shifts/open_shift.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/shifts/pending_assignment.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/shifts/today_shift.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/skills/certificate.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/skills/skill.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/skills/skill_category.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/skills/skill_kit.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/skills/staff_skill.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/support/addon.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/support/media.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/support/tag.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/support/working_area.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/users/client_session.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/users/hub_member.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/users/membership.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/users/staff_session.dart diff --git a/.claude/agents/mobile-builder.md b/.claude/agents/mobile-builder.md index 3d9009e0..55504099 100644 --- a/.claude/agents/mobile-builder.md +++ b/.claude/agents/mobile-builder.md @@ -67,6 +67,84 @@ If any of these files are missing or unreadable, notify the user before proceedi } ``` +## V2 API Migration Rules (Active Migration) + +The mobile apps are migrating from Firebase Data Connect (direct DB) to V2 REST API. Follow these rules for ALL new and migrated features: + +### Backend Access +- **Use `ApiService.get/post/put/delete`** for ALL backend calls — NEVER use Data Connect connectors +- Import `ApiService` from `package:krow_core/core.dart` +- Use `V2ApiEndpoints` from `package:krow_core/core.dart` for endpoint URLs +- V2 API docs are at `docs/BACKEND/API_GUIDES/V2/` — check response shapes before writing code + +### Domain Entities +- Domain entities live in `packages/domain/lib/src/entities/` with `fromJson`/`toJson` directly on the class +- No separate DTO or adapter layer — entities are self-serializing +- Entities are shared across all features via `package:krow_domain/krow_domain.dart` +- When migrating: check if the entity already exists and update its `fromJson` to match V2 API response shape + +### Feature Structure +- **RepoImpl lives in the feature package** at `data/repositories/` +- **Feature-level domain layer is optional** — only add `domain/` when the feature has use cases, validators, or feature-specific interfaces +- **Simple features** (read-only, no business logic) = just `data/` + `presentation/` +- Do NOT import from `packages/data_connect/` — it is deprecated + +### Status & Type Enums +All status/type fields from the V2 API must use Dart enums, NOT raw strings. Parse at the `fromJson` boundary with a safe fallback: +```dart +enum ShiftStatus { + open, assigned, active, completed, cancelled; + + static ShiftStatus fromJson(String value) { + switch (value) { + case 'OPEN': return ShiftStatus.open; + case 'ASSIGNED': return ShiftStatus.assigned; + case 'ACTIVE': return ShiftStatus.active; + case 'COMPLETED': return ShiftStatus.completed; + case 'CANCELLED': return ShiftStatus.cancelled; + default: return ShiftStatus.open; + } + } + + String toJson() { + switch (this) { + case ShiftStatus.open: return 'OPEN'; + case ShiftStatus.assigned: return 'ASSIGNED'; + case ShiftStatus.active: return 'ACTIVE'; + case ShiftStatus.completed: return 'COMPLETED'; + case ShiftStatus.cancelled: return 'CANCELLED'; + } + } +} +``` +Place shared enums (used by multiple entities) in `packages/domain/lib/src/entities/enums/`. Feature-specific enums can live in the entity file. + +### RepoImpl Pattern +```dart +class FeatureRepositoryImpl implements FeatureRepositoryInterface { + FeatureRepositoryImpl({required ApiService apiService}) + : _apiService = apiService; + + final ApiService _apiService; + + Future> getShifts() async { + final ApiResponse response = await _apiService.get(V2ApiEndpoints.staffShiftsAssigned); + final List items = response.data['shifts'] as List; + return items.map((dynamic json) => Shift.fromJson(json as Map)).toList(); + } +} +``` + +### DI Registration +```dart +// Inject ApiService (available from CoreModule) +i.add(() => FeatureRepositoryImpl( + apiService: i.get(), +)); +``` + +--- + ## Standard Workflow Follow these steps in order for every feature implementation: @@ -91,8 +169,8 @@ Follow these steps in order for every feature implementation: - Create barrel file exporting the domain public API ### 4. Data Layer -- Create models with `fromJson`/`toJson` methods -- Implement repository classes using `DataConnectService` +- Implement repository classes using `ApiService` with `V2ApiEndpoints` — NOT DataConnectService +- Parse V2 API JSON responses into domain entities via `Entity.fromJson()` - Map errors to domain `Failure` types - Create barrel file for data layer diff --git a/apps/mobile/packages/core/lib/core.dart b/apps/mobile/packages/core/lib/core.dart index 600ff74f..33e4e5ac 100644 --- a/apps/mobile/packages/core/lib/core.dart +++ b/apps/mobile/packages/core/lib/core.dart @@ -16,8 +16,13 @@ export 'src/routing/routing.dart'; export 'src/services/api_service/api_service.dart'; export 'src/services/api_service/dio_client.dart'; +// API Mixins +export 'src/services/api_service/mixins/api_error_handler.dart'; +export 'src/services/api_service/mixins/session_handler_mixin.dart'; + // Core API Services export 'src/services/api_service/core_api_services/core_api_endpoints.dart'; +export 'src/services/api_service/core_api_services/v2_api_endpoints.dart'; export 'src/services/api_service/core_api_services/file_upload/file_upload_service.dart'; export 'src/services/api_service/core_api_services/file_upload/file_upload_response.dart'; export 'src/services/api_service/core_api_services/signed_url/signed_url_service.dart'; diff --git a/apps/mobile/packages/core/lib/src/config/app_config.dart b/apps/mobile/packages/core/lib/src/config/app_config.dart index 6752f3c6..1dab4a2b 100644 --- a/apps/mobile/packages/core/lib/src/config/app_config.dart +++ b/apps/mobile/packages/core/lib/src/config/app_config.dart @@ -13,4 +13,10 @@ class AppConfig { static const String coreApiBaseUrl = String.fromEnvironment( 'CORE_API_BASE_URL', ); + + /// The base URL for the V2 Unified API gateway. + static const String v2ApiBaseUrl = String.fromEnvironment( + 'V2_API_BASE_URL', + defaultValue: 'https://krow-api-v2-933560802882.us-central1.run.app', + ); } diff --git a/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart index db1119c9..00c58020 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart @@ -86,6 +86,25 @@ class ApiService implements BaseApiService { } } + /// Performs a DELETE request to the specified [endpoint]. + @override + Future delete( + String endpoint, { + dynamic data, + Map? params, + }) async { + try { + final Response response = await _dio.delete( + endpoint, + data: data, + queryParameters: params, + ); + return _handleResponse(response); + } on DioException catch (e) { + return _handleError(e); + } + } + /// Extracts [ApiResponse] from a successful [Response]. ApiResponse _handleResponse(Response response) { return ApiResponse( @@ -96,6 +115,9 @@ class ApiService implements BaseApiService { } /// Extracts [ApiResponse] from a [DioException]. + /// + /// Supports both legacy error format and V2 API error envelope + /// (`{ code, message, details, requestId }`). ApiResponse _handleError(DioException e) { if (e.response?.data is Map) { final Map body = @@ -106,7 +128,7 @@ class ApiService implements BaseApiService { e.response?.statusCode?.toString() ?? 'error', message: body['message']?.toString() ?? e.message ?? 'Error occurred', - data: body['data'], + data: body['data'] ?? body['details'], errors: _parseErrors(body['errors']), ); } diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/v2_api_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/v2_api_endpoints.dart new file mode 100644 index 00000000..a902410f --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/v2_api_endpoints.dart @@ -0,0 +1,340 @@ +import '../../../config/app_config.dart'; + +/// Constants for V2 Unified API endpoints. +/// +/// All mobile read/write operations go through the V2 gateway which proxies +/// to the internal Query API and Command API services. +class V2ApiEndpoints { + V2ApiEndpoints._(); + + /// The base URL for the V2 Unified API gateway. + static const String baseUrl = AppConfig.v2ApiBaseUrl; + + // ── Auth ────────────────────────────────────────────────────────────── + + /// Client email/password sign-in. + static const String clientSignIn = '$baseUrl/auth/client/sign-in'; + + /// Client business registration. + static const String clientSignUp = '$baseUrl/auth/client/sign-up'; + + /// Client sign-out. + static const String clientSignOut = '$baseUrl/auth/client/sign-out'; + + /// Start staff phone verification (SMS). + static const String staffPhoneStart = '$baseUrl/auth/staff/phone/start'; + + /// Complete staff phone verification. + static const String staffPhoneVerify = '$baseUrl/auth/staff/phone/verify'; + + /// Generic sign-out. + static const String signOut = '$baseUrl/auth/sign-out'; + + /// Get current session data. + static const String session = '$baseUrl/auth/session'; + + // ── Staff Read ──────────────────────────────────────────────────────── + + /// Staff session data. + static const String staffSession = '$baseUrl/staff/session'; + + /// Staff dashboard overview. + static const String staffDashboard = '$baseUrl/staff/dashboard'; + + /// Staff profile completion status. + static const String staffProfileCompletion = + '$baseUrl/staff/profile-completion'; + + /// Staff availability schedule. + static const String staffAvailability = '$baseUrl/staff/availability'; + + /// Today's shifts for clock-in. + static const String staffClockInShiftsToday = + '$baseUrl/staff/clock-in/shifts/today'; + + /// Current clock-in status. + static const String staffClockInStatus = '$baseUrl/staff/clock-in/status'; + + /// Payments summary. + static const String staffPaymentsSummary = '$baseUrl/staff/payments/summary'; + + /// Payments history. + static const String staffPaymentsHistory = '$baseUrl/staff/payments/history'; + + /// Payments chart data. + static const String staffPaymentsChart = '$baseUrl/staff/payments/chart'; + + /// Assigned shifts. + static const String staffShiftsAssigned = '$baseUrl/staff/shifts/assigned'; + + /// Open shifts available to apply. + static const String staffShiftsOpen = '$baseUrl/staff/shifts/open'; + + /// Pending shift assignments. + static const String staffShiftsPending = '$baseUrl/staff/shifts/pending'; + + /// Cancelled shifts. + static const String staffShiftsCancelled = '$baseUrl/staff/shifts/cancelled'; + + /// Completed shifts. + static const String staffShiftsCompleted = '$baseUrl/staff/shifts/completed'; + + /// Shift details by ID. + static String staffShiftDetails(String shiftId) => + '$baseUrl/staff/shifts/$shiftId'; + + /// Staff profile sections overview. + static const String staffProfileSections = '$baseUrl/staff/profile/sections'; + + /// Personal info. + static const String staffPersonalInfo = '$baseUrl/staff/profile/personal-info'; + + /// Industries/experience. + static const String staffIndustries = '$baseUrl/staff/profile/industries'; + + /// Skills. + static const String staffSkills = '$baseUrl/staff/profile/skills'; + + /// Documents. + static const String staffDocuments = '$baseUrl/staff/profile/documents'; + + /// Attire items. + static const String staffAttire = '$baseUrl/staff/profile/attire'; + + /// Tax forms. + static const String staffTaxForms = '$baseUrl/staff/profile/tax-forms'; + + /// Emergency contacts. + static const String staffEmergencyContacts = + '$baseUrl/staff/profile/emergency-contacts'; + + /// Certificates. + static const String staffCertificates = '$baseUrl/staff/profile/certificates'; + + /// Bank accounts. + static const String staffBankAccounts = '$baseUrl/staff/profile/bank-accounts'; + + /// Benefits. + static const String staffBenefits = '$baseUrl/staff/profile/benefits'; + + /// Time card. + static const String staffTimeCard = '$baseUrl/staff/profile/time-card'; + + /// Privacy settings. + static const String staffPrivacy = '$baseUrl/staff/profile/privacy'; + + /// FAQs. + static const String staffFaqs = '$baseUrl/staff/faqs'; + + /// FAQs search. + static const String staffFaqsSearch = '$baseUrl/staff/faqs/search'; + + // ── Staff Write ─────────────────────────────────────────────────────── + + /// Staff profile setup. + static const String staffProfileSetup = '$baseUrl/staff/profile/setup'; + + /// Clock in. + static const String staffClockIn = '$baseUrl/staff/clock-in'; + + /// Clock out. + static const String staffClockOut = '$baseUrl/staff/clock-out'; + + /// Quick-set availability. + static const String staffAvailabilityQuickSet = + '$baseUrl/staff/availability/quick-set'; + + /// Apply for a shift. + static String staffShiftApply(String shiftId) => + '$baseUrl/staff/shifts/$shiftId/apply'; + + /// Accept a shift. + static String staffShiftAccept(String shiftId) => + '$baseUrl/staff/shifts/$shiftId/accept'; + + /// Decline a shift. + static String staffShiftDecline(String shiftId) => + '$baseUrl/staff/shifts/$shiftId/decline'; + + /// Request a shift swap. + static String staffShiftRequestSwap(String shiftId) => + '$baseUrl/staff/shifts/$shiftId/request-swap'; + + /// Update emergency contact by ID. + static String staffEmergencyContactUpdate(String contactId) => + '$baseUrl/staff/profile/emergency-contacts/$contactId'; + + /// Update tax form by type. + static String staffTaxFormUpdate(String formType) => + '$baseUrl/staff/profile/tax-forms/$formType'; + + /// Submit tax form by type. + static String staffTaxFormSubmit(String formType) => + '$baseUrl/staff/profile/tax-forms/$formType/submit'; + + /// Upload staff profile photo. + static const String staffProfilePhoto = '$baseUrl/staff/profile/photo'; + + /// Upload document by ID. + static String staffDocumentUpload(String documentId) => + '$baseUrl/staff/profile/documents/$documentId/upload'; + + /// Upload attire by ID. + static String staffAttireUpload(String documentId) => + '$baseUrl/staff/profile/attire/$documentId/upload'; + + /// Delete certificate by ID. + static String staffCertificateDelete(String certificateId) => + '$baseUrl/staff/profile/certificates/$certificateId'; + + // ── Client Read ─────────────────────────────────────────────────────── + + /// Client session data. + static const String clientSession = '$baseUrl/client/session'; + + /// Client dashboard. + static const String clientDashboard = '$baseUrl/client/dashboard'; + + /// Client reorders. + static const String clientReorders = '$baseUrl/client/reorders'; + + /// Billing accounts. + static const String clientBillingAccounts = '$baseUrl/client/billing/accounts'; + + /// Pending invoices. + static const String clientBillingInvoicesPending = + '$baseUrl/client/billing/invoices/pending'; + + /// Invoice history. + static const String clientBillingInvoicesHistory = + '$baseUrl/client/billing/invoices/history'; + + /// Current bill. + static const String clientBillingCurrentBill = + '$baseUrl/client/billing/current-bill'; + + /// Savings data. + static const String clientBillingSavings = '$baseUrl/client/billing/savings'; + + /// Spend breakdown. + static const String clientBillingSpendBreakdown = + '$baseUrl/client/billing/spend-breakdown'; + + /// Coverage overview. + static const String clientCoverage = '$baseUrl/client/coverage'; + + /// Coverage stats. + static const String clientCoverageStats = '$baseUrl/client/coverage/stats'; + + /// Core team. + static const String clientCoverageCoreTeam = + '$baseUrl/client/coverage/core-team'; + + /// Hubs list. + static const String clientHubs = '$baseUrl/client/hubs'; + + /// Cost centers. + static const String clientCostCenters = '$baseUrl/client/cost-centers'; + + /// Vendors. + static const String clientVendors = '$baseUrl/client/vendors'; + + /// Vendor roles by ID. + static String clientVendorRoles(String vendorId) => + '$baseUrl/client/vendors/$vendorId/roles'; + + /// Hub managers by ID. + static String clientHubManagers(String hubId) => + '$baseUrl/client/hubs/$hubId/managers'; + + /// Team members. + static const String clientTeamMembers = '$baseUrl/client/team-members'; + + /// View orders. + static const String clientOrdersView = '$baseUrl/client/orders/view'; + + /// Order reorder preview. + static String clientOrderReorderPreview(String orderId) => + '$baseUrl/client/orders/$orderId/reorder-preview'; + + /// Reports summary. + static const String clientReportsSummary = '$baseUrl/client/reports/summary'; + + /// Daily ops report. + static const String clientReportsDailyOps = + '$baseUrl/client/reports/daily-ops'; + + /// Spend report. + static const String clientReportsSpend = '$baseUrl/client/reports/spend'; + + /// Coverage report. + static const String clientReportsCoverage = + '$baseUrl/client/reports/coverage'; + + /// Forecast report. + static const String clientReportsForecast = + '$baseUrl/client/reports/forecast'; + + /// Performance report. + static const String clientReportsPerformance = + '$baseUrl/client/reports/performance'; + + /// No-show report. + static const String clientReportsNoShow = '$baseUrl/client/reports/no-show'; + + // ── Client Write ────────────────────────────────────────────────────── + + /// Create one-time order. + static const String clientOrdersOneTime = '$baseUrl/client/orders/one-time'; + + /// Create recurring order. + static const String clientOrdersRecurring = + '$baseUrl/client/orders/recurring'; + + /// Create permanent order. + static const String clientOrdersPermanent = + '$baseUrl/client/orders/permanent'; + + /// Edit order by ID. + static String clientOrderEdit(String orderId) => + '$baseUrl/client/orders/$orderId/edit'; + + /// Cancel order by ID. + static String clientOrderCancel(String orderId) => + '$baseUrl/client/orders/$orderId/cancel'; + + /// Create hub. + static const String clientHubCreate = '$baseUrl/client/hubs'; + + /// Update hub by ID. + static String clientHubUpdate(String hubId) => + '$baseUrl/client/hubs/$hubId'; + + /// Delete hub by ID. + static String clientHubDelete(String hubId) => + '$baseUrl/client/hubs/$hubId'; + + /// Assign NFC to hub. + static String clientHubAssignNfc(String hubId) => + '$baseUrl/client/hubs/$hubId/assign-nfc'; + + /// Assign managers to hub. + static String clientHubAssignManagers(String hubId) => + '$baseUrl/client/hubs/$hubId/managers'; + + /// Approve invoice. + static String clientInvoiceApprove(String invoiceId) => + '$baseUrl/client/billing/invoices/$invoiceId/approve'; + + /// Dispute invoice. + static String clientInvoiceDispute(String invoiceId) => + '$baseUrl/client/billing/invoices/$invoiceId/dispute'; + + /// Submit coverage review. + static const String clientCoverageReviews = + '$baseUrl/client/coverage/reviews'; + + /// Cancel late worker assignment. + static String clientCoverageCancelLateWorker(String assignmentId) => + '$baseUrl/client/coverage/late-workers/$assignmentId/cancel'; +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart b/apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart index 687524a7..f869e260 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart @@ -1,8 +1,9 @@ import 'package:dio/dio.dart'; import 'package:krow_core/src/services/api_service/inspectors/auth_interceptor.dart'; +import 'package:krow_core/src/services/api_service/inspectors/idempotency_interceptor.dart'; -/// A custom Dio client for the KROW project that includes basic configuration -/// and an [AuthInterceptor]. +/// A custom Dio client for the KROW project that includes basic configuration, +/// [AuthInterceptor], and [IdempotencyInterceptor]. class DioClient extends DioMixin implements Dio { DioClient([BaseOptions? baseOptions]) { options = @@ -18,10 +19,11 @@ class DioClient extends DioMixin implements Dio { // Add interceptors interceptors.addAll([ AuthInterceptor(), + IdempotencyInterceptor(), LogInterceptor( requestBody: true, responseBody: true, - ), // Added for better debugging + ), ]); } } diff --git a/apps/mobile/packages/core/lib/src/services/api_service/inspectors/idempotency_interceptor.dart b/apps/mobile/packages/core/lib/src/services/api_service/inspectors/idempotency_interceptor.dart new file mode 100644 index 00000000..828dd11b --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/inspectors/idempotency_interceptor.dart @@ -0,0 +1,24 @@ +import 'package:dio/dio.dart'; +import 'package:uuid/uuid.dart'; + +/// A Dio interceptor that adds an `Idempotency-Key` header to write requests. +/// +/// The V2 API requires an idempotency key for all POST, PUT, and DELETE +/// requests to prevent duplicate operations. A unique UUID v4 is generated +/// per request automatically. +class IdempotencyInterceptor extends Interceptor { + /// The UUID generator instance. + static const Uuid _uuid = Uuid(); + + @override + void onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) { + final String method = options.method.toUpperCase(); + if (method == 'POST' || method == 'PUT' || method == 'DELETE') { + options.headers['Idempotency-Key'] = _uuid.v4(); + } + handler.next(options); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/mixins/api_error_handler.dart b/apps/mobile/packages/core/lib/src/services/api_service/mixins/api_error_handler.dart new file mode 100644 index 00000000..da98e982 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/mixins/api_error_handler.dart @@ -0,0 +1,135 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Mixin to handle API layer errors and map them to domain exceptions. +/// +/// Use this in repository implementations to wrap [ApiService] calls. +/// It catches [DioException], [SocketException], etc., and throws +/// the appropriate [AppException] subclass. +mixin ApiErrorHandler { + /// Executes a Future and maps low-level exceptions to [AppException]. + /// + /// [timeout] defaults to 30 seconds. + Future executeProtected( + Future Function() action, { + Duration timeout = const Duration(seconds: 30), + }) async { + try { + return await action().timeout(timeout); + } on TimeoutException { + debugPrint( + 'ApiErrorHandler: Request timed out after ${timeout.inSeconds}s', + ); + throw ServiceUnavailableException( + technicalMessage: 'Request timed out after ${timeout.inSeconds}s', + ); + } on DioException catch (e) { + throw _mapDioException(e); + } on SocketException catch (e) { + throw NetworkException( + technicalMessage: 'SocketException: ${e.message}', + ); + } catch (e) { + // If it's already an AppException, rethrow it. + if (e is AppException) rethrow; + + final String errorStr = e.toString().toLowerCase(); + if (_isNetworkRelated(errorStr)) { + debugPrint('ApiErrorHandler: Network-related error: $e'); + throw NetworkException(technicalMessage: e.toString()); + } + + debugPrint('ApiErrorHandler: Unhandled exception caught: $e'); + throw UnknownException(technicalMessage: e.toString()); + } + } + + /// Maps a [DioException] to the appropriate [AppException]. + AppException _mapDioException(DioException e) { + switch (e.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + debugPrint('ApiErrorHandler: Dio timeout: ${e.type}'); + return ServiceUnavailableException( + technicalMessage: 'Dio ${e.type}: ${e.message}', + ); + + case DioExceptionType.connectionError: + debugPrint('ApiErrorHandler: Connection error: ${e.message}'); + return NetworkException( + technicalMessage: 'Connection error: ${e.message}', + ); + + case DioExceptionType.badResponse: + final int? statusCode = e.response?.statusCode; + final String body = e.response?.data?.toString() ?? ''; + debugPrint( + 'ApiErrorHandler: Bad response $statusCode: $body', + ); + + if (statusCode == 401 || statusCode == 403) { + return NotAuthenticatedException( + technicalMessage: 'HTTP $statusCode: $body', + ); + } + if (statusCode == 404) { + return ServerException( + technicalMessage: 'HTTP 404: Not found — $body', + ); + } + if (statusCode == 429) { + return ServiceUnavailableException( + technicalMessage: 'Rate limited (429): $body', + ); + } + if (statusCode != null && statusCode >= 500) { + return ServiceUnavailableException( + technicalMessage: 'HTTP $statusCode: $body', + ); + } + return ServerException( + technicalMessage: 'HTTP $statusCode: $body', + ); + + case DioExceptionType.cancel: + return UnknownException( + technicalMessage: 'Request cancelled', + ); + + case DioExceptionType.badCertificate: + return NetworkException( + technicalMessage: 'Bad certificate: ${e.message}', + ); + + case DioExceptionType.unknown: + if (e.error is SocketException) { + return NetworkException( + technicalMessage: 'Socket error: ${e.error}', + ); + } + return UnknownException( + technicalMessage: 'Unknown Dio error: ${e.message}', + ); + } + } + + /// Checks if an error string is network-related. + bool _isNetworkRelated(String errorStr) { + return errorStr.contains('socketexception') || + errorStr.contains('network') || + errorStr.contains('offline') || + errorStr.contains('connection failed') || + errorStr.contains('unavailable') || + errorStr.contains('handshake') || + errorStr.contains('clientexception') || + errorStr.contains('failed host lookup') || + errorStr.contains('connection error') || + errorStr.contains('terminated') || + errorStr.contains('connectexception'); + } +} 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 new file mode 100644 index 00000000..7c91cde1 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/mixins/session_handler_mixin.dart @@ -0,0 +1,244 @@ +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. +/// +/// Implementors must provide [auth] and [fetchUserRole]. The role fetch +/// should call `GET /auth/session` via [ApiService] instead of querying +/// Data Connect directly. +mixin SessionHandlerMixin { + /// Stream controller for session state changes. + final StreamController _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 { + return _createStreamWithLastState(); + } + + /// Creates a stream that emits the last state before subscribing to new events. + Stream _createStreamWithLastState() async* { + if (_lastSessionState != null) { + yield _lastSessionState!; + } + 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 (set during initialization). + List _allowedRoles = []; + + /// Initialize the auth state listener (call once on app startup). + void initializeAuthListener({ + List allowedRoles = const [], + }) { + _allowedRoles = allowedRoles; + + _authStateSubscription?.cancel(); + + _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. + 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 the backend. + /// + /// Implementors should call `GET /auth/session` via [ApiService] and + /// extract the role from the response. + 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; + if (user == null) return; + + final DateTime now = DateTime.now(); + if (_lastTokenRefreshTime != null) { + final Duration timeSinceLastCheck = now.difference( + _lastTokenRefreshTime!, + ); + if (timeSinceLastCheck < _minRefreshCheckInterval) { + return; + } + } + + const int maxRetries = 3; + int retryCount = 0; + + while (retryCount < maxRetries) { + try { + final firebase_auth.IdTokenResult idToken = + await user.getIdTokenResult(); + final DateTime? expiryTime = idToken.expirationTime; + + if (expiryTime == null) return; + + final Duration timeUntilExpiry = expiryTime.difference(now); + if (timeUntilExpiry <= _refreshThreshold) { + await user.getIdTokenResult(); + } + + _lastTokenRefreshTime = now; + return; + } catch (e) { + retryCount++; + debugPrint( + 'Token validation error (attempt $retryCount/$maxRetries): $e', + ); + + if (retryCount >= maxRetries) { + _emitSessionState( + SessionState.error( + 'Token validation failed after $maxRetries attempts: $e', + ), + ); + return; + } + + final Duration backoffDuration = Duration( + seconds: 1 << (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()); + + if (_allowedRoles.isNotEmpty) { + final String? userRole = await fetchUserRole(user.uid); + + if (userRole == null) { + _emitSessionState(SessionState.unauthenticated()); + return; + } + + if (!_allowedRoles.contains(userRole)) { + await auth.signOut(); + _emitSessionState(SessionState.unauthenticated()); + return; + } + } + + final firebase_auth.IdTokenResult idToken = + await user.getIdTokenResult(); + if (idToken.expirationTime != null && + DateTime.now().difference(idToken.expirationTime!) < + const Duration(minutes: 5)) { + await user.getIdTokenResult(); + } + + _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/core/pubspec.yaml b/apps/mobile/packages/core/pubspec.yaml index f40200eb..7b01f054 100644 --- a/apps/mobile/packages/core/pubspec.yaml +++ b/apps/mobile/packages/core/pubspec.yaml @@ -32,3 +32,4 @@ dependencies: flutter_local_notifications: ^21.0.0 shared_preferences: ^2.5.4 workmanager: ^0.9.0+3 + uuid: ^4.5.1 diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index c98147f3..2470cacf 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -6,6 +6,21 @@ /// Note: Repository Interfaces are now located in their respective Feature packages. library; +// Enums (shared status/type enums aligned with V2 CHECK constraints) +export 'src/entities/enums/account_type.dart'; +export 'src/entities/enums/application_status.dart'; +export 'src/entities/enums/assignment_status.dart'; +export 'src/entities/enums/attendance_status_type.dart'; +export 'src/entities/enums/availability_status.dart'; +export 'src/entities/enums/benefit_status.dart'; +export 'src/entities/enums/business_status.dart'; +export 'src/entities/enums/invoice_status.dart'; +export 'src/entities/enums/onboarding_status.dart'; +export 'src/entities/enums/order_type.dart'; +export 'src/entities/enums/payment_status.dart'; +export 'src/entities/enums/shift_status.dart'; +export 'src/entities/enums/staff_status.dart'; + // Core export 'src/core/services/api_services/api_response.dart'; export 'src/core/services/api_services/base_api_service.dart'; @@ -22,124 +37,90 @@ export 'src/core/models/device_location.dart'; // Users & Membership export 'src/entities/users/user.dart'; export 'src/entities/users/staff.dart'; -export 'src/entities/users/membership.dart'; export 'src/entities/users/biz_member.dart'; -export 'src/entities/users/hub_member.dart'; +export 'src/entities/users/staff_session.dart'; +export 'src/entities/users/client_session.dart'; // Business & Organization export 'src/entities/business/business.dart'; -export 'src/entities/business/business_setting.dart'; export 'src/entities/business/hub.dart'; -export 'src/entities/business/hub_department.dart'; export 'src/entities/business/vendor.dart'; export 'src/entities/business/cost_center.dart'; - -// Events & Assignments -export 'src/entities/events/event.dart'; -export 'src/entities/events/event_shift.dart'; -export 'src/entities/events/event_shift_position.dart'; -export 'src/entities/events/assignment.dart'; -export 'src/entities/events/work_session.dart'; +export 'src/entities/business/vendor_role.dart'; +export 'src/entities/business/hub_manager.dart'; +export 'src/entities/business/team_member.dart'; // Shifts export 'src/entities/shifts/shift.dart'; -export 'src/adapters/shifts/shift_adapter.dart'; -export 'src/entities/shifts/break/break.dart'; -export 'src/adapters/shifts/break/break_adapter.dart'; +export 'src/entities/shifts/today_shift.dart'; +export 'src/entities/shifts/assigned_shift.dart'; +export 'src/entities/shifts/open_shift.dart'; +export 'src/entities/shifts/pending_assignment.dart'; +export 'src/entities/shifts/cancelled_shift.dart'; +export 'src/entities/shifts/completed_shift.dart'; +export 'src/entities/shifts/shift_detail.dart'; -// Orders & Requests -export 'src/entities/orders/one_time_order.dart'; -export 'src/entities/orders/one_time_order_position.dart'; -export 'src/entities/orders/recurring_order.dart'; -export 'src/entities/orders/recurring_order_position.dart'; -export 'src/entities/orders/permanent_order.dart'; -export 'src/entities/orders/permanent_order_position.dart'; -export 'src/entities/orders/order_type.dart'; +// Orders export 'src/entities/orders/order_item.dart'; -export 'src/entities/orders/reorder_data.dart'; - -// Skills & Certs -export 'src/entities/skills/skill.dart'; -export 'src/entities/skills/skill_category.dart'; -export 'src/entities/skills/staff_skill.dart'; -export 'src/entities/skills/certificate.dart'; -export 'src/entities/skills/skill_kit.dart'; +export 'src/entities/orders/assigned_worker_summary.dart'; +export 'src/entities/orders/order_preview.dart'; +export 'src/entities/orders/recent_order.dart'; // Financial & Payroll export 'src/entities/benefits/benefit.dart'; export 'src/entities/financial/invoice.dart'; -export 'src/entities/financial/time_card.dart'; -export 'src/entities/financial/invoice_item.dart'; -export 'src/entities/financial/invoice_decline.dart'; -export 'src/entities/financial/staff_payment.dart'; +export 'src/entities/financial/billing_account.dart'; +export 'src/entities/financial/current_bill.dart'; +export 'src/entities/financial/savings.dart'; +export 'src/entities/financial/spend_item.dart'; +export 'src/entities/financial/bank_account.dart'; export 'src/entities/financial/payment_summary.dart'; -export 'src/entities/financial/billing_period.dart'; -export 'src/entities/financial/bank_account/bank_account.dart'; -export 'src/entities/financial/bank_account/business_bank_account.dart'; -export 'src/entities/financial/bank_account/staff_bank_account.dart'; -export 'src/adapters/financial/bank_account/bank_account_adapter.dart'; +export 'src/entities/financial/staff_payment.dart'; +export 'src/entities/financial/payment_chart_point.dart'; +export 'src/entities/financial/time_card.dart'; // Profile -export 'src/entities/profile/staff_document.dart'; -export 'src/entities/profile/document_verification_status.dart'; -export 'src/entities/profile/staff_certificate.dart'; -export 'src/entities/profile/compliance_type.dart'; -export 'src/entities/profile/staff_certificate_status.dart'; -export 'src/entities/profile/staff_certificate_validation_status.dart'; -export 'src/entities/profile/attire_item.dart'; -export 'src/entities/profile/attire_verification_status.dart'; -export 'src/entities/profile/relationship_type.dart'; -export 'src/entities/profile/industry.dart'; -export 'src/entities/profile/tax_form.dart'; - -// Ratings & Penalties -export 'src/entities/ratings/staff_rating.dart'; -export 'src/entities/ratings/penalty_log.dart'; -export 'src/entities/ratings/business_staff_preference.dart'; - -// Staff Profile +export 'src/entities/profile/staff_personal_info.dart'; +export 'src/entities/profile/profile_section_status.dart'; +export 'src/entities/profile/profile_completion.dart'; +export 'src/entities/profile/profile_document.dart'; +export 'src/entities/profile/certificate.dart'; export 'src/entities/profile/emergency_contact.dart'; +export 'src/entities/profile/tax_form.dart'; +export 'src/entities/profile/privacy_settings.dart'; +export 'src/entities/profile/attire_checklist.dart'; export 'src/entities/profile/accessibility.dart'; -export 'src/entities/profile/schedule.dart'; -// Support & Config -export 'src/entities/support/addon.dart'; -export 'src/entities/support/tag.dart'; -export 'src/entities/support/media.dart'; -export 'src/entities/support/working_area.dart'; +// Ratings +export 'src/entities/ratings/staff_rating.dart'; // Home -export 'src/entities/home/home_dashboard_data.dart'; -export 'src/entities/home/reorder_item.dart'; +export 'src/entities/home/client_dashboard.dart'; +export 'src/entities/home/spending_summary.dart'; +export 'src/entities/home/coverage_metrics.dart'; +export 'src/entities/home/live_activity_metrics.dart'; +export 'src/entities/home/staff_dashboard.dart'; -// Availability -export 'src/adapters/availability/availability_adapter.dart'; +// Clock-In & Availability export 'src/entities/clock_in/attendance_status.dart'; -export 'src/adapters/clock_in/clock_in_adapter.dart'; -export 'src/entities/availability/availability_slot.dart'; -export 'src/entities/availability/day_availability.dart'; +export 'src/entities/availability/availability_day.dart'; +export 'src/entities/availability/time_slot.dart'; // Coverage -export 'src/entities/coverage_domain/coverage_shift.dart'; -export 'src/entities/coverage_domain/coverage_worker.dart'; +export 'src/entities/coverage_domain/shift_with_workers.dart'; +export 'src/entities/coverage_domain/assigned_worker.dart'; +export 'src/entities/coverage_domain/time_range.dart'; export 'src/entities/coverage_domain/coverage_stats.dart'; +export 'src/entities/coverage_domain/core_team_member.dart'; -// Adapters -export 'src/adapters/profile/emergency_contact_adapter.dart'; -export 'src/adapters/profile/experience_adapter.dart'; -export 'src/entities/profile/experience_skill.dart'; -export 'src/adapters/profile/bank_account_adapter.dart'; -export 'src/adapters/profile/tax_form_adapter.dart'; -export 'src/adapters/financial/payment_adapter.dart'; +// Reports +export 'src/entities/reports/report_summary.dart'; +export 'src/entities/reports/daily_ops_report.dart'; +export 'src/entities/reports/spend_data_point.dart'; +export 'src/entities/reports/coverage_report.dart'; +export 'src/entities/reports/forecast_report.dart'; +export 'src/entities/reports/performance_report.dart'; +export 'src/entities/reports/no_show_report.dart'; // Exceptions export 'src/exceptions/app_exception.dart'; - -// Reports -export 'src/entities/reports/daily_ops_report.dart'; -export 'src/entities/reports/spend_report.dart'; -export 'src/entities/reports/coverage_report.dart'; -export 'src/entities/reports/forecast_report.dart'; -export 'src/entities/reports/no_show_report.dart'; -export 'src/entities/reports/performance_report.dart'; -export 'src/entities/reports/reports_summary.dart'; diff --git a/apps/mobile/packages/domain/lib/src/adapters/availability/availability_adapter.dart b/apps/mobile/packages/domain/lib/src/adapters/availability/availability_adapter.dart deleted file mode 100644 index f06ddeeb..00000000 --- a/apps/mobile/packages/domain/lib/src/adapters/availability/availability_adapter.dart +++ /dev/null @@ -1,33 +0,0 @@ -import '../../entities/availability/availability_slot.dart'; - -/// Adapter for [AvailabilitySlot] domain entity. -class AvailabilityAdapter { - static const Map> _slotDefinitions = >{ - 'MORNING': { - 'id': 'morning', - 'label': 'Morning', - 'timeRange': '4:00 AM - 12:00 PM', - }, - 'AFTERNOON': { - 'id': 'afternoon', - 'label': 'Afternoon', - 'timeRange': '12:00 PM - 6:00 PM', - }, - 'EVENING': { - 'id': 'evening', - 'label': 'Evening', - 'timeRange': '6:00 PM - 12:00 AM', - }, - }; - - /// Converts a backend slot name (e.g. 'MORNING') to a Domain [AvailabilitySlot]. - static AvailabilitySlot fromPrimitive(String slotName, {bool isAvailable = false}) { - final Map def = _slotDefinitions[slotName.toUpperCase()] ?? _slotDefinitions['MORNING']!; - return AvailabilitySlot( - id: def['id']!, - label: def['label']!, - timeRange: def['timeRange']!, - isAvailable: isAvailable, - ); - } -} diff --git a/apps/mobile/packages/domain/lib/src/adapters/clock_in/clock_in_adapter.dart b/apps/mobile/packages/domain/lib/src/adapters/clock_in/clock_in_adapter.dart deleted file mode 100644 index 049dd3cd..00000000 --- a/apps/mobile/packages/domain/lib/src/adapters/clock_in/clock_in_adapter.dart +++ /dev/null @@ -1,27 +0,0 @@ -import '../../entities/clock_in/attendance_status.dart'; - -/// Adapter for Clock In related data. -class ClockInAdapter { - - /// Converts primitive attendance data to [AttendanceStatus]. - static AttendanceStatus toAttendanceStatus({ - required String status, - DateTime? checkInTime, - DateTime? checkOutTime, - String? activeShiftId, - String? activeApplicationId, - }) { - final bool isCheckedIn = status == 'CHECKED_IN' || status == 'LATE'; // Assuming LATE is also checked in? - - // Statuses that imply active attendance: CHECKED_IN, LATE. - // Statuses that imply completed: CHECKED_OUT. - - return AttendanceStatus( - isCheckedIn: isCheckedIn, - checkInTime: checkInTime, - checkOutTime: checkOutTime, - activeShiftId: activeShiftId, - activeApplicationId: activeApplicationId, - ); - } -} diff --git a/apps/mobile/packages/domain/lib/src/adapters/financial/bank_account/bank_account_adapter.dart b/apps/mobile/packages/domain/lib/src/adapters/financial/bank_account/bank_account_adapter.dart deleted file mode 100644 index 167d1126..00000000 --- a/apps/mobile/packages/domain/lib/src/adapters/financial/bank_account/bank_account_adapter.dart +++ /dev/null @@ -1,21 +0,0 @@ -import '../../../entities/financial/bank_account/business_bank_account.dart'; - -/// Adapter for [BusinessBankAccount] to map data layer values to domain entity. -class BusinessBankAccountAdapter { - /// Maps primitive values to [BusinessBankAccount]. - static BusinessBankAccount fromPrimitives({ - required String id, - required String bank, - required String last4, - required bool isPrimary, - DateTime? expiryTime, - }) { - return BusinessBankAccount( - id: id, - bankName: bank, - last4: last4, - isPrimary: isPrimary, - expiryTime: expiryTime, - ); - } -} diff --git a/apps/mobile/packages/domain/lib/src/adapters/financial/payment_adapter.dart b/apps/mobile/packages/domain/lib/src/adapters/financial/payment_adapter.dart deleted file mode 100644 index 66446058..00000000 --- a/apps/mobile/packages/domain/lib/src/adapters/financial/payment_adapter.dart +++ /dev/null @@ -1,19 +0,0 @@ -import '../../entities/financial/staff_payment.dart'; - -/// Adapter for Payment related data. -class PaymentAdapter { - - /// Converts string status to [PaymentStatus]. - static PaymentStatus toPaymentStatus(String status) { - switch (status) { - case 'PAID': - return PaymentStatus.paid; - case 'PENDING': - return PaymentStatus.pending; - case 'FAILED': - return PaymentStatus.failed; - default: - return PaymentStatus.unknown; - } - } -} diff --git a/apps/mobile/packages/domain/lib/src/adapters/financial/time_card_adapter.dart b/apps/mobile/packages/domain/lib/src/adapters/financial/time_card_adapter.dart deleted file mode 100644 index 6c5033a7..00000000 --- a/apps/mobile/packages/domain/lib/src/adapters/financial/time_card_adapter.dart +++ /dev/null @@ -1,49 +0,0 @@ -import '../../entities/financial/time_card.dart'; - -/// Adapter for [TimeCard] to map data layer values to domain entity. -class TimeCardAdapter { - /// Maps primitive values to [TimeCard]. - static TimeCard fromPrimitives({ - required String id, - required String shiftTitle, - required String clientName, - required DateTime date, - required String startTime, - required String endTime, - required double totalHours, - required double hourlyRate, - required double totalPay, - required String status, - String? location, - }) { - return TimeCard( - id: id, - shiftTitle: shiftTitle, - clientName: clientName, - date: date, - startTime: startTime, - endTime: endTime, - totalHours: totalHours, - hourlyRate: hourlyRate, - totalPay: totalPay, - status: _stringToStatus(status), - location: location, - ); - } - - static TimeCardStatus _stringToStatus(String status) { - switch (status.toUpperCase()) { - case 'CHECKED_OUT': - case 'COMPLETED': - return TimeCardStatus.approved; // Assuming completed = approved for now - case 'PAID': - return TimeCardStatus.paid; // If this status exists - case 'DISPUTED': - return TimeCardStatus.disputed; - case 'CHECKED_IN': - case 'CONFIRMED': - default: - return TimeCardStatus.pending; - } - } -} diff --git a/apps/mobile/packages/domain/lib/src/adapters/profile/bank_account_adapter.dart b/apps/mobile/packages/domain/lib/src/adapters/profile/bank_account_adapter.dart deleted file mode 100644 index 133da163..00000000 --- a/apps/mobile/packages/domain/lib/src/adapters/profile/bank_account_adapter.dart +++ /dev/null @@ -1,53 +0,0 @@ -import '../../entities/financial/bank_account/staff_bank_account.dart'; - -/// Adapter for [StaffBankAccount] to map data layer values to domain entity. -class BankAccountAdapter { - /// Maps primitive values to [StaffBankAccount]. - static StaffBankAccount fromPrimitives({ - required String id, - required String userId, - required String bankName, - required String? type, - String? accountNumber, - String? last4, - String? sortCode, - bool? isPrimary, - }) { - return StaffBankAccount( - id: id, - userId: userId, - bankName: bankName, - accountNumber: accountNumber ?? '', - accountName: '', // Not provided by backend - last4: last4, - sortCode: sortCode, - type: _stringToType(type), - isPrimary: isPrimary ?? false, - ); - } - - static StaffBankAccountType _stringToType(String? value) { - if (value == null) return StaffBankAccountType.checking; - try { - // Assuming backend enum names match or are uppercase - return StaffBankAccountType.values.firstWhere( - (StaffBankAccountType e) => e.name.toLowerCase() == value.toLowerCase(), - orElse: () => StaffBankAccountType.other, - ); - } catch (_) { - return StaffBankAccountType.other; - } - } - - /// Converts domain type to string for backend. - static String typeToString(StaffBankAccountType type) { - switch (type) { - case StaffBankAccountType.checking: - return 'CHECKING'; - case StaffBankAccountType.savings: - return 'SAVINGS'; - default: - return 'CHECKING'; - } - } -} diff --git a/apps/mobile/packages/domain/lib/src/adapters/profile/emergency_contact_adapter.dart b/apps/mobile/packages/domain/lib/src/adapters/profile/emergency_contact_adapter.dart deleted file mode 100644 index 964f5735..00000000 --- a/apps/mobile/packages/domain/lib/src/adapters/profile/emergency_contact_adapter.dart +++ /dev/null @@ -1,19 +0,0 @@ -import '../../entities/profile/emergency_contact.dart'; - -/// Adapter for [EmergencyContact] to map data layer values to domain entity. -class EmergencyContactAdapter { - /// Maps primitive values to [EmergencyContact]. - static EmergencyContact fromPrimitives({ - required String id, - required String name, - required String phone, - String? relationship, - }) { - return EmergencyContact( - id: id, - name: name, - phone: phone, - relationship: EmergencyContact.stringToRelationshipType(relationship), - ); - } -} diff --git a/apps/mobile/packages/domain/lib/src/adapters/profile/experience_adapter.dart b/apps/mobile/packages/domain/lib/src/adapters/profile/experience_adapter.dart deleted file mode 100644 index 837f3ab1..00000000 --- a/apps/mobile/packages/domain/lib/src/adapters/profile/experience_adapter.dart +++ /dev/null @@ -1,18 +0,0 @@ -/// Adapter for Experience data (skills/industries) to map data layer values to domain models. -class ExperienceAdapter { - /// Converts a dynamic list (from backend AnyValue) to List. - /// - /// Handles nulls and converts elements to Strings. - static List fromDynamicList(dynamic data) { - if (data == null) return []; - - if (data is List) { - return data - .where((dynamic e) => e != null) - .map((dynamic e) => e.toString()) - .toList(); - } - - return []; - } -} diff --git a/apps/mobile/packages/domain/lib/src/adapters/profile/tax_form_adapter.dart b/apps/mobile/packages/domain/lib/src/adapters/profile/tax_form_adapter.dart deleted file mode 100644 index 8d4c8f6e..00000000 --- a/apps/mobile/packages/domain/lib/src/adapters/profile/tax_form_adapter.dart +++ /dev/null @@ -1,104 +0,0 @@ -import '../../entities/profile/tax_form.dart'; - -/// Adapter for [TaxForm] to map data layer values to domain entity. -class TaxFormAdapter { - /// Maps primitive values to [TaxForm]. - static TaxForm fromPrimitives({ - required String id, - required String type, - required String title, - String? subtitle, - String? description, - required String status, - String? staffId, - dynamic formData, - DateTime? createdAt, - DateTime? updatedAt, - }) { - final TaxFormType formType = _stringToType(type); - final TaxFormStatus formStatus = _stringToStatus(status); - final Map formDetails = - formData is Map ? Map.from(formData) : {}; - - if (formType == TaxFormType.i9) { - return I9TaxForm( - id: id, - title: title, - subtitle: subtitle, - description: description, - status: formStatus, - staffId: staffId, - formData: formDetails, - createdAt: createdAt, - updatedAt: updatedAt, - ); - } else { - return W4TaxForm( - id: id, - title: title, - subtitle: subtitle, - description: description, - status: formStatus, - staffId: staffId, - formData: formDetails, - createdAt: createdAt, - updatedAt: updatedAt, - ); - } - } - - static TaxFormType _stringToType(String? value) { - if (value == null) return TaxFormType.i9; - try { - return TaxFormType.values.firstWhere( - (TaxFormType e) => e.name.toLowerCase() == value.toLowerCase(), - orElse: () => TaxFormType.i9, - ); - } catch (_) { - return TaxFormType.i9; - } - } - - static TaxFormStatus _stringToStatus(String? value) { - if (value == null) return TaxFormStatus.notStarted; - try { - final String normalizedValue = value.replaceAll('_', '').toLowerCase(); - // map DRAFT to inProgress - if (normalizedValue == 'draft') return TaxFormStatus.inProgress; - - return TaxFormStatus.values.firstWhere( - (TaxFormStatus e) { - // Handle differences like not_started vs notStarted if any, - // but standardizing to lowercase is a good start. - // The enum names are camelCase in Dart, but might be SNAKE_CASE from backend. - final String normalizedEnum = e.name.toLowerCase(); - return normalizedValue == normalizedEnum; - }, - orElse: () => TaxFormStatus.notStarted, - ); - } catch (_) { - return TaxFormStatus.notStarted; - } - } - - /// Converts domain [TaxFormType] to string for backend. - static String typeToString(TaxFormType type) { - return type.name.toUpperCase(); - } - - /// Converts domain [TaxFormStatus] to string for backend. - static String statusToString(TaxFormStatus status) { - switch (status) { - case TaxFormStatus.notStarted: - return 'NOT_STARTED'; - case TaxFormStatus.inProgress: - return 'DRAFT'; - case TaxFormStatus.submitted: - return 'SUBMITTED'; - case TaxFormStatus.approved: - return 'APPROVED'; - case TaxFormStatus.rejected: - return 'REJECTED'; - } - } -} diff --git a/apps/mobile/packages/domain/lib/src/adapters/shifts/break/break_adapter.dart b/apps/mobile/packages/domain/lib/src/adapters/shifts/break/break_adapter.dart deleted file mode 100644 index 59f46949..00000000 --- a/apps/mobile/packages/domain/lib/src/adapters/shifts/break/break_adapter.dart +++ /dev/null @@ -1,39 +0,0 @@ -import '../../../entities/shifts/break/break.dart'; - -/// Adapter for Break related data. -class BreakAdapter { - /// Maps break data to a Break entity. - /// - /// [isPaid] whether the break is paid. - /// [breakTime] the string representation of the break duration (e.g., 'MIN_10', 'MIN_30'). - static Break fromData({ - required bool isPaid, - required String? breakTime, - }) { - return Break( - isBreakPaid: isPaid, - duration: _parseDuration(breakTime), - ); - } - - static BreakDuration _parseDuration(String? breakTime) { - if (breakTime == null) return BreakDuration.none; - - switch (breakTime.toUpperCase()) { - case 'MIN_10': - return BreakDuration.ten; - case 'MIN_15': - return BreakDuration.fifteen; - case 'MIN_20': - return BreakDuration.twenty; - case 'MIN_30': - return BreakDuration.thirty; - case 'MIN_45': - return BreakDuration.fortyFive; - case 'MIN_60': - return BreakDuration.sixty; - default: - return BreakDuration.none; - } - } -} diff --git a/apps/mobile/packages/domain/lib/src/adapters/shifts/shift_adapter.dart b/apps/mobile/packages/domain/lib/src/adapters/shifts/shift_adapter.dart deleted file mode 100644 index 6022d327..00000000 --- a/apps/mobile/packages/domain/lib/src/adapters/shifts/shift_adapter.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:intl/intl.dart'; -import '../../entities/shifts/shift.dart'; - -/// Adapter for Shift related data. -class ShiftAdapter { - /// Maps application data to a Shift entity. - /// - /// This method handles the common mapping logic used across different - /// repositories when converting application data from Data Connect to - /// domain Shift entities. - static Shift fromApplicationData({ - required String shiftId, - required String roleId, - required String roleName, - required String businessName, - String? companyLogoUrl, - required double costPerHour, - String? shiftLocation, - required String teamHubName, - DateTime? shiftDate, - DateTime? startTime, - DateTime? endTime, - DateTime? createdAt, - required String status, - String? description, - int? durationDays, - required int count, - int? assigned, - String? eventName, - bool hasApplied = false, - }) { - final String orderName = (eventName ?? '').trim().isNotEmpty - ? eventName! - : businessName; - final String title = '$roleName - $orderName'; - - return Shift( - id: shiftId, - roleId: roleId, - title: title, - clientName: businessName, - logoUrl: companyLogoUrl, - hourlyRate: costPerHour, - location: shiftLocation ?? '', - locationAddress: teamHubName, - date: shiftDate?.toIso8601String() ?? '', - startTime: startTime != null ? DateFormat('HH:mm').format(startTime) : '', - endTime: endTime != null ? DateFormat('HH:mm').format(endTime) : '', - createdDate: createdAt?.toIso8601String() ?? '', - status: status, - description: description, - durationDays: durationDays, - requiredSlots: count, - filledSlots: assigned ?? 0, - hasApplied: hasApplied, - ); - } -} - diff --git a/apps/mobile/packages/domain/lib/src/core/services/api_services/base_api_service.dart b/apps/mobile/packages/domain/lib/src/core/services/api_services/base_api_service.dart index ef9ccef6..a3dabfb0 100644 --- a/apps/mobile/packages/domain/lib/src/core/services/api_services/base_api_service.dart +++ b/apps/mobile/packages/domain/lib/src/core/services/api_services/base_api_service.dart @@ -27,4 +27,11 @@ abstract class BaseApiService { dynamic data, Map? params, }); + + /// Performs a DELETE request to the specified [endpoint]. + Future delete( + String endpoint, { + dynamic data, + Map? params, + }); } diff --git a/apps/mobile/packages/domain/lib/src/entities/availability/availability_day.dart b/apps/mobile/packages/domain/lib/src/entities/availability/availability_day.dart new file mode 100644 index 00000000..b7622698 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/availability/availability_day.dart @@ -0,0 +1,86 @@ +import 'package:equatable/equatable.dart'; + +import 'package:krow_domain/src/entities/availability/time_slot.dart'; +import 'package:krow_domain/src/entities/enums/availability_status.dart'; + +/// Availability for a single calendar date. +/// +/// Returned by `GET /staff/availability`. The backend generates one entry +/// per date in the requested range by projecting the staff member's +/// recurring weekly availability pattern. +class AvailabilityDay extends Equatable { + /// Creates an [AvailabilityDay]. + const AvailabilityDay({ + required this.date, + required this.dayOfWeek, + required this.availabilityStatus, + this.slots = const [], + }); + + /// Deserialises from the V2 API JSON response. + factory AvailabilityDay.fromJson(Map json) { + final dynamic rawSlots = json['slots']; + final List parsedSlots = rawSlots is List + ? rawSlots + .map((dynamic e) => + TimeSlot.fromJson(e as Map)) + .toList() + : []; + + return AvailabilityDay( + date: json['date'] as String, + dayOfWeek: json['dayOfWeek'] as int, + availabilityStatus: + AvailabilityStatus.fromJson(json['availabilityStatus'] as String?), + slots: parsedSlots, + ); + } + + /// ISO date string (`YYYY-MM-DD`). + final String date; + + /// Day of week (0 = Sunday, 6 = Saturday). + final int dayOfWeek; + + /// Availability status for this day. + final AvailabilityStatus availabilityStatus; + + /// Time slots when the worker is available (relevant for `PARTIAL`). + final List slots; + + /// Whether the worker has any availability on this day. + bool get isAvailable => availabilityStatus != AvailabilityStatus.unavailable; + + /// Creates a copy with the given fields replaced. + AvailabilityDay copyWith({ + String? date, + int? dayOfWeek, + AvailabilityStatus? availabilityStatus, + List? slots, + }) { + return AvailabilityDay( + date: date ?? this.date, + dayOfWeek: dayOfWeek ?? this.dayOfWeek, + availabilityStatus: availabilityStatus ?? this.availabilityStatus, + slots: slots ?? this.slots, + ); + } + + /// Serialises to JSON. + Map toJson() { + return { + 'date': date, + 'dayOfWeek': dayOfWeek, + 'availabilityStatus': availabilityStatus.toJson(), + 'slots': slots.map((TimeSlot s) => s.toJson()).toList(), + }; + } + + @override + List get props => [ + date, + dayOfWeek, + availabilityStatus, + slots, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/availability/availability_slot.dart b/apps/mobile/packages/domain/lib/src/entities/availability/availability_slot.dart deleted file mode 100644 index b0085bed..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/availability/availability_slot.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents a specific time slot within a day (e.g., Morning, Afternoon, Evening). -class AvailabilitySlot extends Equatable { - - const AvailabilitySlot({ - required this.id, - required this.label, - required this.timeRange, - this.isAvailable = true, - }); - final String id; - final String label; - final String timeRange; - final bool isAvailable; - - AvailabilitySlot copyWith({ - String? id, - String? label, - String? timeRange, - bool? isAvailable, - }) { - return AvailabilitySlot( - id: id ?? this.id, - label: label ?? this.label, - timeRange: timeRange ?? this.timeRange, - isAvailable: isAvailable ?? this.isAvailable, - ); - } - - @override - List get props => [id, label, timeRange, isAvailable]; -} diff --git a/apps/mobile/packages/domain/lib/src/entities/availability/day_availability.dart b/apps/mobile/packages/domain/lib/src/entities/availability/day_availability.dart deleted file mode 100644 index ee285830..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/availability/day_availability.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:equatable/equatable.dart'; - -import 'availability_slot.dart'; - -/// Represents availability configuration for a specific date. -class DayAvailability extends Equatable { - - const DayAvailability({ - required this.date, - this.isAvailable = false, - this.slots = const [], - }); - final DateTime date; - final bool isAvailable; - final List slots; - - DayAvailability copyWith({ - DateTime? date, - bool? isAvailable, - List? slots, - }) { - return DayAvailability( - date: date ?? this.date, - isAvailable: isAvailable ?? this.isAvailable, - slots: slots ?? this.slots, - ); - } - - @override - List get props => [date, isAvailable, slots]; -} 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 new file mode 100644 index 00000000..8bfcf812 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/availability/time_slot.dart @@ -0,0 +1,39 @@ +import 'package:equatable/equatable.dart'; + +/// A time range within a day of availability. +/// +/// Embedded inside [AvailabilityDay.slots]. Times are stored as `HH:MM` +/// strings because the backend stores them in a JSONB array and they +/// are timezone-agnostic display values. +class TimeSlot extends Equatable { + /// Creates a [TimeSlot]. + const TimeSlot({ + required this.startTime, + required this.endTime, + }); + + /// Deserialises from a JSON map inside the availability slots array. + factory TimeSlot.fromJson(Map json) { + return TimeSlot( + startTime: json['startTime'] as String? ?? '00:00', + endTime: json['endTime'] as String? ?? '00:00', + ); + } + + /// Start time in `HH:MM` format. + final String startTime; + + /// End time in `HH:MM` format. + final String endTime; + + /// Serialises to JSON. + Map toJson() { + return { + 'startTime': startTime, + 'endTime': endTime, + }; + } + + @override + List get props => [startTime, endTime]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/benefits/benefit.dart b/apps/mobile/packages/domain/lib/src/entities/benefits/benefit.dart index 15bb6f99..59ea40a7 100644 --- a/apps/mobile/packages/domain/lib/src/entities/benefits/benefit.dart +++ b/apps/mobile/packages/domain/lib/src/entities/benefits/benefit.dart @@ -1,26 +1,73 @@ import 'package:equatable/equatable.dart'; -/// Represents a staff member's benefit balance. +import 'package:krow_domain/src/entities/enums/benefit_status.dart'; + +/// A benefit accrued by a staff member (e.g. sick leave, vacation). +/// +/// Returned by `GET /staff/profile/benefits`. class Benefit extends Equatable { - /// Creates a [Benefit]. + /// Creates a [Benefit] instance. const Benefit({ + required this.benefitId, + required this.benefitType, required this.title, - required this.entitlementHours, - required this.usedHours, + required this.status, + required this.trackedHours, + required this.targetHours, }); - /// The title of the benefit (e.g., Sick Leave, Holiday, Vacation). + /// Deserialises a [Benefit] from a V2 API JSON map. + factory Benefit.fromJson(Map json) { + return Benefit( + benefitId: json['benefitId'] as String, + benefitType: json['benefitType'] as String, + title: json['title'] as String, + status: BenefitStatus.fromJson(json['status'] as String?), + trackedHours: (json['trackedHours'] as num).toInt(), + targetHours: (json['targetHours'] as num).toInt(), + ); + } + + /// Unique identifier. + final String benefitId; + + /// Type code (e.g. SICK_LEAVE, VACATION). + final String benefitType; + + /// Human-readable title. final String title; - /// The total entitlement in hours. - final double entitlementHours; + /// Current benefit status. + final BenefitStatus status; - /// The hours used so far. - final double usedHours; + /// Hours tracked so far. + final int trackedHours; - /// The hours remaining. - double get remainingHours => entitlementHours - usedHours; + /// Target hours to accrue. + final int targetHours; + + /// Remaining hours to reach the target. + int get remainingHours => targetHours - trackedHours; + + /// Serialises this [Benefit] to a JSON map. + Map toJson() { + return { + 'benefitId': benefitId, + 'benefitType': benefitType, + 'title': title, + 'status': status.toJson(), + 'trackedHours': trackedHours, + 'targetHours': targetHours, + }; + } @override - List get props => [title, entitlementHours, usedHours]; + List get props => [ + benefitId, + benefitType, + title, + status, + trackedHours, + targetHours, + ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/business/biz_contract.dart b/apps/mobile/packages/domain/lib/src/entities/business/biz_contract.dart deleted file mode 100644 index 196c9eb8..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/business/biz_contract.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents a legal or service contract. -/// -/// Can be between a business and the platform, or a business and staff. -class BizContract extends Equatable { - - const BizContract({ - required this.id, - required this.businessId, - required this.name, - required this.startDate, - this.endDate, - required this.contentUrl, - }); - /// Unique identifier. - final String id; - - /// The [Business] party to the contract. - final String businessId; - - /// Descriptive name of the contract. - final String name; - - /// Valid from date. - final DateTime startDate; - - /// Valid until date (null if indefinite). - final DateTime? endDate; - - /// URL to the document content (PDF/HTML). - final String contentUrl; - - @override - List get props => [id, businessId, name, startDate, endDate, contentUrl]; -} \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/src/entities/business/business.dart b/apps/mobile/packages/domain/lib/src/entities/business/business.dart index c03e75c9..36339f32 100644 --- a/apps/mobile/packages/domain/lib/src/entities/business/business.dart +++ b/apps/mobile/packages/domain/lib/src/entities/business/business.dart @@ -1,47 +1,111 @@ import 'package:equatable/equatable.dart'; -/// The operating status of a [Business]. -enum BusinessStatus { - /// Business created but not yet approved. - pending, +import 'package:krow_domain/src/entities/enums/business_status.dart'; - /// Fully active and operational. - active, - - /// Temporarily suspended (e.g. for non-payment). - suspended, - - /// Permanently inactive. - inactive, -} - -/// Represents a Client Company / Business. +/// A client company registered on the platform. /// -/// This is the top-level organizational entity in the system. +/// Maps to the V2 `businesses` table. class Business extends Equatable { - + /// Creates a [Business] instance. const Business({ required this.id, - required this.name, - required this.registrationNumber, + required this.tenantId, + required this.slug, + required this.businessName, required this.status, - this.avatar, + this.contactName, + this.contactEmail, + this.contactPhone, + this.metadata = const {}, + this.createdAt, + this.updatedAt, }); - /// Unique identifier for the business. + + /// Deserialises a [Business] from a V2 API JSON map. + factory Business.fromJson(Map json) { + return Business( + id: json['id'] as String, + tenantId: json['tenantId'] as String, + slug: json['slug'] as String, + businessName: json['businessName'] as String, + status: BusinessStatus.fromJson(json['status'] as String?), + contactName: json['contactName'] as String?, + contactEmail: json['contactEmail'] as String?, + contactPhone: json['contactPhone'] as String?, + metadata: json['metadata'] is Map + ? Map.from(json['metadata'] as Map) + : const {}, + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt'] as String) + : null, + updatedAt: json['updatedAt'] != null + ? DateTime.parse(json['updatedAt'] as String) + : null, + ); + } + + /// Unique identifier. final String id; + /// Tenant this business belongs to. + final String tenantId; + + /// URL-safe slug. + final String slug; + /// Display name of the business. - final String name; + final String businessName; - /// Legal registration or tax number. - final String registrationNumber; - - /// Current operating status. + /// Current account status. final BusinessStatus status; - /// URL to the business logo. - final String? avatar; + /// Primary contact name. + final String? contactName; + + /// Primary contact email. + final String? contactEmail; + + /// Primary contact phone. + final String? contactPhone; + + /// Flexible metadata bag. + final Map metadata; + + /// When the record was created. + final DateTime? createdAt; + + /// When the record was last updated. + final DateTime? updatedAt; + + /// Serialises this [Business] to a JSON map. + Map toJson() { + return { + 'id': id, + 'tenantId': tenantId, + 'slug': slug, + 'businessName': businessName, + 'status': status.toJson(), + 'contactName': contactName, + 'contactEmail': contactEmail, + 'contactPhone': contactPhone, + 'metadata': metadata, + 'createdAt': createdAt?.toIso8601String(), + 'updatedAt': updatedAt?.toIso8601String(), + }; + } @override - List get props => [id, name, registrationNumber, status, avatar]; -} \ No newline at end of file + List get props => [ + id, + tenantId, + slug, + businessName, + status, + contactName, + contactEmail, + contactPhone, + metadata, + createdAt, + updatedAt, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/business/business_setting.dart b/apps/mobile/packages/domain/lib/src/entities/business/business_setting.dart deleted file mode 100644 index 328cb39c..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/business/business_setting.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents payroll and operational configuration for a [Business]. -class BusinessSetting extends Equatable { - - const BusinessSetting({ - required this.id, - required this.businessId, - required this.prefix, - required this.overtimeEnabled, - this.clockInRequirement, - this.clockOutRequirement, - }); - /// Unique identifier for the settings record. - final String id; - - /// The [Business] these settings apply to. - final String businessId; - - /// Prefix for generated invoices (e.g., "INV-"). - final String prefix; - - /// Whether overtime calculations are applied. - final bool overtimeEnabled; - - /// Requirement method for clocking in (e.g. "qr_code", "geo_fence"). - final String? clockInRequirement; - - /// Requirement method for clocking out. - final String? clockOutRequirement; - - @override - List get props => [ - id, - businessId, - prefix, - overtimeEnabled, - clockInRequirement, - clockOutRequirement, - ]; -} \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/src/entities/business/cost_center.dart b/apps/mobile/packages/domain/lib/src/entities/business/cost_center.dart index 8d3d5528..33ad6b2e 100644 --- a/apps/mobile/packages/domain/lib/src/entities/business/cost_center.dart +++ b/apps/mobile/packages/domain/lib/src/entities/business/cost_center.dart @@ -1,22 +1,37 @@ import 'package:equatable/equatable.dart'; -/// Represents a financial cost center used for billing and tracking. +/// A financial cost center used for billing and tracking. +/// +/// Returned by `GET /client/cost-centers`. class CostCenter extends Equatable { + /// Creates a [CostCenter] instance. const CostCenter({ - required this.id, + required this.costCenterId, required this.name, - this.code, }); - /// Unique identifier. - final String id; + /// Deserialises a [CostCenter] from a V2 API JSON map. + factory CostCenter.fromJson(Map json) { + return CostCenter( + costCenterId: json['costCenterId'] as String, + name: json['name'] as String, + ); + } - /// Display name of the cost center. + /// Unique identifier. + final String costCenterId; + + /// Display name. final String name; - /// Optional alphanumeric code associated with this cost center. - final String? code; + /// Serialises this [CostCenter] to a JSON map. + Map toJson() { + return { + 'costCenterId': costCenterId, + 'name': name, + }; + } @override - List get props => [id, name, code]; + List get props => [costCenterId, name]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/business/hub.dart b/apps/mobile/packages/domain/lib/src/entities/business/hub.dart index 79c06572..5c2212e0 100644 --- a/apps/mobile/packages/domain/lib/src/entities/business/hub.dart +++ b/apps/mobile/packages/domain/lib/src/entities/business/hub.dart @@ -1,51 +1,107 @@ import 'package:equatable/equatable.dart'; -import 'cost_center.dart'; - -/// The status of a [Hub]. -enum HubStatus { - /// Fully operational. - active, - - /// Closed or inactive. - inactive, - - /// Not yet ready for operations. - underConstruction, -} - -/// Represents a branch location or operational unit within a [Business]. +/// A physical clock-point location (hub) belonging to a business. +/// +/// Maps to the V2 `clock_points` table; returned by `GET /client/hubs`. class Hub extends Equatable { + /// Creates a [Hub] instance. const Hub({ - required this.id, - required this.businessId, + required this.hubId, required this.name, - required this.address, + this.fullAddress, + this.latitude, + this.longitude, this.nfcTagId, - required this.status, - this.costCenter, + this.city, + this.state, + this.zipCode, + this.costCenterId, + this.costCenterName, }); - /// Unique identifier. - final String id; - /// The parent [Business]. - final String businessId; + /// Deserialises a [Hub] from a V2 API JSON map. + factory Hub.fromJson(Map json) { + return Hub( + hubId: json['hubId'] as String, + name: json['name'] as String, + fullAddress: json['fullAddress'] as String?, + latitude: json['latitude'] != null + ? double.parse(json['latitude'].toString()) + : null, + longitude: json['longitude'] != null + ? double.parse(json['longitude'].toString()) + : null, + nfcTagId: json['nfcTagId'] as String?, + city: json['city'] as String?, + state: json['state'] as String?, + zipCode: json['zipCode'] as String?, + costCenterId: json['costCenterId'] as String?, + costCenterName: json['costCenterName'] as String?, + ); + } - /// Display name of the hub (e.g. "Downtown Branch"). + /// Unique identifier (clock_point id). + final String hubId; + + /// Display label for the hub. final String name; - /// Physical address of this hub. - final String address; + /// Full street address. + final String? fullAddress; - /// Unique identifier of the NFC tag assigned to this hub. + /// GPS latitude. + final double? latitude; + + /// GPS longitude. + final double? longitude; + + /// NFC tag UID assigned to this hub. final String? nfcTagId; - /// Operational status. - final HubStatus status; + /// City from metadata. + final String? city; - /// Assigned cost center for this hub. - final CostCenter? costCenter; + /// State from metadata. + final String? state; + + /// Zip code from metadata. + final String? zipCode; + + /// Associated cost center ID. + final String? costCenterId; + + /// Associated cost center name. + final String? costCenterName; + + /// Serialises this [Hub] to a JSON map. + Map toJson() { + return { + 'hubId': hubId, + 'name': name, + 'fullAddress': fullAddress, + 'latitude': latitude, + 'longitude': longitude, + 'nfcTagId': nfcTagId, + 'city': city, + 'state': state, + 'zipCode': zipCode, + 'costCenterId': costCenterId, + 'costCenterName': costCenterName, + }; + } @override - List get props => [id, businessId, name, address, nfcTagId, status, costCenter]; + List get props => [ + hubId, + name, + fullAddress, + latitude, + longitude, + nfcTagId, + city, + state, + zipCode, + costCenterId, + costCenterName, + ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/business/hub_department.dart b/apps/mobile/packages/domain/lib/src/entities/business/hub_department.dart deleted file mode 100644 index 3c6891bc..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/business/hub_department.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents a department within a [Hub]. -/// -/// Used for more granular organization of staff and events (e.g. "Kitchen", "Service"). -class HubDepartment extends Equatable { - - const HubDepartment({ - required this.id, - required this.hubId, - required this.name, - }); - /// Unique identifier. - final String id; - - /// The [Hub] this department belongs to. - final String hubId; - - /// Name of the department. - final String name; - - @override - List get props => [id, hubId, name]; -} \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/src/entities/business/hub_manager.dart b/apps/mobile/packages/domain/lib/src/entities/business/hub_manager.dart new file mode 100644 index 00000000..d86f6cff --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/business/hub_manager.dart @@ -0,0 +1,54 @@ +import 'package:equatable/equatable.dart'; + +/// A manager assigned to a hub (clock point). +/// +/// Returned by `GET /client/hubs/:id/managers`. +class HubManager extends Equatable { + /// Creates a [HubManager] instance. + const HubManager({ + required this.managerAssignmentId, + required this.businessMembershipId, + required this.managerId, + required this.name, + }); + + /// Deserialises a [HubManager] from a V2 API JSON map. + factory HubManager.fromJson(Map json) { + return HubManager( + managerAssignmentId: json['managerAssignmentId'] as String, + businessMembershipId: json['businessMembershipId'] as String, + managerId: json['managerId'] as String, + name: json['name'] as String, + ); + } + + /// Primary key of the hub_managers row. + final String managerAssignmentId; + + /// Business membership ID of the manager. + final String businessMembershipId; + + /// User ID of the manager. + final String managerId; + + /// Display name of the manager. + final String name; + + /// Serialises this [HubManager] to a JSON map. + Map toJson() { + return { + 'managerAssignmentId': managerAssignmentId, + 'businessMembershipId': businessMembershipId, + 'managerId': managerId, + 'name': name, + }; + } + + @override + List get props => [ + managerAssignmentId, + businessMembershipId, + managerId, + name, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/business/team_member.dart b/apps/mobile/packages/domain/lib/src/entities/business/team_member.dart new file mode 100644 index 00000000..db0c0d0f --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/business/team_member.dart @@ -0,0 +1,61 @@ +import 'package:equatable/equatable.dart'; + +/// A member of a business team (business membership + user). +/// +/// Returned by `GET /client/team-members`. +class TeamMember extends Equatable { + /// Creates a [TeamMember] instance. + const TeamMember({ + required this.businessMembershipId, + required this.userId, + required this.name, + this.email, + this.role, + }); + + /// Deserialises a [TeamMember] from a V2 API JSON map. + factory TeamMember.fromJson(Map json) { + return TeamMember( + businessMembershipId: json['businessMembershipId'] as String, + userId: json['userId'] as String, + name: json['name'] as String, + email: json['email'] as String?, + role: json['role'] as String?, + ); + } + + /// Business membership primary key. + final String businessMembershipId; + + /// User ID. + final String userId; + + /// Display name. + final String name; + + /// Email address. + final String? email; + + /// Business role (owner, manager, member, viewer). + final String? role; + + /// Serialises this [TeamMember] to a JSON map. + Map toJson() { + return { + 'businessMembershipId': businessMembershipId, + 'userId': userId, + 'name': name, + 'email': email, + 'role': role, + }; + } + + @override + List get props => [ + businessMembershipId, + userId, + name, + email, + role, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/business/vendor.dart b/apps/mobile/packages/domain/lib/src/entities/business/vendor.dart index 19d8bf98..8397933e 100644 --- a/apps/mobile/packages/domain/lib/src/entities/business/vendor.dart +++ b/apps/mobile/packages/domain/lib/src/entities/business/vendor.dart @@ -1,15 +1,86 @@ import 'package:equatable/equatable.dart'; -/// Represents a staffing vendor. +import 'package:krow_domain/src/entities/enums/business_status.dart'; + +/// A staffing vendor that supplies workers to businesses. +/// +/// Maps to the V2 `vendors` table. class Vendor extends Equatable { - const Vendor({required this.id, required this.name, required this.rates}); + /// Creates a [Vendor] instance. + const Vendor({ + required this.id, + required this.tenantId, + required this.slug, + required this.companyName, + required this.status, + this.contactName, + this.contactEmail, + this.contactPhone, + }); + /// Deserialises a [Vendor] from a V2 API JSON map. + factory Vendor.fromJson(Map json) { + return Vendor( + id: json['id'] as String? ?? json['vendorId'] as String, + tenantId: json['tenantId'] as String? ?? '', + slug: json['slug'] as String? ?? '', + companyName: json['companyName'] as String? ?? + json['vendorName'] as String? ?? + '', + status: BusinessStatus.fromJson(json['status'] as String?), + contactName: json['contactName'] as String?, + contactEmail: json['contactEmail'] as String?, + contactPhone: json['contactPhone'] as String?, + ); + } + + /// Unique identifier. final String id; - final String name; - /// A map of role names to hourly rates. - final Map rates; + /// Tenant this vendor belongs to. + final String tenantId; + + /// URL-safe slug. + final String slug; + + /// Display name of the vendor company. + final String companyName; + + /// Current account status. + final BusinessStatus status; + + /// Primary contact name. + final String? contactName; + + /// Primary contact email. + final String? contactEmail; + + /// Primary contact phone. + final String? contactPhone; + + /// Serialises this [Vendor] to a JSON map. + Map toJson() { + return { + 'id': id, + 'tenantId': tenantId, + 'slug': slug, + 'companyName': companyName, + 'status': status.toJson(), + 'contactName': contactName, + 'contactEmail': contactEmail, + 'contactPhone': contactPhone, + }; + } @override - List get props => [id, name, rates]; + List get props => [ + id, + tenantId, + slug, + companyName, + status, + contactName, + contactEmail, + contactPhone, + ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/business/vendor_role.dart b/apps/mobile/packages/domain/lib/src/entities/business/vendor_role.dart new file mode 100644 index 00000000..f2b1c07c --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/business/vendor_role.dart @@ -0,0 +1,49 @@ +import 'package:equatable/equatable.dart'; + +/// A role available through a vendor with its billing rate. +/// +/// Returned by `GET /client/vendors/:id/roles`. +class VendorRole extends Equatable { + /// Creates a [VendorRole] instance. + const VendorRole({ + required this.roleId, + required this.roleCode, + required this.roleName, + required this.hourlyRateCents, + }); + + /// Deserialises a [VendorRole] from a V2 API JSON map. + factory VendorRole.fromJson(Map json) { + return VendorRole( + roleId: json['roleId'] as String, + roleCode: json['roleCode'] as String, + roleName: json['roleName'] as String, + hourlyRateCents: (json['hourlyRateCents'] as num).toInt(), + ); + } + + /// Unique identifier from the roles catalog. + final String roleId; + + /// Short code for the role (e.g. BARISTA). + final String roleCode; + + /// Human-readable role name. + final String roleName; + + /// Billing rate in cents per hour. + final int hourlyRateCents; + + /// Serialises this [VendorRole] to a JSON map. + Map toJson() { + return { + 'roleId': roleId, + 'roleCode': roleCode, + 'roleName': roleName, + 'hourlyRateCents': hourlyRateCents, + }; + } + + @override + List get props => [roleId, roleCode, roleName, hourlyRateCents]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/clock_in/attendance_status.dart b/apps/mobile/packages/domain/lib/src/entities/clock_in/attendance_status.dart index 3d6bc3e1..043179d7 100644 --- a/apps/mobile/packages/domain/lib/src/entities/clock_in/attendance_status.dart +++ b/apps/mobile/packages/domain/lib/src/entities/clock_in/attendance_status.dart @@ -1,27 +1,56 @@ import 'package:equatable/equatable.dart'; -/// Simple entity to hold attendance state -class AttendanceStatus extends Equatable { +import 'package:krow_domain/src/entities/enums/attendance_status_type.dart'; +/// Current clock-in / attendance status of the staff member. +/// +/// Returned by `GET /staff/clock-in/status`. When no open session exists +/// the API returns `attendanceStatus: 'NOT_CLOCKED_IN'` with null IDs. +class AttendanceStatus extends Equatable { + /// Creates an [AttendanceStatus]. const AttendanceStatus({ - this.isCheckedIn = false, - this.checkInTime, - this.checkOutTime, this.activeShiftId, - this.activeApplicationId, + required this.attendanceStatus, + this.clockInAt, }); - final bool isCheckedIn; - final DateTime? checkInTime; - final DateTime? checkOutTime; + + /// Deserialises from the V2 API JSON response. + factory AttendanceStatus.fromJson(Map json) { + return AttendanceStatus( + activeShiftId: json['activeShiftId'] as String?, + attendanceStatus: + AttendanceStatusType.fromJson(json['attendanceStatus'] as String?), + clockInAt: json['clockInAt'] != null + ? DateTime.parse(json['clockInAt'] as String) + : null, + ); + } + + /// The shift id of the currently active attendance session, if any. final String? activeShiftId; - final String? activeApplicationId; + + /// Attendance session status. + final AttendanceStatusType attendanceStatus; + + /// Timestamp of clock-in, if currently clocked in. + final DateTime? clockInAt; + + /// Whether the worker is currently clocked in. + bool get isClockedIn => attendanceStatus == AttendanceStatusType.open; + + /// Serialises to JSON. + Map toJson() { + return { + 'activeShiftId': activeShiftId, + 'attendanceStatus': attendanceStatus.toJson(), + 'clockInAt': clockInAt?.toIso8601String(), + }; + } @override List get props => [ - isCheckedIn, - checkInTime, - checkOutTime, activeShiftId, - activeApplicationId, + attendanceStatus, + clockInAt, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart new file mode 100644 index 00000000..88ee6ebc --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart @@ -0,0 +1,65 @@ +import 'package:equatable/equatable.dart'; + +import 'package:krow_domain/src/entities/enums/assignment_status.dart'; + +/// A worker assigned to a coverage shift. +/// +/// Nested within [ShiftWithWorkers]. +class AssignedWorker extends Equatable { + /// Creates an [AssignedWorker] instance. + const AssignedWorker({ + required this.assignmentId, + required this.staffId, + required this.fullName, + required this.status, + this.checkInAt, + }); + + /// Deserialises an [AssignedWorker] from a V2 API JSON map. + factory AssignedWorker.fromJson(Map json) { + return AssignedWorker( + assignmentId: json['assignmentId'] as String, + staffId: json['staffId'] as String, + fullName: json['fullName'] as String, + status: AssignmentStatus.fromJson(json['status'] as String?), + checkInAt: json['checkInAt'] != null + ? DateTime.parse(json['checkInAt'] as String) + : null, + ); + } + + /// Assignment ID. + final String assignmentId; + + /// Staff member ID. + final String staffId; + + /// Worker display name. + final String fullName; + + /// Assignment status. + final AssignmentStatus status; + + /// When the worker clocked in (null if not yet). + final DateTime? checkInAt; + + /// Serialises this [AssignedWorker] to a JSON map. + Map toJson() { + return { + 'assignmentId': assignmentId, + 'staffId': staffId, + 'fullName': fullName, + 'status': status.toJson(), + 'checkInAt': checkInAt?.toIso8601String(), + }; + } + + @override + List get props => [ + assignmentId, + staffId, + fullName, + status, + checkInAt, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/core_team_member.dart b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/core_team_member.dart new file mode 100644 index 00000000..760746e7 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/core_team_member.dart @@ -0,0 +1,68 @@ +import 'package:equatable/equatable.dart'; + +/// A staff member on the business's core team (favorites). +/// +/// Returned by `GET /client/coverage/core-team`. +class CoreTeamMember extends Equatable { + /// Creates a [CoreTeamMember] instance. + const CoreTeamMember({ + required this.staffId, + required this.fullName, + this.primaryRole, + required this.averageRating, + required this.ratingCount, + required this.favorite, + }); + + /// Deserialises a [CoreTeamMember] from a V2 API JSON map. + factory CoreTeamMember.fromJson(Map json) { + return CoreTeamMember( + staffId: json['staffId'] as String, + fullName: json['fullName'] as String, + primaryRole: json['primaryRole'] as String?, + averageRating: (json['averageRating'] as num).toDouble(), + ratingCount: (json['ratingCount'] as num).toInt(), + favorite: json['favorite'] as bool? ?? true, + ); + } + + /// Staff member ID. + final String staffId; + + /// Display name. + final String fullName; + + /// Primary role code. + final String? primaryRole; + + /// Average review rating (0-5). + final double averageRating; + + /// Total number of reviews. + final int ratingCount; + + /// Whether this staff is favorited by the business. + final bool favorite; + + /// Serialises this [CoreTeamMember] to a JSON map. + Map toJson() { + return { + 'staffId': staffId, + 'fullName': fullName, + 'primaryRole': primaryRole, + 'averageRating': averageRating, + 'ratingCount': ratingCount, + 'favorite': favorite, + }; + } + + @override + List get props => [ + staffId, + fullName, + primaryRole, + averageRating, + ratingCount, + favorite, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/coverage_shift.dart b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/coverage_shift.dart deleted file mode 100644 index afc10d60..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/coverage_shift.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'coverage_worker.dart'; - -/// Domain entity representing a shift in the coverage view. -/// -/// This is a feature-specific domain entity that encapsulates shift information -/// including scheduling details and assigned workers. -class CoverageShift extends Equatable { - /// Creates a [CoverageShift]. - const CoverageShift({ - required this.id, - required this.title, - required this.location, - required this.startTime, - required this.workersNeeded, - required this.date, - required this.workers, - }); - - /// The unique identifier for the shift. - final String id; - - /// The title or role of the shift. - final String title; - - /// The location where the shift takes place. - final String location; - - /// The start time of the shift (e.g., "16:00"). - final String startTime; - - /// The number of workers needed for this shift. - final int workersNeeded; - - /// The date of the shift. - final DateTime date; - - /// The list of workers assigned to this shift. - final List workers; - - /// Calculates the coverage percentage for this shift. - int get coveragePercent { - if (workersNeeded == 0) return 0; - return ((workers.length / workersNeeded) * 100).round(); - } - - @override - List get props => [ - id, - title, - location, - startTime, - workersNeeded, - date, - workers, - ]; -} diff --git a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/coverage_stats.dart b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/coverage_stats.dart index 580116a9..02e8cf60 100644 --- a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/coverage_stats.dart +++ b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/coverage_stats.dart @@ -1,45 +1,69 @@ import 'package:equatable/equatable.dart'; -/// Domain entity representing coverage statistics. +/// Aggregated coverage statistics for a specific date. /// -/// Aggregates coverage metrics for a specific date. +/// Returned by `GET /client/coverage/stats`. class CoverageStats extends Equatable { - /// Creates a [CoverageStats]. + /// Creates a [CoverageStats] instance. const CoverageStats({ - required this.totalNeeded, - required this.totalConfirmed, - required this.checkedIn, - required this.enRoute, - required this.late, + required this.totalPositionsNeeded, + required this.totalPositionsConfirmed, + required this.totalWorkersCheckedIn, + required this.totalWorkersEnRoute, + required this.totalWorkersLate, + required this.totalCoveragePercentage, }); - /// The total number of workers needed. - final int totalNeeded; + /// Deserialises a [CoverageStats] from a V2 API JSON map. + factory CoverageStats.fromJson(Map json) { + return CoverageStats( + totalPositionsNeeded: (json['totalPositionsNeeded'] as num).toInt(), + totalPositionsConfirmed: (json['totalPositionsConfirmed'] as num).toInt(), + totalWorkersCheckedIn: (json['totalWorkersCheckedIn'] as num).toInt(), + totalWorkersEnRoute: (json['totalWorkersEnRoute'] as num).toInt(), + totalWorkersLate: (json['totalWorkersLate'] as num).toInt(), + totalCoveragePercentage: + (json['totalCoveragePercentage'] as num).toInt(), + ); + } - /// The total number of confirmed workers. - final int totalConfirmed; + /// Total positions that need to be filled. + final int totalPositionsNeeded; - /// The number of workers who have checked in. - final int checkedIn; + /// Total positions that have been confirmed. + final int totalPositionsConfirmed; - /// The number of workers en route. - final int enRoute; + /// Workers who have checked in. + final int totalWorkersCheckedIn; - /// The number of late workers. - final int late; + /// Workers en route (accepted but not checked in). + final int totalWorkersEnRoute; - /// Calculates the overall coverage percentage. - int get coveragePercent { - if (totalNeeded == 0) return 0; - return ((totalConfirmed / totalNeeded) * 100).round(); + /// Workers marked as late / no-show. + final int totalWorkersLate; + + /// Overall coverage percentage (0-100). + final int totalCoveragePercentage; + + /// Serialises this [CoverageStats] to a JSON map. + Map toJson() { + return { + 'totalPositionsNeeded': totalPositionsNeeded, + 'totalPositionsConfirmed': totalPositionsConfirmed, + 'totalWorkersCheckedIn': totalWorkersCheckedIn, + 'totalWorkersEnRoute': totalWorkersEnRoute, + 'totalWorkersLate': totalWorkersLate, + 'totalCoveragePercentage': totalCoveragePercentage, + }; } @override List get props => [ - totalNeeded, - totalConfirmed, - checkedIn, - enRoute, - late, + totalPositionsNeeded, + totalPositionsConfirmed, + totalWorkersCheckedIn, + totalWorkersEnRoute, + totalWorkersLate, + totalCoveragePercentage, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/coverage_worker.dart b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/coverage_worker.dart deleted file mode 100644 index 3ade4d9d..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/coverage_worker.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Worker status enum matching ApplicationStatus from Data Connect. -enum CoverageWorkerStatus { - /// Application is pending approval. - pending, - - /// Application has been accepted. - accepted, - - /// Application has been rejected. - rejected, - - /// Worker has confirmed attendance. - confirmed, - - /// Worker has checked in. - checkedIn, - - /// Worker has checked out. - checkedOut, - - /// Worker is late. - late, - - /// Worker did not show up. - noShow, - - /// Shift is completed. - completed, -} - -/// Domain entity representing a worker in the coverage view. -/// -/// This entity tracks worker status including check-in information. -class CoverageWorker extends Equatable { - /// Creates a [CoverageWorker]. - const CoverageWorker({ - required this.name, - required this.status, - this.checkInTime, - }); - - /// The name of the worker. - final String name; - - /// The status of the worker. - final CoverageWorkerStatus status; - - /// The time the worker checked in, if applicable. - final String? checkInTime; - - @override - List get props => [name, status, checkInTime]; -} diff --git a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/shift_with_workers.dart b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/shift_with_workers.dart new file mode 100644 index 00000000..476334f8 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/shift_with_workers.dart @@ -0,0 +1,80 @@ +import 'package:equatable/equatable.dart'; + +import 'assigned_worker.dart'; +import 'time_range.dart'; + +/// A shift in the coverage view with its assigned workers. +/// +/// Returned by `GET /client/coverage`. +class ShiftWithWorkers extends Equatable { + /// Creates a [ShiftWithWorkers] instance. + const ShiftWithWorkers({ + required this.shiftId, + required this.roleName, + required this.timeRange, + required this.requiredWorkerCount, + required this.assignedWorkerCount, + this.assignedWorkers = const [], + }); + + /// Deserialises a [ShiftWithWorkers] from a V2 API JSON map. + factory ShiftWithWorkers.fromJson(Map json) { + final dynamic workersRaw = json['assignedWorkers']; + final List workersList = workersRaw is List + ? workersRaw + .map((dynamic e) => + AssignedWorker.fromJson(e as Map)) + .toList() + : const []; + + return ShiftWithWorkers( + shiftId: json['shiftId'] as String, + roleName: json['roleName'] as String? ?? '', + timeRange: TimeRange.fromJson(json['timeRange'] as Map), + requiredWorkerCount: (json['requiredWorkerCount'] as num).toInt(), + assignedWorkerCount: (json['assignedWorkerCount'] as num).toInt(), + assignedWorkers: workersList, + ); + } + + /// Shift ID. + final String shiftId; + + /// Role name for this shift. + final String roleName; + + /// Start and end time range. + final TimeRange timeRange; + + /// Total workers required. + final int requiredWorkerCount; + + /// Workers currently assigned. + final int assignedWorkerCount; + + /// List of assigned workers with their statuses. + final List assignedWorkers; + + /// Serialises this [ShiftWithWorkers] to a JSON map. + Map toJson() { + return { + 'shiftId': shiftId, + 'roleName': roleName, + 'timeRange': timeRange.toJson(), + 'requiredWorkerCount': requiredWorkerCount, + 'assignedWorkerCount': assignedWorkerCount, + 'assignedWorkers': + assignedWorkers.map((AssignedWorker w) => w.toJson()).toList(), + }; + } + + @override + List get props => [ + shiftId, + roleName, + timeRange, + requiredWorkerCount, + assignedWorkerCount, + assignedWorkers, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/time_range.dart b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/time_range.dart new file mode 100644 index 00000000..543deccd --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/time_range.dart @@ -0,0 +1,37 @@ +import 'package:equatable/equatable.dart'; + +/// A time range with start and end timestamps. +/// +/// Used within [ShiftWithWorkers] for shift time windows. +class TimeRange extends Equatable { + /// Creates a [TimeRange] instance. + const TimeRange({ + required this.startsAt, + required this.endsAt, + }); + + /// Deserialises a [TimeRange] from a V2 API JSON map. + factory TimeRange.fromJson(Map json) { + return TimeRange( + startsAt: DateTime.parse(json['startsAt'] as String), + endsAt: DateTime.parse(json['endsAt'] as String), + ); + } + + /// Start timestamp. + final DateTime startsAt; + + /// End timestamp. + final DateTime endsAt; + + /// Serialises this [TimeRange] to a JSON map. + Map toJson() { + return { + 'startsAt': startsAt.toIso8601String(), + 'endsAt': endsAt.toIso8601String(), + }; + } + + @override + List get props => [startsAt, endsAt]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/enums/account_type.dart b/apps/mobile/packages/domain/lib/src/entities/enums/account_type.dart new file mode 100644 index 00000000..088b9213 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/enums/account_type.dart @@ -0,0 +1,30 @@ +/// Type of bank account. +/// +/// Used by both staff bank accounts and client billing accounts. +enum AccountType { + /// Checking account. + checking('CHECKING'), + + /// Savings account. + savings('SAVINGS'), + + /// Fallback for unrecognised API values. + unknown('UNKNOWN'); + + const AccountType(this.value); + + /// The V2 API string representation. + final String value; + + /// Deserialises from a V2 API string with safe fallback. + static AccountType fromJson(String? value) { + if (value == null) return AccountType.checking; + for (final AccountType type in AccountType.values) { + if (type.value == value) return type; + } + return AccountType.unknown; + } + + /// Serialises to the V2 API string. + String toJson() => value; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/enums/application_status.dart b/apps/mobile/packages/domain/lib/src/entities/enums/application_status.dart new file mode 100644 index 00000000..7374e41f --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/enums/application_status.dart @@ -0,0 +1,48 @@ +/// Status of a worker's application to a shift. +/// +/// Maps to the `status` CHECK constraint in the V2 `applications` table. +enum ApplicationStatus { + /// Application submitted, awaiting review. + pending('PENDING'), + + /// Application confirmed / approved. + confirmed('CONFIRMED'), + + /// Worker has checked in for the shift. + checkedIn('CHECKED_IN'), + + /// Worker is late for check-in. + late_('LATE'), + + /// Worker did not show up. + noShow('NO_SHOW'), + + /// Application / attendance completed. + completed('COMPLETED'), + + /// Application rejected. + rejected('REJECTED'), + + /// Application cancelled. + cancelled('CANCELLED'), + + /// Fallback for unrecognised API values. + unknown('UNKNOWN'); + + const ApplicationStatus(this.value); + + /// The V2 API string representation. + final String value; + + /// Deserialises from a V2 API string with safe fallback. + static ApplicationStatus fromJson(String? value) { + if (value == null) return ApplicationStatus.unknown; + for (final ApplicationStatus status in ApplicationStatus.values) { + if (status.value == value) return status; + } + return ApplicationStatus.unknown; + } + + /// Serialises to the V2 API string. + String toJson() => value; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/enums/assignment_status.dart b/apps/mobile/packages/domain/lib/src/entities/enums/assignment_status.dart new file mode 100644 index 00000000..a446fe00 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/enums/assignment_status.dart @@ -0,0 +1,48 @@ +/// Status of a worker's assignment to a shift. +/// +/// Maps to the `status` CHECK constraint in the V2 `assignments` table. +enum AssignmentStatus { + /// Worker has been assigned but not yet accepted. + assigned('ASSIGNED'), + + /// Worker accepted the assignment. + accepted('ACCEPTED'), + + /// Worker requested to swap this assignment. + swapRequested('SWAP_REQUESTED'), + + /// Worker has checked in. + checkedIn('CHECKED_IN'), + + /// Worker has checked out. + checkedOut('CHECKED_OUT'), + + /// Assignment completed. + completed('COMPLETED'), + + /// Assignment cancelled. + cancelled('CANCELLED'), + + /// Worker did not show up. + noShow('NO_SHOW'), + + /// Fallback for unrecognised API values. + unknown('UNKNOWN'); + + const AssignmentStatus(this.value); + + /// The V2 API string representation. + final String value; + + /// Deserialises from a V2 API string with safe fallback. + static AssignmentStatus fromJson(String? value) { + if (value == null) return AssignmentStatus.unknown; + for (final AssignmentStatus status in AssignmentStatus.values) { + if (status.value == value) return status; + } + return AssignmentStatus.unknown; + } + + /// Serialises to the V2 API string. + String toJson() => value; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/enums/attendance_status_type.dart b/apps/mobile/packages/domain/lib/src/entities/enums/attendance_status_type.dart new file mode 100644 index 00000000..e339f797 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/enums/attendance_status_type.dart @@ -0,0 +1,36 @@ +/// Attendance session status for clock-in tracking. +/// +/// Maps to the `status` CHECK constraint in the V2 `attendance_events` table. +enum AttendanceStatusType { + /// Worker has not clocked in yet. + notClockedIn('NOT_CLOCKED_IN'), + + /// Attendance session is open (worker is clocked in). + open('OPEN'), + + /// Attendance session is closed (worker clocked out). + closed('CLOSED'), + + /// Attendance record is disputed. + disputed('DISPUTED'), + + /// Fallback for unrecognised API values. + unknown('UNKNOWN'); + + const AttendanceStatusType(this.value); + + /// The V2 API string representation. + final String value; + + /// Deserialises from a V2 API string with safe fallback. + static AttendanceStatusType fromJson(String? value) { + if (value == null) return AttendanceStatusType.notClockedIn; + for (final AttendanceStatusType status in AttendanceStatusType.values) { + if (status.value == value) return status; + } + return AttendanceStatusType.unknown; + } + + /// Serialises to the V2 API string. + String toJson() => value; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/enums/availability_status.dart b/apps/mobile/packages/domain/lib/src/entities/enums/availability_status.dart new file mode 100644 index 00000000..150f08c6 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/enums/availability_status.dart @@ -0,0 +1,34 @@ +/// Availability status for a calendar day. +/// +/// Used by the staff availability feature to indicate whether a worker +/// is available on a given date. +enum AvailabilityStatus { + /// Worker is available for the full day. + available('AVAILABLE'), + + /// Worker is not available. + unavailable('UNAVAILABLE'), + + /// Worker is available for partial time slots. + partial('PARTIAL'), + + /// Fallback for unrecognised API values. + unknown('UNKNOWN'); + + const AvailabilityStatus(this.value); + + /// The V2 API string representation. + final String value; + + /// Deserialises from a V2 API string with safe fallback. + static AvailabilityStatus fromJson(String? value) { + if (value == null) return AvailabilityStatus.unavailable; + for (final AvailabilityStatus status in AvailabilityStatus.values) { + if (status.value == value) return status; + } + return AvailabilityStatus.unknown; + } + + /// Serialises to the V2 API string. + String toJson() => value; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/enums/benefit_status.dart b/apps/mobile/packages/domain/lib/src/entities/enums/benefit_status.dart new file mode 100644 index 00000000..4483d475 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/enums/benefit_status.dart @@ -0,0 +1,34 @@ +/// Status of a staff benefit accrual. +/// +/// Used by the benefits feature to track whether a benefit is currently +/// active, paused, or pending activation. +enum BenefitStatus { + /// Benefit is active and accruing. + active('ACTIVE'), + + /// Benefit is inactive / paused. + inactive('INACTIVE'), + + /// Benefit is pending activation. + pending('PENDING'), + + /// Fallback for unrecognised API values. + unknown('UNKNOWN'); + + const BenefitStatus(this.value); + + /// The V2 API string representation. + final String value; + + /// Deserialises from a V2 API string with safe fallback. + static BenefitStatus fromJson(String? value) { + if (value == null) return BenefitStatus.unknown; + for (final BenefitStatus status in BenefitStatus.values) { + if (status.value == value) return status; + } + return BenefitStatus.unknown; + } + + /// Serialises to the V2 API string. + String toJson() => value; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/enums/business_status.dart b/apps/mobile/packages/domain/lib/src/entities/enums/business_status.dart new file mode 100644 index 00000000..721b999f --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/enums/business_status.dart @@ -0,0 +1,34 @@ +/// Account status of a business or vendor. +/// +/// Maps to the `status` CHECK constraint in the V2 `businesses` and +/// `vendors` tables. +enum BusinessStatus { + /// Account is active. + active('ACTIVE'), + + /// Account is inactive / suspended. + inactive('INACTIVE'), + + /// Account has been archived. + archived('ARCHIVED'), + + /// Fallback for unrecognised API values. + unknown('UNKNOWN'); + + const BusinessStatus(this.value); + + /// The V2 API string representation. + final String value; + + /// Deserialises from a V2 API string with safe fallback. + static BusinessStatus fromJson(String? value) { + if (value == null) return BusinessStatus.unknown; + for (final BusinessStatus status in BusinessStatus.values) { + if (status.value == value) return status; + } + return BusinessStatus.unknown; + } + + /// Serialises to the V2 API string. + String toJson() => value; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/enums/invoice_status.dart b/apps/mobile/packages/domain/lib/src/entities/enums/invoice_status.dart new file mode 100644 index 00000000..f592dfb3 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/enums/invoice_status.dart @@ -0,0 +1,48 @@ +/// Lifecycle status of an invoice. +/// +/// Maps to the `status` CHECK constraint in the V2 `invoices` table. +enum InvoiceStatus { + /// Invoice created but not yet sent. + draft('DRAFT'), + + /// Invoice sent, awaiting payment. + pending('PENDING'), + + /// Invoice under review. + pendingReview('PENDING_REVIEW'), + + /// Invoice approved for payment. + approved('APPROVED'), + + /// Invoice paid. + paid('PAID'), + + /// Invoice overdue. + overdue('OVERDUE'), + + /// Invoice disputed by the client. + disputed('DISPUTED'), + + /// Invoice voided / cancelled. + void_('VOID'), + + /// Fallback for unrecognised API values. + unknown('UNKNOWN'); + + const InvoiceStatus(this.value); + + /// The V2 API string representation. + final String value; + + /// Deserialises from a V2 API string with safe fallback. + static InvoiceStatus fromJson(String? value) { + if (value == null) return InvoiceStatus.unknown; + for (final InvoiceStatus status in InvoiceStatus.values) { + if (status.value == value) return status; + } + return InvoiceStatus.unknown; + } + + /// Serialises to the V2 API string. + String toJson() => value; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/enums/onboarding_status.dart b/apps/mobile/packages/domain/lib/src/entities/enums/onboarding_status.dart new file mode 100644 index 00000000..151add4a --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/enums/onboarding_status.dart @@ -0,0 +1,33 @@ +/// Onboarding progress status for a staff member. +/// +/// Maps to the `onboarding_status` CHECK constraint in the V2 `staffs` table. +enum OnboardingStatus { + /// Onboarding not yet started. + pending('PENDING'), + + /// Onboarding in progress. + inProgress('IN_PROGRESS'), + + /// Onboarding completed. + completed('COMPLETED'), + + /// Fallback for unrecognised API values. + unknown('UNKNOWN'); + + const OnboardingStatus(this.value); + + /// The V2 API string representation. + final String value; + + /// Deserialises from a V2 API string with safe fallback. + static OnboardingStatus fromJson(String? value) { + if (value == null) return OnboardingStatus.unknown; + for (final OnboardingStatus status in OnboardingStatus.values) { + if (status.value == value) return status; + } + return OnboardingStatus.unknown; + } + + /// Serialises to the V2 API string. + String toJson() => value; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/enums/order_type.dart b/apps/mobile/packages/domain/lib/src/entities/enums/order_type.dart new file mode 100644 index 00000000..ca3cc1bc --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/enums/order_type.dart @@ -0,0 +1,36 @@ +/// Type of order placed by a business client. +/// +/// Maps to the `order_type` CHECK constraint in the V2 `orders` table. +enum OrderType { + /// A single occurrence order. + oneTime('ONE_TIME'), + + /// A recurring/repeating order. + recurring('RECURRING'), + + /// A permanent/ongoing order. + permanent('PERMANENT'), + + /// A rapid-fill order. + rapid('RAPID'), + + /// Fallback for unrecognised API values. + unknown('UNKNOWN'); + + const OrderType(this.value); + + /// The V2 API string representation. + final String value; + + /// Deserialises from a V2 API string with safe fallback. + static OrderType fromJson(String? value) { + if (value == null) return OrderType.unknown; + for (final OrderType type in OrderType.values) { + if (type.value == value) return type; + } + return OrderType.unknown; + } + + /// Serialises to the V2 API string. + String toJson() => value; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/enums/payment_status.dart b/apps/mobile/packages/domain/lib/src/entities/enums/payment_status.dart new file mode 100644 index 00000000..4c3446b7 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/enums/payment_status.dart @@ -0,0 +1,36 @@ +/// Payment processing status. +/// +/// Maps to the `payment_status` CHECK constraint in the V2 schema. +enum PaymentStatus { + /// Payment not yet processed. + pending('PENDING'), + + /// Payment is being processed. + processing('PROCESSING'), + + /// Payment completed successfully. + paid('PAID'), + + /// Payment processing failed. + failed('FAILED'), + + /// Fallback for unrecognised API values. + unknown('UNKNOWN'); + + const PaymentStatus(this.value); + + /// The V2 API string representation. + final String value; + + /// Deserialises from a V2 API string with safe fallback. + static PaymentStatus fromJson(String? value) { + if (value == null) return PaymentStatus.unknown; + for (final PaymentStatus status in PaymentStatus.values) { + if (status.value == value) return status; + } + return PaymentStatus.unknown; + } + + /// Serialises to the V2 API string. + String toJson() => value; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/enums/shift_status.dart b/apps/mobile/packages/domain/lib/src/entities/enums/shift_status.dart new file mode 100644 index 00000000..0288ad7a --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/enums/shift_status.dart @@ -0,0 +1,45 @@ +/// Lifecycle status of a shift. +/// +/// Maps to the `status` CHECK constraint in the V2 `shifts` table. +enum ShiftStatus { + /// Shift created but not yet published. + draft('DRAFT'), + + /// Open for applications. + open('OPEN'), + + /// Awaiting worker confirmation. + pendingConfirmation('PENDING_CONFIRMATION'), + + /// All roles filled and confirmed. + assigned('ASSIGNED'), + + /// Currently in progress. + active('ACTIVE'), + + /// Shift finished. + completed('COMPLETED'), + + /// Shift cancelled. + cancelled('CANCELLED'), + + /// Fallback for unrecognised API values. + unknown('UNKNOWN'); + + const ShiftStatus(this.value); + + /// The V2 API string representation. + final String value; + + /// Deserialises from a V2 API string with safe fallback. + static ShiftStatus fromJson(String? value) { + if (value == null) return ShiftStatus.unknown; + for (final ShiftStatus status in ShiftStatus.values) { + if (status.value == value) return status; + } + return ShiftStatus.unknown; + } + + /// Serialises to the V2 API string. + String toJson() => value; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/enums/staff_status.dart b/apps/mobile/packages/domain/lib/src/entities/enums/staff_status.dart new file mode 100644 index 00000000..78667065 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/enums/staff_status.dart @@ -0,0 +1,36 @@ +/// Account status of a staff member. +/// +/// Maps to the `status` CHECK constraint in the V2 `staffs` table. +enum StaffStatus { + /// Staff account is active. + active('ACTIVE'), + + /// Staff has been invited but not yet onboarded. + invited('INVITED'), + + /// Staff account is inactive / suspended. + inactive('INACTIVE'), + + /// Staff account has been blocked. + blocked('BLOCKED'), + + /// Fallback for unrecognised API values. + unknown('UNKNOWN'); + + const StaffStatus(this.value); + + /// The V2 API string representation. + final String value; + + /// Deserialises from a V2 API string with safe fallback. + static StaffStatus fromJson(String? value) { + if (value == null) return StaffStatus.unknown; + for (final StaffStatus status in StaffStatus.values) { + if (status.value == value) return status; + } + return StaffStatus.unknown; + } + + /// Serialises to the V2 API string. + String toJson() => value; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/events/assignment.dart b/apps/mobile/packages/domain/lib/src/entities/events/assignment.dart deleted file mode 100644 index 197281a5..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/events/assignment.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// The status of a staff [Assignment]. -enum AssignmentStatus { - /// Staff member has been assigned but hasn't confirmed. - assigned, - - /// Staff member has accepted the assignment. - confirmed, - - /// Work is currently in progress (Clocked In). - ongoing, - - /// Work completed successfully (Clocked Out). - completed, - - /// Staff rejected the assignment offer. - declinedByStaff, - - /// Staff canceled after accepting. - canceledByStaff, - - /// Staff did not show up. - noShowed, -} - -/// Represents the link between a [Staff] member and an [EventShiftPosition]. -class Assignment extends Equatable { - - const Assignment({ - required this.id, - required this.positionId, - required this.staffId, - required this.status, - this.clockIn, - this.clockOut, - }); - /// Unique identifier. - final String id; - - /// The job position being filled. - final String positionId; - - /// The staff member filling the position. - final String staffId; - - /// Current status of the assignment. - final AssignmentStatus status; - - /// Actual timestamp when staff clocked in. - final DateTime? clockIn; - - /// Actual timestamp when staff clocked out. - final DateTime? clockOut; - - @override - List get props => [id, positionId, staffId, status, clockIn, clockOut]; -} \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/src/entities/events/event.dart b/apps/mobile/packages/domain/lib/src/entities/events/event.dart deleted file mode 100644 index d7def36f..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/events/event.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// The workflow status of an [Event]. -enum EventStatus { - /// Created but incomplete. - draft, - - /// Waiting for approval or publication. - pending, - - /// Published and staff have been assigned. - assigned, - - /// Fully confirmed and ready to start. - confirmed, - - /// Currently in progress. - active, - - /// Work has finished. - finished, - - /// All post-event processes (invoicing) complete. - completed, - - /// Archived. - closed, - - /// Flagged for administrative review. - underReview, -} - -/// Represents a Job Posting or Event. -/// -/// This is the central entity for scheduling work. An Event contains [EventShift]s. -class Event extends Equatable { - - const Event({ - required this.id, - required this.businessId, - required this.hubId, - required this.name, - required this.date, - required this.status, - required this.contractType, - }); - /// Unique identifier. - final String id; - - /// The [Business] hosting the event. - final String businessId; - - /// The [Hub] location. - final String hubId; - - /// Title of the event. - final String name; - - /// Date of the event. - final DateTime date; - - /// Current workflow status. - final EventStatus status; - - /// Type of employment contract (e.g., 'freelance', 'permanent'). - final String contractType; - - @override - List get props => [id, businessId, hubId, name, date, status, contractType]; -} \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/src/entities/events/event_shift.dart b/apps/mobile/packages/domain/lib/src/entities/events/event_shift.dart deleted file mode 100644 index 32a025e3..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/events/event_shift.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents a specific time block or "shift" within an [Event]. -/// -/// An Event can have multiple shifts (e.g. "Morning Shift", "Evening Shift"). -class EventShift extends Equatable { - - const EventShift({ - required this.id, - required this.eventId, - required this.name, - required this.address, - }); - /// Unique identifier. - final String id; - - /// The [Event] this shift belongs to. - final String eventId; - - /// Descriptive name (e.g. "Setup Crew"). - final String name; - - /// Specific address for this shift (if different from Hub). - final String address; - - @override - List get props => [id, eventId, name, address]; -} \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/src/entities/events/event_shift_position.dart b/apps/mobile/packages/domain/lib/src/entities/events/event_shift_position.dart deleted file mode 100644 index 8eb226f0..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/events/event_shift_position.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents a specific job opening within a [EventShift]. -/// -/// Defines the requirement for a specific [Skill], the quantity needed, and the pay. -class EventShiftPosition extends Equatable { - - const EventShiftPosition({ - required this.id, - required this.shiftId, - required this.skillId, - required this.count, - required this.rate, - required this.startTime, - required this.endTime, - required this.breakDurationMinutes, - }); - /// Unique identifier. - final String id; - - /// The [EventShift] this position is part of. - final String shiftId; - - /// The [Skill] required for this position. - final String skillId; - - /// Number of staff needed. - final int count; - - /// Hourly pay rate. - final double rate; - - /// Start time of this specific position. - final DateTime startTime; - - /// End time of this specific position. - final DateTime endTime; - - /// Deducted break duration in minutes. - final int breakDurationMinutes; - - @override - List get props => [ - id, - shiftId, - skillId, - count, - rate, - startTime, - endTime, - breakDurationMinutes, - ]; -} \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/src/entities/events/work_session.dart b/apps/mobile/packages/domain/lib/src/entities/events/work_session.dart deleted file mode 100644 index ef06a323..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/events/work_session.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents a verified record of time worked. -/// -/// Derived from [Assignment] clock-in/out times, used for payroll. -class WorkSession extends Equatable { - - const WorkSession({ - required this.id, - required this.assignmentId, - required this.startTime, - this.endTime, - required this.breakDurationMinutes, - }); - /// Unique identifier. - final String id; - - /// The [Assignment] this session belongs to. - final String assignmentId; - - /// Verified start time. - final DateTime startTime; - - /// Verified end time. - final DateTime? endTime; - - /// Verified break duration. - final int breakDurationMinutes; - - @override - List get props => [id, assignmentId, startTime, endTime, breakDurationMinutes]; -} \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/bank_account.dart b/apps/mobile/packages/domain/lib/src/entities/financial/bank_account.dart new file mode 100644 index 00000000..bde621a4 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/financial/bank_account.dart @@ -0,0 +1,70 @@ +import 'package:equatable/equatable.dart'; + +import 'package:krow_domain/src/entities/enums/account_type.dart'; + +/// A bank account belonging to a staff member. +/// +/// Returned by `GET /staff/profile/bank-accounts`. +class BankAccount extends Equatable { + /// Creates a [BankAccount] instance. + const BankAccount({ + required this.accountId, + required this.bankName, + required this.providerReference, + this.last4, + required this.isPrimary, + required this.accountType, + }); + + /// Deserialises a [BankAccount] from a V2 API JSON map. + factory BankAccount.fromJson(Map json) { + return BankAccount( + accountId: json['accountId'] as String, + bankName: json['bankName'] as String, + providerReference: json['providerReference'] as String, + last4: json['last4'] as String?, + isPrimary: json['isPrimary'] as bool, + accountType: AccountType.fromJson(json['accountType'] as String?), + ); + } + + /// Unique identifier. + final String accountId; + + /// Name of the bank / payment provider. + final String bankName; + + /// External provider reference. + final String providerReference; + + /// Last 4 digits of the account number. + final String? last4; + + /// Whether this is the primary payout account. + final bool isPrimary; + + /// Account type. + final AccountType accountType; + + /// Serialises this [BankAccount] to a JSON map. + Map toJson() { + return { + 'accountId': accountId, + 'bankName': bankName, + 'providerReference': providerReference, + 'last4': last4, + 'isPrimary': isPrimary, + 'accountType': accountType.toJson(), + }; + } + + @override + List get props => [ + accountId, + bankName, + providerReference, + last4, + isPrimary, + accountType, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/bank_account.dart b/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/bank_account.dart deleted file mode 100644 index 04af8402..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/bank_account.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Abstract base class for all types of bank accounts. -abstract class BankAccount extends Equatable { - /// Creates a [BankAccount]. - const BankAccount({ - required this.id, - required this.bankName, - required this.isPrimary, - this.last4, - }); - - /// Unique identifier. - final String id; - - /// Name of the bank or provider. - final String bankName; - - /// Whether this is the primary payment method. - final bool isPrimary; - - /// Last 4 digits of the account/card. - final String? last4; - - @override - List get props => [id, bankName, isPrimary, last4]; -} diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/business_bank_account.dart b/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/business_bank_account.dart deleted file mode 100644 index 8ad3d48e..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/business_bank_account.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'bank_account.dart'; - -/// Domain model representing a business bank account or payment method. -class BusinessBankAccount extends BankAccount { - /// Creates a [BusinessBankAccount]. - const BusinessBankAccount({ - required super.id, - required super.bankName, - required String last4, - required super.isPrimary, - this.expiryTime, - }) : super(last4: last4); - - /// Expiration date if applicable. - final DateTime? expiryTime; - - @override - List get props => [ - ...super.props, - expiryTime, - ]; - - /// Getter for non-nullable last4 in Business context. - @override - String get last4 => super.last4!; -} diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/staff_bank_account.dart b/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/staff_bank_account.dart deleted file mode 100644 index 3f2f034e..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/staff_bank_account.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'bank_account.dart'; - -/// Type of staff bank account. -enum StaffBankAccountType { - /// Checking account. - checking, - - /// Savings account. - savings, - - /// Other type. - other, -} - -/// Domain entity representing a staff's bank account. -class StaffBankAccount extends BankAccount { - /// Creates a [StaffBankAccount]. - const StaffBankAccount({ - required super.id, - required this.userId, - required super.bankName, - required this.accountNumber, - required this.accountName, - required super.isPrimary, - super.last4, - this.sortCode, - this.type = StaffBankAccountType.checking, - }); - - /// User identifier. - final String userId; - - /// Full account number. - final String accountNumber; - - /// Name of the account holder. - final String accountName; - - /// Sort code (optional). - final String? sortCode; - - /// Account type. - final StaffBankAccountType type; - - @override - List get props => - [...super.props, userId, accountNumber, accountName, sortCode, type]; -} diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/billing_account.dart b/apps/mobile/packages/domain/lib/src/entities/financial/billing_account.dart new file mode 100644 index 00000000..738c0c4c --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/financial/billing_account.dart @@ -0,0 +1,77 @@ +import 'package:equatable/equatable.dart'; + +import 'package:krow_domain/src/entities/enums/account_type.dart'; + +/// A billing/bank account belonging to a business. +/// +/// Returned by `GET /client/billing/accounts`. +class BillingAccount extends Equatable { + /// Creates a [BillingAccount] instance. + const BillingAccount({ + required this.accountId, + required this.bankName, + required this.providerReference, + this.last4, + required this.isPrimary, + required this.accountType, + this.routingNumberMasked, + }); + + /// Deserialises a [BillingAccount] from a V2 API JSON map. + factory BillingAccount.fromJson(Map json) { + return BillingAccount( + accountId: json['accountId'] as String, + bankName: json['bankName'] as String, + providerReference: json['providerReference'] as String, + last4: json['last4'] as String?, + isPrimary: json['isPrimary'] as bool, + accountType: AccountType.fromJson(json['accountType'] as String?), + routingNumberMasked: json['routingNumberMasked'] as String?, + ); + } + + /// Unique identifier. + final String accountId; + + /// Name of the bank / payment provider. + final String bankName; + + /// External provider reference (e.g. Stripe account ID). + final String providerReference; + + /// Last 4 digits of the account number. + final String? last4; + + /// Whether this is the primary billing account. + final bool isPrimary; + + /// Account type. + final AccountType accountType; + + /// Masked routing number. + final String? routingNumberMasked; + + /// Serialises this [BillingAccount] to a JSON map. + Map toJson() { + return { + 'accountId': accountId, + 'bankName': bankName, + 'providerReference': providerReference, + 'last4': last4, + 'isPrimary': isPrimary, + 'accountType': accountType.toJson(), + 'routingNumberMasked': routingNumberMasked, + }; + } + + @override + List get props => [ + accountId, + bankName, + providerReference, + last4, + isPrimary, + accountType, + routingNumberMasked, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/billing_period.dart b/apps/mobile/packages/domain/lib/src/entities/financial/billing_period.dart deleted file mode 100644 index c26a4108..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/financial/billing_period.dart +++ /dev/null @@ -1,8 +0,0 @@ -/// Defines the period for billing calculations. -enum BillingPeriod { - /// Weekly billing period. - week, - - /// Monthly billing period. - month, -} diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/current_bill.dart b/apps/mobile/packages/domain/lib/src/entities/financial/current_bill.dart new file mode 100644 index 00000000..b7722e81 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/financial/current_bill.dart @@ -0,0 +1,29 @@ +import 'package:equatable/equatable.dart'; + +/// The current outstanding bill for a business. +/// +/// Returned by `GET /client/billing/current-bill`. +class CurrentBill extends Equatable { + /// Creates a [CurrentBill] instance. + const CurrentBill({required this.currentBillCents}); + + /// Deserialises a [CurrentBill] from a V2 API JSON map. + factory CurrentBill.fromJson(Map json) { + return CurrentBill( + currentBillCents: (json['currentBillCents'] as num).toInt(), + ); + } + + /// Outstanding bill amount in cents. + final int currentBillCents; + + /// Serialises this [CurrentBill] to a JSON map. + Map toJson() { + return { + 'currentBillCents': currentBillCents, + }; + } + + @override + List get props => [currentBillCents]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/invoice.dart b/apps/mobile/packages/domain/lib/src/entities/financial/invoice.dart index 64341884..7d370fd3 100644 --- a/apps/mobile/packages/domain/lib/src/entities/financial/invoice.dart +++ b/apps/mobile/packages/domain/lib/src/entities/financial/invoice.dart @@ -1,148 +1,88 @@ import 'package:equatable/equatable.dart'; -/// The workflow status of an [Invoice]. -enum InvoiceStatus { - /// Generated but not yet sent/finalized. - open, +import 'package:krow_domain/src/entities/enums/invoice_status.dart'; - /// Client has disputed a line item. - disputed, - - /// Dispute has been handled. - resolved, - - /// Invoice accepted by client. - verified, - - /// Payment received. - paid, - - /// Payment reconciled in accounting. - reconciled, - - /// Payment not received by due date. - overdue, -} - -/// Represents a bill sent to a [Business] for services rendered. +/// An invoice issued to a business for services rendered. +/// +/// Returned by `GET /client/billing/invoices/*`. class Invoice extends Equatable { - + /// Creates an [Invoice] instance. const Invoice({ - required this.id, - required this.eventId, - required this.businessId, + required this.invoiceId, + required this.invoiceNumber, + required this.amountCents, required this.status, - required this.totalAmount, - required this.workAmount, - required this.addonsAmount, - this.invoiceNumber, - this.issueDate, - this.title, - this.clientName, - this.locationAddress, - this.staffCount, - this.totalHours, - this.workers = const [], + this.dueDate, + this.paymentDate, + this.vendorId, + this.vendorName, }); + + /// Deserialises an [Invoice] from a V2 API JSON map. + factory Invoice.fromJson(Map json) { + return Invoice( + invoiceId: json['invoiceId'] as String, + invoiceNumber: json['invoiceNumber'] as String, + amountCents: (json['amountCents'] as num).toInt(), + status: InvoiceStatus.fromJson(json['status'] as String?), + dueDate: json['dueDate'] != null + ? DateTime.parse(json['dueDate'] as String) + : null, + paymentDate: json['paymentDate'] != null + ? DateTime.parse(json['paymentDate'] as String) + : null, + vendorId: json['vendorId'] as String?, + vendorName: json['vendorName'] as String?, + ); + } + /// Unique identifier. - final String id; - - /// The [Event] this invoice covers. - final String eventId; - - /// The [Business] being billed. - final String businessId; - - /// Current payment/approval status. - final InvoiceStatus status; - - /// Grand total amount. - final double totalAmount; - - /// Total amount for labor costs. - final double workAmount; - - /// Total amount for addons/extras. - final double addonsAmount; + final String invoiceId; /// Human-readable invoice number. - final String? invoiceNumber; + final String invoiceNumber; - /// Date when the invoice was issued. - final DateTime? issueDate; + /// Total amount in cents. + final int amountCents; - /// Human-readable title (e.g. event name). - final String? title; + /// Current invoice lifecycle status. + final InvoiceStatus status; - /// Name of the client business. - final String? clientName; + /// When payment is due. + final DateTime? dueDate; - /// Address of the event/location. - final String? locationAddress; + /// When the invoice was paid (history endpoint). + final DateTime? paymentDate; - /// Number of staff worked. - final int? staffCount; + /// Vendor ID associated with this invoice. + final String? vendorId; - /// Total hours worked. - final double? totalHours; + /// Vendor company name. + final String? vendorName; - /// List of workers associated with this invoice. - final List workers; + /// Serialises this [Invoice] to a JSON map. + Map toJson() { + return { + 'invoiceId': invoiceId, + 'invoiceNumber': invoiceNumber, + 'amountCents': amountCents, + 'status': status.toJson(), + 'dueDate': dueDate?.toIso8601String(), + 'paymentDate': paymentDate?.toIso8601String(), + 'vendorId': vendorId, + 'vendorName': vendorName, + }; + } @override List get props => [ - id, - eventId, - businessId, - status, - totalAmount, - workAmount, - addonsAmount, + invoiceId, invoiceNumber, - issueDate, - title, - clientName, - locationAddress, - staffCount, - totalHours, - workers, - ]; -} - -/// Represents a worker entry in an [Invoice]. -class InvoiceWorker extends Equatable { - const InvoiceWorker({ - required this.name, - required this.role, - required this.amount, - required this.hours, - required this.rate, - this.checkIn, - this.checkOut, - this.breakMinutes = 0, - this.avatarUrl, - }); - - final String name; - final String role; - final double amount; - final double hours; - final double rate; - final DateTime? checkIn; - final DateTime? checkOut; - final int breakMinutes; - final String? avatarUrl; - - @override - List get props => [ - name, - role, - amount, - hours, - rate, - checkIn, - checkOut, - breakMinutes, - avatarUrl, + amountCents, + status, + dueDate, + paymentDate, + vendorId, + vendorName, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/invoice_decline.dart b/apps/mobile/packages/domain/lib/src/entities/financial/invoice_decline.dart deleted file mode 100644 index 1d0a8035..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/financial/invoice_decline.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents a reason or log for a declined [Invoice]. -class InvoiceDecline extends Equatable { - - const InvoiceDecline({ - required this.id, - required this.invoiceId, - required this.reason, - required this.declinedAt, - }); - /// Unique identifier. - final String id; - - /// The [Invoice] that was declined. - final String invoiceId; - - /// Reason provided by the client. - final String reason; - - /// When the decline happened. - final DateTime declinedAt; - - @override - List get props => [id, invoiceId, reason, declinedAt]; -} \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/invoice_item.dart b/apps/mobile/packages/domain/lib/src/entities/financial/invoice_item.dart deleted file mode 100644 index e661334a..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/financial/invoice_item.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents a line item in an [Invoice]. -/// -/// Corresponds to the work done by one [Staff] member. -class InvoiceItem extends Equatable { - - const InvoiceItem({ - required this.id, - required this.invoiceId, - required this.staffId, - required this.workHours, - required this.rate, - required this.amount, - }); - /// Unique identifier. - final String id; - - /// The [Invoice] this item belongs to. - final String invoiceId; - - /// The [Staff] member whose work is being billed. - final String staffId; - - /// Total billed hours. - final double workHours; - - /// Hourly rate applied. - final double rate; - - /// Total line item amount (workHours * rate). - final double amount; - - @override - List get props => [id, invoiceId, staffId, workHours, rate, amount]; -} \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/payment_chart_point.dart b/apps/mobile/packages/domain/lib/src/entities/financial/payment_chart_point.dart new file mode 100644 index 00000000..2e9f92f0 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/financial/payment_chart_point.dart @@ -0,0 +1,37 @@ +import 'package:equatable/equatable.dart'; + +/// A single data point in the staff payment chart. +/// +/// Returned by `GET /staff/payments/chart`. +class PaymentChartPoint extends Equatable { + /// Creates a [PaymentChartPoint] instance. + const PaymentChartPoint({ + required this.bucket, + required this.amountCents, + }); + + /// Deserialises a [PaymentChartPoint] from a V2 API JSON map. + factory PaymentChartPoint.fromJson(Map json) { + return PaymentChartPoint( + bucket: DateTime.parse(json['bucket'] as String), + amountCents: (json['amountCents'] as num).toInt(), + ); + } + + /// Time bucket start (day, week, or month). + final DateTime bucket; + + /// Aggregated payment amount in cents for this bucket. + final int amountCents; + + /// Serialises this [PaymentChartPoint] to a JSON map. + Map toJson() { + return { + 'bucket': bucket.toIso8601String(), + 'amountCents': amountCents, + }; + } + + @override + List get props => [bucket, amountCents]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/payment_summary.dart b/apps/mobile/packages/domain/lib/src/entities/financial/payment_summary.dart index 5a905853..1d090b5a 100644 --- a/apps/mobile/packages/domain/lib/src/entities/financial/payment_summary.dart +++ b/apps/mobile/packages/domain/lib/src/entities/financial/payment_summary.dart @@ -1,24 +1,29 @@ import 'package:equatable/equatable.dart'; -/// Summary of staff earnings. +/// Aggregated payment summary for a staff member over a date range. +/// +/// Returned by `GET /staff/payments/summary`. class PaymentSummary extends Equatable { + /// Creates a [PaymentSummary] instance. + const PaymentSummary({required this.totalEarningsCents}); - const PaymentSummary({ - required this.weeklyEarnings, - required this.monthlyEarnings, - required this.pendingEarnings, - required this.totalEarnings, - }); - final double weeklyEarnings; - final double monthlyEarnings; - final double pendingEarnings; - final double totalEarnings; + /// Deserialises a [PaymentSummary] from a V2 API JSON map. + factory PaymentSummary.fromJson(Map json) { + return PaymentSummary( + totalEarningsCents: (json['totalEarningsCents'] as num).toInt(), + ); + } + + /// Total earnings in cents for the queried period. + final int totalEarningsCents; + + /// Serialises this [PaymentSummary] to a JSON map. + Map toJson() { + return { + 'totalEarningsCents': totalEarningsCents, + }; + } @override - List get props => [ - weeklyEarnings, - monthlyEarnings, - pendingEarnings, - totalEarnings, - ]; + List get props => [totalEarningsCents]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/savings.dart b/apps/mobile/packages/domain/lib/src/entities/financial/savings.dart new file mode 100644 index 00000000..66126d71 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/financial/savings.dart @@ -0,0 +1,29 @@ +import 'package:equatable/equatable.dart'; + +/// Accumulated savings for a business. +/// +/// Returned by `GET /client/billing/savings`. +class Savings extends Equatable { + /// Creates a [Savings] instance. + const Savings({required this.savingsCents}); + + /// Deserialises a [Savings] from a V2 API JSON map. + factory Savings.fromJson(Map json) { + return Savings( + savingsCents: (json['savingsCents'] as num).toInt(), + ); + } + + /// Total savings amount in cents. + final int savingsCents; + + /// Serialises this [Savings] to a JSON map. + Map toJson() { + return { + 'savingsCents': savingsCents, + }; + } + + @override + List get props => [savingsCents]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/spend_item.dart b/apps/mobile/packages/domain/lib/src/entities/financial/spend_item.dart new file mode 100644 index 00000000..be8f9cf1 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/financial/spend_item.dart @@ -0,0 +1,43 @@ +import 'package:equatable/equatable.dart'; + +/// A single category in the spend breakdown. +/// +/// Returned by `GET /client/billing/spend-breakdown`. +class SpendItem extends Equatable { + /// Creates a [SpendItem] instance. + const SpendItem({ + required this.category, + required this.amountCents, + required this.percentage, + }); + + /// Deserialises a [SpendItem] from a V2 API JSON map. + factory SpendItem.fromJson(Map json) { + return SpendItem( + category: json['category'] as String, + amountCents: (json['amountCents'] as num).toInt(), + percentage: (json['percentage'] as num).toDouble(), + ); + } + + /// Role/category name. + final String category; + + /// Total spend in cents for this category. + final int amountCents; + + /// Percentage of total spend. + final double percentage; + + /// Serialises this [SpendItem] to a JSON map. + Map toJson() { + return { + 'category': category, + 'amountCents': amountCents, + 'percentage': percentage, + }; + } + + @override + List get props => [category, amountCents, percentage]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/staff_payment.dart b/apps/mobile/packages/domain/lib/src/entities/financial/staff_payment.dart index 75cd8d8e..3df1b383 100644 --- a/apps/mobile/packages/domain/lib/src/entities/financial/staff_payment.dart +++ b/apps/mobile/packages/domain/lib/src/entities/financial/staff_payment.dart @@ -1,76 +1,88 @@ import 'package:equatable/equatable.dart'; -/// Status of a staff payout. -enum PaymentStatus { - /// Payout calculated but not processed. - pending, +import 'package:krow_domain/src/entities/enums/payment_status.dart'; - /// Submitted to banking provider. - processing, - - /// Successfully transferred to staff. - paid, - - /// Transfer failed. - failed, - - /// Status unknown. - unknown, -} - -/// Represents a payout to a [Staff] member for a completed [Assignment]. -class StaffPayment extends Equatable { - - const StaffPayment({ - required this.id, - required this.staffId, - required this.assignmentId, - required this.amount, +/// A single payment record for a staff member. +/// +/// Returned by `GET /staff/payments/history`. +class PaymentRecord extends Equatable { + /// Creates a [PaymentRecord] instance. + const PaymentRecord({ + required this.paymentId, + required this.amountCents, + required this.date, required this.status, - this.paidAt, - this.shiftTitle, - this.shiftLocation, - this.locationAddress, - this.hoursWorked, - this.hourlyRate, - this.workedTime, + this.shiftName, + this.location, + this.hourlyRateCents, + this.minutesWorked, }); + + /// Deserialises a [PaymentRecord] from a V2 API JSON map. + factory PaymentRecord.fromJson(Map json) { + return PaymentRecord( + paymentId: json['paymentId'] as String, + amountCents: (json['amountCents'] as num).toInt(), + date: DateTime.parse(json['date'] as String), + status: PaymentStatus.fromJson(json['status'] as String?), + shiftName: json['shiftName'] as String?, + location: json['location'] as String?, + hourlyRateCents: json['hourlyRateCents'] != null + ? (json['hourlyRateCents'] as num).toInt() + : null, + minutesWorked: json['minutesWorked'] != null + ? (json['minutesWorked'] as num).toInt() + : null, + ); + } + /// Unique identifier. - final String id; + final String paymentId; - /// The recipient [Staff]. - final String staffId; + /// Payment amount in cents. + final int amountCents; - /// The [Assignment] being paid for. - final String assignmentId; + /// Date the payment was processed or created. + final DateTime date; - /// Amount to be paid. - final double amount; - - /// Processing status. + /// Payment processing status. final PaymentStatus status; - /// When the payment was successfully processed. - final DateTime? paidAt; + /// Title of the associated shift. + final String? shiftName; - /// Title of the shift worked. - final String? shiftTitle; + /// Location/hub name. + final String? location; - /// Location/hub name of the shift. - final String? shiftLocation; + /// Hourly pay rate in cents. + final int? hourlyRateCents; - /// Address of the shift location. - final String? locationAddress; + /// Total minutes worked for this payment. + final int? minutesWorked; - /// Number of hours worked. - final double? hoursWorked; - - /// Hourly rate for the shift. - final double? hourlyRate; - - /// Work session duration or status. - final String? workedTime; + /// Serialises this [PaymentRecord] to a JSON map. + Map toJson() { + return { + 'paymentId': paymentId, + 'amountCents': amountCents, + 'date': date.toIso8601String(), + 'status': status.toJson(), + 'shiftName': shiftName, + 'location': location, + 'hourlyRateCents': hourlyRateCents, + 'minutesWorked': minutesWorked, + }; + } @override - List get props => [id, staffId, assignmentId, amount, status, paidAt, shiftTitle, shiftLocation, locationAddress, hoursWorked, hourlyRate, workedTime]; -} \ No newline at end of file + List get props => [ + paymentId, + amountCents, + date, + status, + shiftName, + location, + hourlyRateCents, + minutesWorked, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/time_card.dart b/apps/mobile/packages/domain/lib/src/entities/financial/time_card.dart index bb70cdd7..a5d459ec 100644 --- a/apps/mobile/packages/domain/lib/src/entities/financial/time_card.dart +++ b/apps/mobile/packages/domain/lib/src/entities/financial/time_card.dart @@ -1,78 +1,88 @@ import 'package:equatable/equatable.dart'; -/// Status of a time card. -enum TimeCardStatus { - /// Waiting for approval or payment. - pending, - /// Approved by manager. - approved, - /// Payment has been issued. - paid, - /// Disputed by staff or client. - disputed; - - /// Whether the card is approved. - bool get isApproved => this == TimeCardStatus.approved; - /// Whether the card is paid. - bool get isPaid => this == TimeCardStatus.paid; - /// Whether the card is disputed. - bool get isDisputed => this == TimeCardStatus.disputed; - /// Whether the card is pending. - bool get isPending => this == TimeCardStatus.pending; -} - -/// Represents a time card for a staff member. -class TimeCard extends Equatable { - - /// Creates a [TimeCard]. - const TimeCard({ - required this.id, - required this.shiftTitle, - required this.clientName, +/// A single time-card entry for a completed shift. +/// +/// Returned by `GET /staff/profile/time-card`. +class TimeCardEntry extends Equatable { + /// Creates a [TimeCardEntry] instance. + const TimeCardEntry({ required this.date, - required this.startTime, - required this.endTime, - required this.totalHours, - required this.hourlyRate, - required this.totalPay, - required this.status, + required this.shiftName, this.location, + this.clockInAt, + this.clockOutAt, + required this.minutesWorked, + this.hourlyRateCents, + required this.totalPayCents, }); - /// Unique identifier of the time card (often matches Application ID). - final String id; - /// Title of the shift. - final String shiftTitle; - /// Name of the client business. - final String clientName; + + /// Deserialises a [TimeCardEntry] from a V2 API JSON map. + factory TimeCardEntry.fromJson(Map json) { + return TimeCardEntry( + date: DateTime.parse(json['date'] as String), + shiftName: json['shiftName'] as String, + location: json['location'] as String?, + clockInAt: json['clockInAt'] != null + ? DateTime.parse(json['clockInAt'] as String) + : null, + clockOutAt: json['clockOutAt'] != null + ? DateTime.parse(json['clockOutAt'] as String) + : null, + minutesWorked: (json['minutesWorked'] as num).toInt(), + hourlyRateCents: json['hourlyRateCents'] != null + ? (json['hourlyRateCents'] as num).toInt() + : null, + totalPayCents: (json['totalPayCents'] as num).toInt(), + ); + } + /// Date of the shift. final DateTime date; - /// Actual or scheduled start time. - final String startTime; - /// Actual or scheduled end time. - final String endTime; - /// Total hours worked. - final double totalHours; - /// Hourly pay rate. - final double hourlyRate; - /// Total pay amount. - final double totalPay; - /// Current status of the time card. - final TimeCardStatus status; - /// Location name. + + /// Title of the shift. + final String shiftName; + + /// Location/hub name. final String? location; + /// Clock-in timestamp. + final DateTime? clockInAt; + + /// Clock-out timestamp. + final DateTime? clockOutAt; + + /// Total minutes worked (regular + overtime). + final int minutesWorked; + + /// Hourly pay rate in cents. + final int? hourlyRateCents; + + /// Gross pay in cents. + final int totalPayCents; + + /// Serialises this [TimeCardEntry] to a JSON map. + Map toJson() { + return { + 'date': date.toIso8601String(), + 'shiftName': shiftName, + 'location': location, + 'clockInAt': clockInAt?.toIso8601String(), + 'clockOutAt': clockOutAt?.toIso8601String(), + 'minutesWorked': minutesWorked, + 'hourlyRateCents': hourlyRateCents, + 'totalPayCents': totalPayCents, + }; + } + @override List get props => [ - id, - shiftTitle, - clientName, date, - startTime, - endTime, - totalHours, - hourlyRate, - totalPay, - status, + shiftName, location, + clockInAt, + clockOutAt, + minutesWorked, + hourlyRateCents, + totalPayCents, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/home/client_dashboard.dart b/apps/mobile/packages/domain/lib/src/entities/home/client_dashboard.dart new file mode 100644 index 00000000..94376019 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/home/client_dashboard.dart @@ -0,0 +1,75 @@ +import 'package:equatable/equatable.dart'; + +import 'coverage_metrics.dart'; +import 'live_activity_metrics.dart'; +import 'spending_summary.dart'; + +/// Client dashboard data aggregating key business metrics. +/// +/// Returned by `GET /client/dashboard`. +class ClientDashboard extends Equatable { + /// Creates a [ClientDashboard] instance. + const ClientDashboard({ + required this.userName, + required this.businessName, + required this.businessId, + required this.spending, + required this.coverage, + required this.liveActivity, + }); + + /// Deserialises a [ClientDashboard] from a V2 API JSON map. + factory ClientDashboard.fromJson(Map json) { + return ClientDashboard( + userName: json['userName'] as String, + businessName: json['businessName'] as String, + businessId: json['businessId'] as String, + spending: + SpendingSummary.fromJson(json['spending'] as Map), + coverage: + CoverageMetrics.fromJson(json['coverage'] as Map), + liveActivity: LiveActivityMetrics.fromJson( + json['liveActivity'] as Map), + ); + } + + /// Display name of the logged-in user. + final String userName; + + /// Name of the business. + final String businessName; + + /// Business ID. + final String businessId; + + /// Spending summary. + final SpendingSummary spending; + + /// Today's coverage metrics. + final CoverageMetrics coverage; + + /// Live activity metrics. + final LiveActivityMetrics liveActivity; + + /// Serialises this [ClientDashboard] to a JSON map. + Map toJson() { + return { + 'userName': userName, + 'businessName': businessName, + 'businessId': businessId, + 'spending': spending.toJson(), + 'coverage': coverage.toJson(), + 'liveActivity': liveActivity.toJson(), + }; + } + + @override + List get props => [ + userName, + businessName, + businessId, + spending, + coverage, + liveActivity, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/home/coverage_metrics.dart b/apps/mobile/packages/domain/lib/src/entities/home/coverage_metrics.dart new file mode 100644 index 00000000..43357b2d --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/home/coverage_metrics.dart @@ -0,0 +1,42 @@ +import 'package:equatable/equatable.dart'; + +/// Today's coverage metrics nested in [ClientDashboard]. +class CoverageMetrics extends Equatable { + /// Creates a [CoverageMetrics] instance. + const CoverageMetrics({ + required this.neededWorkersToday, + required this.filledWorkersToday, + required this.openPositionsToday, + }); + + /// Deserialises a [CoverageMetrics] from a V2 API JSON map. + factory CoverageMetrics.fromJson(Map json) { + return CoverageMetrics( + neededWorkersToday: (json['neededWorkersToday'] as num).toInt(), + filledWorkersToday: (json['filledWorkersToday'] as num).toInt(), + openPositionsToday: (json['openPositionsToday'] as num).toInt(), + ); + } + + /// Workers needed today. + final int neededWorkersToday; + + /// Workers filled today. + final int filledWorkersToday; + + /// Open (unfilled) positions today. + final int openPositionsToday; + + /// Serialises this [CoverageMetrics] to a JSON map. + Map toJson() { + return { + 'neededWorkersToday': neededWorkersToday, + 'filledWorkersToday': filledWorkersToday, + 'openPositionsToday': openPositionsToday, + }; + } + + @override + List get props => + [neededWorkersToday, filledWorkersToday, openPositionsToday]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/home/home_dashboard_data.dart b/apps/mobile/packages/domain/lib/src/entities/home/home_dashboard_data.dart deleted file mode 100644 index 681e7a22..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/home/home_dashboard_data.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Entity representing dashboard data for the home screen. -/// -/// This entity provides aggregated metrics such as spending and shift counts -/// for both the current week and the upcoming 7 days. -class HomeDashboardData extends Equatable { - - /// Creates a [HomeDashboardData] instance. - const HomeDashboardData({ - required this.weeklySpending, - required this.next7DaysSpending, - required this.weeklyShifts, - required this.next7DaysScheduled, - required this.totalNeeded, - required this.totalFilled, - }); - /// Total spending for the current week. - final double weeklySpending; - - /// Projected spending for the next 7 days. - final double next7DaysSpending; - - /// Total shifts scheduled for the current week. - final int weeklyShifts; - - /// Shifts scheduled for the next 7 days. - final int next7DaysScheduled; - - /// Total workers needed for today's shifts. - final int totalNeeded; - - /// Total workers filled for today's shifts. - final int totalFilled; - - @override - List get props => [ - weeklySpending, - next7DaysSpending, - weeklyShifts, - next7DaysScheduled, - totalNeeded, - totalFilled, - ]; -} diff --git a/apps/mobile/packages/domain/lib/src/entities/home/live_activity_metrics.dart b/apps/mobile/packages/domain/lib/src/entities/home/live_activity_metrics.dart new file mode 100644 index 00000000..2f04b637 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/home/live_activity_metrics.dart @@ -0,0 +1,43 @@ +import 'package:equatable/equatable.dart'; + +/// Live activity metrics nested in [ClientDashboard]. +class LiveActivityMetrics extends Equatable { + /// Creates a [LiveActivityMetrics] instance. + const LiveActivityMetrics({ + required this.lateWorkersToday, + required this.checkedInWorkersToday, + required this.averageShiftCostCents, + }); + + /// Deserialises a [LiveActivityMetrics] from a V2 API JSON map. + factory LiveActivityMetrics.fromJson(Map json) { + return LiveActivityMetrics( + lateWorkersToday: (json['lateWorkersToday'] as num).toInt(), + checkedInWorkersToday: (json['checkedInWorkersToday'] as num).toInt(), + averageShiftCostCents: + (json['averageShiftCostCents'] as num).toInt(), + ); + } + + /// Workers marked late/no-show today. + final int lateWorkersToday; + + /// Workers who have checked in today. + final int checkedInWorkersToday; + + /// Average shift cost in cents. + final int averageShiftCostCents; + + /// Serialises this [LiveActivityMetrics] to a JSON map. + Map toJson() { + return { + 'lateWorkersToday': lateWorkersToday, + 'checkedInWorkersToday': checkedInWorkersToday, + 'averageShiftCostCents': averageShiftCostCents, + }; + } + + @override + List get props => + [lateWorkersToday, checkedInWorkersToday, averageShiftCostCents]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/home/reorder_item.dart b/apps/mobile/packages/domain/lib/src/entities/home/reorder_item.dart deleted file mode 100644 index d13a80bd..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/home/reorder_item.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Summary of a completed order used for reorder suggestions. -class ReorderItem extends Equatable { - const ReorderItem({ - required this.orderId, - required this.title, - required this.location, - required this.totalCost, - required this.workers, - required this.type, - this.hourlyRate = 0, - this.hours = 0, - }); - - /// Unique identifier of the order. - final String orderId; - - /// Display title of the order (e.g., event name or first shift title). - final String title; - - /// Location of the order (e.g., first shift location). - final String location; - - /// Total calculated cost for the order. - final double totalCost; - - /// Total number of workers required for the order. - final int workers; - - /// The type of order (e.g., ONE_TIME, RECURRING). - final String type; - - /// Average or primary hourly rate (optional, for display). - final double hourlyRate; - - /// Total hours for the order (optional, for display). - final double hours; - - @override - List get props => [ - orderId, - title, - location, - totalCost, - workers, - type, - hourlyRate, - hours, - ]; -} diff --git a/apps/mobile/packages/domain/lib/src/entities/home/spending_summary.dart b/apps/mobile/packages/domain/lib/src/entities/home/spending_summary.dart new file mode 100644 index 00000000..407e8578 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/home/spending_summary.dart @@ -0,0 +1,37 @@ +import 'package:equatable/equatable.dart'; + +/// Spending summary nested in [ClientDashboard]. +class SpendingSummary extends Equatable { + /// Creates a [SpendingSummary] instance. + const SpendingSummary({ + required this.weeklySpendCents, + required this.projectedNext7DaysCents, + }); + + /// Deserialises a [SpendingSummary] from a V2 API JSON map. + factory SpendingSummary.fromJson(Map json) { + return SpendingSummary( + weeklySpendCents: (json['weeklySpendCents'] as num).toInt(), + projectedNext7DaysCents: + (json['projectedNext7DaysCents'] as num).toInt(), + ); + } + + /// Total spend this week in cents. + final int weeklySpendCents; + + /// Projected spend for the next 7 days in cents. + final int projectedNext7DaysCents; + + /// Serialises this [SpendingSummary] to a JSON map. + Map toJson() { + return { + 'weeklySpendCents': weeklySpendCents, + 'projectedNext7DaysCents': projectedNext7DaysCents, + }; + } + + @override + List get props => + [weeklySpendCents, projectedNext7DaysCents]; +} 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 new file mode 100644 index 00000000..c299a3ac --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/home/staff_dashboard.dart @@ -0,0 +1,80 @@ +import 'package:equatable/equatable.dart'; + +import '../benefits/benefit.dart'; + +/// Staff dashboard data with shifts and benefits overview. +/// +/// Returned by `GET /staff/dashboard`. +class StaffDashboard extends Equatable { + /// Creates a [StaffDashboard] instance. + const StaffDashboard({ + required this.staffName, + this.todaysShifts = const >[], + this.tomorrowsShifts = const >[], + this.recommendedShifts = const >[], + this.benefits = const [], + }); + + /// Deserialises a [StaffDashboard] from a V2 API JSON map. + factory StaffDashboard.fromJson(Map json) { + final dynamic benefitsRaw = json['benefits']; + final List benefitsList = benefitsRaw is List + ? benefitsRaw + .map((dynamic e) => Benefit.fromJson(e as Map)) + .toList() + : const []; + + return StaffDashboard( + staffName: json['staffName'] as String, + todaysShifts: _castShiftList(json['todaysShifts']), + tomorrowsShifts: _castShiftList(json['tomorrowsShifts']), + recommendedShifts: _castShiftList(json['recommendedShifts']), + benefits: benefitsList, + ); + } + + /// Display name of the staff member. + final String staffName; + + /// Shifts assigned for today. + final List> todaysShifts; + + /// Shifts assigned for tomorrow. + final List> tomorrowsShifts; + + /// Recommended open shifts. + final List> recommendedShifts; + + /// Active benefits. + final List benefits; + + /// Serialises this [StaffDashboard] to a JSON map. + Map toJson() { + return { + 'staffName': staffName, + 'todaysShifts': todaysShifts, + 'tomorrowsShifts': tomorrowsShifts, + 'recommendedShifts': recommendedShifts, + 'benefits': benefits.map((Benefit b) => b.toJson()).toList(), + }; + } + + static List> _castShiftList(dynamic raw) { + if (raw is List) { + return raw + .map((dynamic e) => + Map.from(e as Map)) + .toList(); + } + return const >[]; + } + + @override + List get props => [ + staffName, + todaysShifts, + tomorrowsShifts, + recommendedShifts, + benefits, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/assigned_worker_summary.dart b/apps/mobile/packages/domain/lib/src/entities/orders/assigned_worker_summary.dart new file mode 100644 index 00000000..38fbf730 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/assigned_worker_summary.dart @@ -0,0 +1,58 @@ +import 'package:equatable/equatable.dart'; + +import 'package:krow_domain/src/entities/enums/application_status.dart'; + +/// Summary of a worker assigned to an order line item. +/// +/// Nested within [OrderItem]. +class AssignedWorkerSummary extends Equatable { + /// Creates an [AssignedWorkerSummary] instance. + const AssignedWorkerSummary({ + this.applicationId, + this.workerName, + this.role, + this.confirmationStatus, + }); + + /// Deserialises an [AssignedWorkerSummary] from a V2 API JSON map. + factory AssignedWorkerSummary.fromJson(Map json) { + return AssignedWorkerSummary( + applicationId: json['applicationId'] as String?, + workerName: json['workerName'] as String?, + role: json['role'] as String?, + confirmationStatus: json['confirmationStatus'] != null + ? ApplicationStatus.fromJson(json['confirmationStatus'] as String?) + : null, + ); + } + + /// Application ID for this worker assignment. + final String? applicationId; + + /// Display name of the worker. + final String? workerName; + + /// Role the worker is assigned to. + final String? role; + + /// Confirmation status of the assignment. + final ApplicationStatus? confirmationStatus; + + /// Serialises this [AssignedWorkerSummary] to a JSON map. + Map toJson() { + return { + 'applicationId': applicationId, + 'workerName': workerName, + 'role': role, + 'confirmationStatus': confirmationStatus?.toJson(), + }; + } + + @override + List get props => [ + applicationId, + workerName, + role, + confirmationStatus, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/one_time_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/one_time_order.dart deleted file mode 100644 index fe50bd20..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/orders/one_time_order.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'one_time_order_position.dart'; - -/// Represents a customer's request for a single event or shift. -/// -/// Encapsulates the date, primary location, and a list of specific [OneTimeOrderPosition] requirements. -class OneTimeOrder extends Equatable { - - const OneTimeOrder({ - required this.date, - required this.location, - required this.positions, - this.hub, - this.eventName, - this.vendorId, - this.hubManagerId, - this.roleRates = const {}, - }); - /// The specific date for the shift or event. - final DateTime date; - - /// The primary location where the work will take place. - final String location; - - /// The list of positions and headcounts required for this order. - final List positions; - - /// Selected hub details for this order. - final OneTimeOrderHubDetails? hub; - - /// Optional order name. - final String? eventName; - - /// Selected vendor id for this order. - final String? vendorId; - - /// Optional hub manager id. - final String? hubManagerId; - - /// Role hourly rates keyed by role id. - final Map roleRates; - - @override - List get props => [ - date, - location, - positions, - hub, - eventName, - vendorId, - hubManagerId, - roleRates, - ]; -} - -/// Minimal hub details used during order creation. -class OneTimeOrderHubDetails extends Equatable { - const OneTimeOrderHubDetails({ - required this.id, - required this.name, - required this.address, - this.placeId, - this.latitude, - this.longitude, - this.city, - this.state, - this.street, - this.country, - this.zipCode, - }); - - final String id; - final String name; - final String address; - final String? placeId; - final double? latitude; - final double? longitude; - final String? city; - final String? state; - final String? street; - final String? country; - final String? zipCode; - - @override - List get props => [ - id, - name, - address, - placeId, - latitude, - longitude, - city, - state, - street, - country, - zipCode, - ]; -} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/one_time_order_position.dart b/apps/mobile/packages/domain/lib/src/entities/orders/one_time_order_position.dart deleted file mode 100644 index 8e9776be..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/orders/one_time_order_position.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents a specific position requirement within a [OneTimeOrder]. -/// -/// Defines the role, headcount, and scheduling details for a single staffing requirement. -class OneTimeOrderPosition extends Equatable { - - const OneTimeOrderPosition({ - required this.role, - required this.count, - required this.startTime, - required this.endTime, - this.lunchBreak = 'NO_BREAK', - this.location, - }); - /// The job role or title required. - final String role; - - /// The number of workers required for this position. - final int count; - - /// The scheduled start time (e.g., "09:00 AM"). - final String startTime; - - /// The scheduled end time (e.g., "05:00 PM"). - final String endTime; - - /// The break duration enum value (e.g., NO_BREAK, MIN_15, MIN_30). - final String lunchBreak; - - /// Optional specific location for this position, if different from the order's main location. - final String? location; - - @override - List get props => [ - role, - count, - startTime, - endTime, - lunchBreak, - location, - ]; - - /// Creates a copy of this position with the given fields replaced. - OneTimeOrderPosition copyWith({ - String? role, - int? count, - String? startTime, - String? endTime, - String? lunchBreak, - String? location, - }) { - return OneTimeOrderPosition( - role: role ?? this.role, - count: count ?? this.count, - startTime: startTime ?? this.startTime, - endTime: endTime ?? this.endTime, - lunchBreak: lunchBreak ?? this.lunchBreak, - location: location ?? this.location, - ); - } -} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart b/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart index 88ae8091..473495ca 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart @@ -1,117 +1,137 @@ import 'package:equatable/equatable.dart'; -import 'order_type.dart'; +import 'package:krow_domain/src/entities/enums/order_type.dart'; +import 'package:krow_domain/src/entities/enums/shift_status.dart'; -/// Represents a customer's view of an order or shift. +import 'assigned_worker_summary.dart'; + +/// A line item within an order, representing a role needed for a shift. /// -/// This entity captures the details necessary for the dashboard/view orders screen, -/// including status and worker assignments. +/// Returned by `GET /client/orders/view`. class OrderItem extends Equatable { - /// Creates an [OrderItem]. + /// Creates an [OrderItem] instance. const OrderItem({ - required this.id, + required this.itemId, required this.orderId, required this.orderType, - required this.title, - required this.clientName, - required this.status, + required this.roleName, required this.date, - required this.startTime, - required this.endTime, - required this.location, - required this.locationAddress, - required this.filled, - required this.workersNeeded, - required this.hourlyRate, - required this.eventName, - this.hours = 0, - this.totalValue = 0, - this.confirmedApps = const >[], - this.hubManagerId, - this.hubManagerName, + required this.startsAt, + required this.endsAt, + required this.requiredWorkerCount, + required this.filledCount, + required this.hourlyRateCents, + required this.totalCostCents, + this.locationName, + required this.status, + this.workers = const [], }); - /// Unique identifier of the order. - final String id; + /// Deserialises an [OrderItem] from a V2 API JSON map. + factory OrderItem.fromJson(Map json) { + final dynamic workersRaw = json['workers']; + final List workersList = workersRaw is List + ? workersRaw + .map((dynamic e) => AssignedWorkerSummary.fromJson( + e as Map)) + .toList() + : const []; - /// Parent order identifier. + return OrderItem( + itemId: json['itemId'] as String, + orderId: json['orderId'] as String, + orderType: OrderType.fromJson(json['orderType'] as String?), + roleName: json['roleName'] as String, + date: DateTime.parse(json['date'] as String), + startsAt: DateTime.parse(json['startsAt'] as String), + endsAt: DateTime.parse(json['endsAt'] as String), + requiredWorkerCount: (json['requiredWorkerCount'] as num).toInt(), + filledCount: (json['filledCount'] as num).toInt(), + hourlyRateCents: (json['hourlyRateCents'] as num).toInt(), + totalCostCents: (json['totalCostCents'] as num).toInt(), + locationName: json['locationName'] as String?, + status: ShiftStatus.fromJson(json['status'] as String?), + workers: workersList, + ); + } + + /// Shift-role ID (primary key). + final String itemId; + + /// Parent order ID. final String orderId; - /// The type of order (e.g., ONE_TIME, PERMANENT). + /// Order type (ONE_TIME, RECURRING, PERMANENT, RAPID). final OrderType orderType; - /// Title or name of the role. - final String title; + /// Name of the role. + final String roleName; - /// Name of the client company. - final String clientName; + /// Shift date. + final DateTime date; - /// status of the order (e.g., 'open', 'filled', 'completed'). - final String status; + /// Shift start time. + final DateTime startsAt; - /// Date of the shift (ISO format). - final String date; + /// Shift end time. + final DateTime endsAt; - /// Start time of the shift. - final String startTime; + /// Total workers required. + final int requiredWorkerCount; - /// End time of the shift. - final String endTime; + /// Workers currently assigned/filled. + final int filledCount; - /// Location name. - final String location; + /// Billing rate in cents per hour. + final int hourlyRateCents; - /// Full address of the location. - final String locationAddress; + /// Total cost in cents. + final int totalCostCents; - /// Number of workers currently filled. - final int filled; + /// Location/hub name. + final String? locationName; - /// Total number of workers required. - final int workersNeeded; + /// Shift status. + final ShiftStatus status; - /// Hourly pay rate. - final double hourlyRate; + /// Assigned workers for this line item. + final List workers; - /// Total hours for the shift role. - final double hours; - - /// Total value for the shift role. - final double totalValue; - - /// Name of the event. - final String eventName; - - /// List of confirmed worker applications. - final List> confirmedApps; - - /// Optional ID of the assigned hub manager. - final String? hubManagerId; - - /// Optional Name of the assigned hub manager. - final String? hubManagerName; + /// Serialises this [OrderItem] to a JSON map. + Map toJson() { + return { + 'itemId': itemId, + 'orderId': orderId, + 'orderType': orderType.toJson(), + 'roleName': roleName, + 'date': date.toIso8601String(), + 'startsAt': startsAt.toIso8601String(), + 'endsAt': endsAt.toIso8601String(), + 'requiredWorkerCount': requiredWorkerCount, + 'filledCount': filledCount, + 'hourlyRateCents': hourlyRateCents, + 'totalCostCents': totalCostCents, + 'locationName': locationName, + 'status': status.toJson(), + 'workers': workers.map((AssignedWorkerSummary w) => w.toJson()).toList(), + }; + } @override List get props => [ - id, - orderId, - orderType, - title, - clientName, - status, - date, - startTime, - endTime, - location, - locationAddress, - filled, - workersNeeded, - hourlyRate, - hours, - totalValue, - eventName, - confirmedApps, - hubManagerId, - hubManagerName, - ]; + itemId, + orderId, + orderType, + roleName, + date, + startsAt, + endsAt, + requiredWorkerCount, + filledCount, + hourlyRateCents, + totalCostCents, + locationName, + status, + workers, + ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/order_preview.dart b/apps/mobile/packages/domain/lib/src/entities/orders/order_preview.dart new file mode 100644 index 00000000..3bc41648 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/order_preview.dart @@ -0,0 +1,235 @@ +import 'package:equatable/equatable.dart'; + +/// A preview of an order for reordering purposes. +/// +/// Returned by `GET /client/orders/:id/reorder-preview`. +class OrderPreview extends Equatable { + /// Creates an [OrderPreview] instance. + const OrderPreview({ + required this.orderId, + required this.title, + this.description, + this.startsAt, + this.endsAt, + this.locationName, + this.locationAddress, + this.metadata = const {}, + this.shifts = const [], + }); + + /// Deserialises an [OrderPreview] from a V2 API JSON map. + factory OrderPreview.fromJson(Map json) { + final dynamic shiftsRaw = json['shifts']; + final List shiftsList = shiftsRaw is List + ? shiftsRaw + .map((dynamic e) => + OrderPreviewShift.fromJson(e as Map)) + .toList() + : const []; + + return OrderPreview( + orderId: json['orderId'] as String, + title: json['title'] as String, + description: json['description'] as String?, + startsAt: json['startsAt'] != null + ? DateTime.parse(json['startsAt'] as String) + : null, + endsAt: json['endsAt'] != null + ? DateTime.parse(json['endsAt'] as String) + : null, + locationName: json['locationName'] as String?, + locationAddress: json['locationAddress'] as String?, + metadata: json['metadata'] is Map + ? Map.from(json['metadata'] as Map) + : const {}, + shifts: shiftsList, + ); + } + + /// Order ID. + final String orderId; + + /// Order title. + final String title; + + /// Order description. + final String? description; + + /// Order start time. + final DateTime? startsAt; + + /// Order end time. + final DateTime? endsAt; + + /// Location name. + final String? locationName; + + /// Location address. + final String? locationAddress; + + /// Flexible metadata bag. + final Map metadata; + + /// Shifts with their roles from the original order. + final List shifts; + + /// Serialises this [OrderPreview] to a JSON map. + Map toJson() { + return { + 'orderId': orderId, + 'title': title, + 'description': description, + 'startsAt': startsAt?.toIso8601String(), + 'endsAt': endsAt?.toIso8601String(), + 'locationName': locationName, + 'locationAddress': locationAddress, + 'metadata': metadata, + 'shifts': shifts.map((OrderPreviewShift s) => s.toJson()).toList(), + }; + } + + @override + List get props => [ + orderId, + title, + description, + startsAt, + endsAt, + locationName, + locationAddress, + metadata, + shifts, + ]; +} + +/// A shift within a reorder preview. +class OrderPreviewShift extends Equatable { + /// Creates an [OrderPreviewShift] instance. + const OrderPreviewShift({ + required this.shiftId, + required this.shiftCode, + required this.title, + required this.startsAt, + required this.endsAt, + this.roles = const [], + }); + + /// Deserialises an [OrderPreviewShift] from a V2 API JSON map. + factory OrderPreviewShift.fromJson(Map json) { + final dynamic rolesRaw = json['roles']; + final List rolesList = rolesRaw is List + ? rolesRaw + .map((dynamic e) => + OrderPreviewRole.fromJson(e as Map)) + .toList() + : const []; + + return OrderPreviewShift( + shiftId: json['shiftId'] as String, + shiftCode: json['shiftCode'] as String, + title: json['title'] as String, + startsAt: DateTime.parse(json['startsAt'] as String), + endsAt: DateTime.parse(json['endsAt'] as String), + roles: rolesList, + ); + } + + /// Shift ID. + final String shiftId; + + /// Shift code. + final String shiftCode; + + /// Shift title. + final String title; + + /// Shift start time. + final DateTime startsAt; + + /// Shift end time. + final DateTime endsAt; + + /// Roles in this shift. + final List roles; + + /// Serialises this [OrderPreviewShift] to a JSON map. + Map toJson() { + return { + 'shiftId': shiftId, + 'shiftCode': shiftCode, + 'title': title, + 'startsAt': startsAt.toIso8601String(), + 'endsAt': endsAt.toIso8601String(), + 'roles': roles.map((OrderPreviewRole r) => r.toJson()).toList(), + }; + } + + @override + List get props => + [shiftId, shiftCode, title, startsAt, endsAt, roles]; +} + +/// A role within a reorder preview shift. +class OrderPreviewRole extends Equatable { + /// Creates an [OrderPreviewRole] instance. + const OrderPreviewRole({ + required this.roleId, + required this.roleCode, + required this.roleName, + required this.workersNeeded, + required this.payRateCents, + required this.billRateCents, + }); + + /// Deserialises an [OrderPreviewRole] from a V2 API JSON map. + factory OrderPreviewRole.fromJson(Map json) { + return OrderPreviewRole( + roleId: json['roleId'] as String, + roleCode: json['roleCode'] as String, + roleName: json['roleName'] as String, + workersNeeded: (json['workersNeeded'] as num).toInt(), + payRateCents: (json['payRateCents'] as num).toInt(), + billRateCents: (json['billRateCents'] as num).toInt(), + ); + } + + /// Role ID. + final String roleId; + + /// Role code. + final String roleCode; + + /// Role name. + final String roleName; + + /// Workers needed for this role. + final int workersNeeded; + + /// Pay rate in cents per hour. + final int payRateCents; + + /// Bill rate in cents per hour. + final int billRateCents; + + /// Serialises this [OrderPreviewRole] to a JSON map. + Map toJson() { + return { + 'roleId': roleId, + 'roleCode': roleCode, + 'roleName': roleName, + 'workersNeeded': workersNeeded, + 'payRateCents': payRateCents, + 'billRateCents': billRateCents, + }; + } + + @override + List get props => [ + roleId, + roleCode, + roleName, + workersNeeded, + payRateCents, + billRateCents, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/order_type.dart b/apps/mobile/packages/domain/lib/src/entities/orders/order_type.dart deleted file mode 100644 index f4385b5b..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/orders/order_type.dart +++ /dev/null @@ -1,30 +0,0 @@ -/// Defines the type of an order. -enum OrderType { - /// A single occurrence shift. - oneTime, - - /// A long-term or permanent staffing position. - permanent, - - /// Shifts that repeat on a defined schedule. - recurring, - - /// A quickly created shift. - rapid; - - /// Creates an [OrderType] from a string value (typically from the backend). - static OrderType fromString(String value) { - switch (value.toUpperCase()) { - case 'ONE_TIME': - return OrderType.oneTime; - case 'PERMANENT': - return OrderType.permanent; - case 'RECURRING': - return OrderType.recurring; - case 'RAPID': - return OrderType.rapid; - default: - return OrderType.oneTime; - } - } -} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart deleted file mode 100644 index ef950f87..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'one_time_order.dart'; -import 'one_time_order_position.dart'; - -/// Represents a customer's request for permanent/ongoing staffing. -class PermanentOrder extends Equatable { - const PermanentOrder({ - required this.startDate, - required this.permanentDays, - required this.positions, - this.hub, - this.eventName, - this.vendorId, - this.hubManagerId, - this.roleRates = const {}, - }); - - final DateTime startDate; - - /// List of days (e.g., ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']) - final List permanentDays; - - final List positions; - final OneTimeOrderHubDetails? hub; - final String? eventName; - final String? vendorId; - final String? hubManagerId; - final Map roleRates; - - @override - List get props => [ - startDate, - permanentDays, - positions, - hub, - eventName, - vendorId, - hubManagerId, - roleRates, - ]; -} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order_position.dart b/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order_position.dart deleted file mode 100644 index fb4d1e1b..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order_position.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents a specific position requirement within a [PermanentOrder]. -class PermanentOrderPosition extends Equatable { - const PermanentOrderPosition({ - required this.role, - required this.count, - required this.startTime, - required this.endTime, - this.lunchBreak = 'NO_BREAK', - this.location, - }); - - /// The job role or title required. - final String role; - - /// The number of workers required for this position. - final int count; - - /// The scheduled start time (e.g., "09:00 AM"). - final String startTime; - - /// The scheduled end time (e.g., "05:00 PM"). - final String endTime; - - /// The break duration enum value (e.g., NO_BREAK, MIN_15, MIN_30). - final String lunchBreak; - - /// Optional specific location for this position, if different from the order's main location. - final String? location; - - @override - List get props => [ - role, - count, - startTime, - endTime, - lunchBreak, - location, - ]; - - /// Creates a copy of this position with the given fields replaced. - PermanentOrderPosition copyWith({ - String? role, - int? count, - String? startTime, - String? endTime, - String? lunchBreak, - String? location, - }) { - return PermanentOrderPosition( - role: role ?? this.role, - count: count ?? this.count, - startTime: startTime ?? this.startTime, - endTime: endTime ?? this.endTime, - lunchBreak: lunchBreak ?? this.lunchBreak, - location: location ?? this.location, - ); - } -} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/recent_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/recent_order.dart new file mode 100644 index 00000000..453a048b --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/recent_order.dart @@ -0,0 +1,65 @@ +import 'package:equatable/equatable.dart'; + +import 'package:krow_domain/src/entities/enums/order_type.dart'; + +/// A recently completed order available for reordering. +/// +/// Returned by `GET /client/reorders`. +class RecentOrder extends Equatable { + /// Creates a [RecentOrder] instance. + const RecentOrder({ + required this.id, + required this.title, + this.date, + this.hubName, + required this.positionCount, + required this.orderType, + }); + + /// Deserialises a [RecentOrder] from a V2 API JSON map. + factory RecentOrder.fromJson(Map json) { + return RecentOrder( + id: json['id'] as String, + title: json['title'] as String, + date: json['date'] != null + ? DateTime.parse(json['date'] as String) + : null, + hubName: json['hubName'] as String?, + positionCount: (json['positionCount'] as num).toInt(), + orderType: OrderType.fromJson(json['orderType'] as String?), + ); + } + + /// Order ID. + final String id; + + /// Order title. + final String title; + + /// Order date. + final DateTime? date; + + /// Hub/location name. + final String? hubName; + + /// Number of positions in the order. + final int positionCount; + + /// Type of order (ONE_TIME, RECURRING, PERMANENT, RAPID). + final OrderType orderType; + + /// Serialises this [RecentOrder] to a JSON map. + Map toJson() { + return { + 'id': id, + 'title': title, + 'date': date?.toIso8601String(), + 'hubName': hubName, + 'positionCount': positionCount, + 'orderType': orderType.toJson(), + }; + } + + @override + List get props => [id, title, date, hubName, positionCount, orderType]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart deleted file mode 100644 index 76f00720..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'recurring_order_position.dart'; - -/// Represents a recurring staffing request spanning a date range. -class RecurringOrder extends Equatable { - const RecurringOrder({ - required this.startDate, - required this.endDate, - required this.recurringDays, - required this.location, - required this.positions, - this.hub, - this.eventName, - this.vendorId, - this.hubManagerId, - this.roleRates = const {}, - }); - - /// Start date for the recurring schedule. - final DateTime startDate; - - /// End date for the recurring schedule. - final DateTime endDate; - - /// Days of the week to repeat on (e.g., ["S", "M", ...]). - final List recurringDays; - - /// The primary location where the work will take place. - final String location; - - /// The list of positions and headcounts required for this order. - final List positions; - - /// Selected hub details for this order. - final RecurringOrderHubDetails? hub; - - /// Optional order name. - final String? eventName; - - /// Selected vendor id for this order. - final String? vendorId; - - /// Optional hub manager id. - final String? hubManagerId; - - /// Role hourly rates keyed by role id. - final Map roleRates; - - @override - List get props => [ - startDate, - endDate, - recurringDays, - location, - positions, - hub, - eventName, - vendorId, - hubManagerId, - roleRates, - ]; -} - -/// Minimal hub details used during recurring order creation. -class RecurringOrderHubDetails extends Equatable { - const RecurringOrderHubDetails({ - required this.id, - required this.name, - required this.address, - this.placeId, - this.latitude, - this.longitude, - this.city, - this.state, - this.street, - this.country, - this.zipCode, - }); - - final String id; - final String name; - final String address; - final String? placeId; - final double? latitude; - final double? longitude; - final String? city; - final String? state; - final String? street; - final String? country; - final String? zipCode; - - @override - List get props => [ - id, - name, - address, - placeId, - latitude, - longitude, - city, - state, - street, - country, - zipCode, - ]; -} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order_position.dart b/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order_position.dart deleted file mode 100644 index 9fdc2161..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order_position.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents a specific position requirement within a [RecurringOrder]. -class RecurringOrderPosition extends Equatable { - const RecurringOrderPosition({ - required this.role, - required this.count, - required this.startTime, - required this.endTime, - this.lunchBreak = 'NO_BREAK', - this.location, - }); - - /// The job role or title required. - final String role; - - /// The number of workers required for this position. - final int count; - - /// The scheduled start time (e.g., "09:00 AM"). - final String startTime; - - /// The scheduled end time (e.g., "05:00 PM"). - final String endTime; - - /// The break duration enum value (e.g., NO_BREAK, MIN_15, MIN_30). - final String lunchBreak; - - /// Optional specific location for this position, if different from the order's main location. - final String? location; - - @override - List get props => [ - role, - count, - startTime, - endTime, - lunchBreak, - location, - ]; - - /// Creates a copy of this position with the given fields replaced. - RecurringOrderPosition copyWith({ - String? role, - int? count, - String? startTime, - String? endTime, - String? lunchBreak, - String? location, - }) { - return RecurringOrderPosition( - role: role ?? this.role, - count: count ?? this.count, - startTime: startTime ?? this.startTime, - endTime: endTime ?? this.endTime, - lunchBreak: lunchBreak ?? this.lunchBreak, - location: location ?? this.location, - ); - } -} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/reorder_data.dart b/apps/mobile/packages/domain/lib/src/entities/orders/reorder_data.dart deleted file mode 100644 index 2f325d3a..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/orders/reorder_data.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'one_time_order.dart'; -import 'order_type.dart'; - -/// Represents the full details of an order retrieved for reordering. -class ReorderData extends Equatable { - const ReorderData({ - required this.orderId, - required this.orderType, - required this.eventName, - required this.vendorId, - required this.hub, - required this.positions, - this.date, - this.startDate, - this.endDate, - this.recurringDays = const [], - this.permanentDays = const [], - }); - - final String orderId; - final OrderType orderType; - final String eventName; - final String? vendorId; - final OneTimeOrderHubDetails hub; - final List positions; - - // One-time specific - final DateTime? date; - - // Recurring/Permanent specific - final DateTime? startDate; - final DateTime? endDate; - final List recurringDays; - final List permanentDays; - - @override - List get props => [ - orderId, - orderType, - eventName, - vendorId, - hub, - positions, - date, - startDate, - endDate, - recurringDays, - permanentDays, - ]; -} - -class ReorderPosition extends Equatable { - const ReorderPosition({ - required this.roleId, - required this.count, - required this.startTime, - required this.endTime, - this.lunchBreak = 'NO_BREAK', - }); - - final String roleId; - final int count; - final String startTime; - final String endTime; - final String lunchBreak; - - @override - List get props => [ - roleId, - count, - startTime, - endTime, - lunchBreak, - ]; -} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/attire_checklist.dart b/apps/mobile/packages/domain/lib/src/entities/profile/attire_checklist.dart new file mode 100644 index 00000000..f779576e --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/profile/attire_checklist.dart @@ -0,0 +1,138 @@ +import 'package:equatable/equatable.dart'; + +/// Status of an attire checklist item. +enum AttireItemStatus { + /// Photo has not been uploaded yet. + notUploaded, + + /// Upload is pending review. + pending, + + /// Photo has been verified/approved. + verified, + + /// Photo was rejected. + rejected, + + /// Document has expired. + expired, +} + +/// An attire checklist item for a staff member. +/// +/// Returned by `GET /staff/profile/attire`. Joins the `documents` catalog +/// (filtered to ATTIRE type) with the staff-specific `staff_documents` record. +class AttireChecklist extends Equatable { + /// Creates an [AttireChecklist] instance. + const AttireChecklist({ + required this.documentId, + required this.name, + this.description = '', + this.mandatory = true, + this.staffDocumentId, + this.photoUri, + required this.status, + this.verificationStatus, + }); + + /// Deserialises an [AttireChecklist] from the V2 API JSON response. + factory AttireChecklist.fromJson(Map json) { + return AttireChecklist( + documentId: json['documentId'] as String, + name: json['name'] as String, + description: json['description'] as String? ?? '', + mandatory: json['mandatory'] as bool? ?? true, + staffDocumentId: json['staffDocumentId'] as String?, + photoUri: json['photoUri'] as String?, + status: _parseStatus(json['status'] as String?), + verificationStatus: json['verificationStatus'] as String?, + ); + } + + /// Catalog document definition ID (UUID). + final String documentId; + + /// Human-readable attire item name. + final String name; + + /// Description of the attire requirement. + final String description; + + /// Whether this attire item is mandatory. + final bool mandatory; + + /// Staff-specific document record ID, or null if not uploaded. + final String? staffDocumentId; + + /// URI to the uploaded attire photo. + final String? photoUri; + + /// Current status of the attire item. + final AttireItemStatus status; + + /// Detailed verification status string (from metadata). + final String? verificationStatus; + + /// Whether a photo has been uploaded. + bool get isUploaded => staffDocumentId != null; + + /// Serialises this [AttireChecklist] to a JSON map. + Map toJson() { + return { + 'documentId': documentId, + 'name': name, + 'description': description, + 'mandatory': mandatory, + 'staffDocumentId': staffDocumentId, + 'photoUri': photoUri, + 'status': _statusToString(status), + 'verificationStatus': verificationStatus, + }; + } + + @override + List get props => [ + documentId, + name, + description, + mandatory, + staffDocumentId, + photoUri, + status, + verificationStatus, + ]; + + /// Parses a status string from the API. + static AttireItemStatus _parseStatus(String? value) { + switch (value?.toUpperCase()) { + case 'NOT_UPLOADED': + return AttireItemStatus.notUploaded; + case 'PENDING': + return AttireItemStatus.pending; + case 'VERIFIED': + return AttireItemStatus.verified; + case 'REJECTED': + return AttireItemStatus.rejected; + case 'EXPIRED': + return AttireItemStatus.expired; + default: + return AttireItemStatus.notUploaded; + } + } + + /// Converts an [AttireItemStatus] to its API string. + static String _statusToString(AttireItemStatus status) { + switch (status) { + case AttireItemStatus.notUploaded: + return 'NOT_UPLOADED'; + case AttireItemStatus.pending: + return 'PENDING'; + case AttireItemStatus.verified: + return 'VERIFIED'; + case AttireItemStatus.rejected: + return 'REJECTED'; + case AttireItemStatus.expired: + return 'EXPIRED'; + } + } +} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart b/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart deleted file mode 100644 index d794ca9e..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:equatable/equatable.dart'; - -import 'attire_verification_status.dart'; - -/// Represents an attire item that a staff member might need or possess. -/// -/// Attire items are specific clothing or equipment required for jobs. -class AttireItem extends Equatable { - /// Creates an [AttireItem]. - const AttireItem({ - required this.id, - required this.code, - required this.label, - this.description, - this.imageUrl, - this.isMandatory = false, - this.verificationStatus, - this.photoUrl, - this.verificationId, - }); - - /// Unique identifier of the attire item (UUID). - final String id; - - /// String code for the attire item (e.g. BLACK_TSHIRT). - final String code; - - /// Display name of the item. - final String label; - - /// Optional description for the attire item. - final String? description; - - /// URL of the reference image. - final String? imageUrl; - - /// Whether this item is mandatory for onboarding. - final bool isMandatory; - - /// The current verification status of the uploaded photo. - final AttireVerificationStatus? verificationStatus; - - /// The URL of the photo uploaded by the staff member. - final String? photoUrl; - - /// The ID of the verification record. - final String? verificationId; - - @override - List get props => [ - id, - code, - label, - description, - imageUrl, - isMandatory, - verificationStatus, - photoUrl, - verificationId, - ]; - - /// Creates a copy of this [AttireItem] with the given fields replaced. - AttireItem copyWith({ - String? id, - String? code, - String? label, - String? description, - String? imageUrl, - bool? isMandatory, - AttireVerificationStatus? verificationStatus, - String? photoUrl, - String? verificationId, - }) { - return AttireItem( - id: id ?? this.id, - code: code ?? this.code, - label: label ?? this.label, - description: description ?? this.description, - imageUrl: imageUrl ?? this.imageUrl, - isMandatory: isMandatory ?? this.isMandatory, - verificationStatus: verificationStatus ?? this.verificationStatus, - photoUrl: photoUrl ?? this.photoUrl, - verificationId: verificationId ?? this.verificationId, - ); - } -} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/attire_verification_status.dart b/apps/mobile/packages/domain/lib/src/entities/profile/attire_verification_status.dart deleted file mode 100644 index f766e8dc..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/profile/attire_verification_status.dart +++ /dev/null @@ -1,39 +0,0 @@ -/// Represents the verification status of an attire item photo. -enum AttireVerificationStatus { - /// Job is created and waiting to be processed. - pending('PENDING'), - - /// Job is currently being processed by machine or human. - processing('PROCESSING'), - - /// Machine verification passed automatically. - autoPass('AUTO_PASS'), - - /// Machine verification failed automatically. - autoFail('AUTO_FAIL'), - - /// Machine results are inconclusive and require human review. - needsReview('NEEDS_REVIEW'), - - /// Human reviewer approved the verification. - approved('APPROVED'), - - /// Human reviewer rejected the verification. - rejected('REJECTED'), - - /// An error occurred during processing. - error('ERROR'); - - const AttireVerificationStatus(this.value); - - /// The string value expected by the Core API. - final String value; - - /// Creates a [AttireVerificationStatus] from a string. - static AttireVerificationStatus fromString(String value) { - return AttireVerificationStatus.values.firstWhere( - (AttireVerificationStatus e) => e.value == value, - orElse: () => AttireVerificationStatus.error, - ); - } -} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/certificate.dart b/apps/mobile/packages/domain/lib/src/entities/profile/certificate.dart new file mode 100644 index 00000000..388e154a --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/profile/certificate.dart @@ -0,0 +1,147 @@ +import 'package:equatable/equatable.dart'; + +/// Status of a staff certificate. +enum CertificateStatus { + /// Certificate uploaded, pending verification. + pending, + + /// Certificate has been verified. + verified, + + /// Certificate was rejected. + rejected, + + /// Certificate has expired. + expired, +} + +/// A staff certificate record (e.g. food hygiene, SIA badge). +/// +/// Returned by `GET /staff/profile/certificates`. Maps to the V2 +/// `certificates` table with additional metadata fields. +/// Named [StaffCertificate] to distinguish from the catalog [Certificate] +/// definition in `skills/certificate.dart`. +class StaffCertificate extends Equatable { + /// Creates a [StaffCertificate] instance. + const StaffCertificate({ + required this.certificateId, + required this.certificateType, + required this.name, + this.fileUri, + this.issuer, + this.certificateNumber, + this.issuedAt, + this.expiresAt, + required this.status, + this.verificationStatus, + }); + + /// Deserialises a [StaffCertificate] from the V2 API JSON response. + factory StaffCertificate.fromJson(Map json) { + return StaffCertificate( + certificateId: json['certificateId'] as String, + certificateType: json['certificateType'] as String, + name: json['name'] as String, + fileUri: json['fileUri'] as String?, + issuer: json['issuer'] as String?, + certificateNumber: json['certificateNumber'] as String?, + issuedAt: json['issuedAt'] != null ? DateTime.parse(json['issuedAt'] as String) : null, + expiresAt: json['expiresAt'] != null ? DateTime.parse(json['expiresAt'] as String) : null, + status: _parseStatus(json['status'] as String?), + verificationStatus: json['verificationStatus'] as String?, + ); + } + + /// Certificate record UUID. + final String certificateId; + + /// Type code (e.g. "FOOD_HYGIENE", "SIA_BADGE"). + final String certificateType; + + /// Human-readable name (from metadata or falls back to certificateType). + final String name; + + /// URI to the uploaded certificate file. + final String? fileUri; + + /// Issuing authority name (from metadata). + final String? issuer; + + /// Certificate number/ID. + final String? certificateNumber; + + /// When the certificate was issued. + final DateTime? issuedAt; + + /// When the certificate expires. + final DateTime? expiresAt; + + /// Current verification status. + final CertificateStatus status; + + /// Detailed verification status string (from metadata). + final String? verificationStatus; + + /// Whether the certificate has expired based on [expiresAt]. + bool get isExpired => expiresAt != null && expiresAt!.isBefore(DateTime.now()); + + /// Serialises this [StaffCertificate] to a JSON map. + Map toJson() { + return { + 'certificateId': certificateId, + 'certificateType': certificateType, + 'name': name, + 'fileUri': fileUri, + 'issuer': issuer, + 'certificateNumber': certificateNumber, + 'issuedAt': issuedAt?.toIso8601String(), + 'expiresAt': expiresAt?.toIso8601String(), + 'status': _statusToString(status), + 'verificationStatus': verificationStatus, + }; + } + + @override + List get props => [ + certificateId, + certificateType, + name, + fileUri, + issuer, + certificateNumber, + issuedAt, + expiresAt, + status, + verificationStatus, + ]; + + /// Parses a status string from the API. + static CertificateStatus _parseStatus(String? value) { + switch (value?.toUpperCase()) { + case 'PENDING': + return CertificateStatus.pending; + case 'VERIFIED': + return CertificateStatus.verified; + case 'REJECTED': + return CertificateStatus.rejected; + case 'EXPIRED': + return CertificateStatus.expired; + default: + return CertificateStatus.pending; + } + } + + /// Converts a [CertificateStatus] to its API string. + static String _statusToString(CertificateStatus status) { + switch (status) { + case CertificateStatus.pending: + return 'PENDING'; + case CertificateStatus.verified: + return 'VERIFIED'; + case CertificateStatus.rejected: + return 'REJECTED'; + case CertificateStatus.expired: + return 'EXPIRED'; + } + } +} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/compliance_type.dart b/apps/mobile/packages/domain/lib/src/entities/profile/compliance_type.dart deleted file mode 100644 index ce5533ce..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/profile/compliance_type.dart +++ /dev/null @@ -1,25 +0,0 @@ -/// Represents the broad category of a compliance certificate. -enum ComplianceType { - backgroundCheck('BACKGROUND_CHECK'), - foodHandler('FOOD_HANDLER'), - rbs('RBS'), - legal('LEGAL'), - operational('OPERATIONAL'), - safety('SAFETY'), - training('TRAINING'), - license('LICENSE'), - other('OTHER'); - - const ComplianceType(this.value); - - /// The string value expected by the backend. - final String value; - - /// Creates a [ComplianceType] from a string. - static ComplianceType fromString(String value) { - return ComplianceType.values.firstWhere( - (ComplianceType e) => e.value == value, - orElse: () => ComplianceType.other, - ); - } -} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/document_verification_status.dart b/apps/mobile/packages/domain/lib/src/entities/profile/document_verification_status.dart deleted file mode 100644 index 99b47bb8..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/profile/document_verification_status.dart +++ /dev/null @@ -1,39 +0,0 @@ -/// Represents the verification status of a compliance document. -enum DocumentVerificationStatus { - /// Job is created and waiting to be processed. - pending('PENDING'), - - /// Job is currently being processed by machine or human. - processing('PROCESSING'), - - /// Machine verification passed automatically. - autoPass('AUTO_PASS'), - - /// Machine verification failed automatically. - autoFail('AUTO_FAIL'), - - /// Machine results are inconclusive and require human review. - needsReview('NEEDS_REVIEW'), - - /// Human reviewer approved the verification. - approved('APPROVED'), - - /// Human reviewer rejected the verification. - rejected('REJECTED'), - - /// An error occurred during processing. - error('ERROR'); - - const DocumentVerificationStatus(this.value); - - /// The string value expected by the Core API. - final String value; - - /// Creates a [DocumentVerificationStatus] from a string. - static DocumentVerificationStatus fromString(String value) { - return DocumentVerificationStatus.values.firstWhere( - (DocumentVerificationStatus e) => e.value == value, - orElse: () => DocumentVerificationStatus.error, - ); - } -} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/emergency_contact.dart b/apps/mobile/packages/domain/lib/src/entities/profile/emergency_contact.dart index ed3d438d..6c16bc71 100644 --- a/apps/mobile/packages/domain/lib/src/entities/profile/emergency_contact.dart +++ b/apps/mobile/packages/domain/lib/src/entities/profile/emergency_contact.dart @@ -1,75 +1,89 @@ import 'package:equatable/equatable.dart'; -import 'relationship_type.dart'; -/// Represents an emergency contact for a user. +/// An emergency contact for a staff member. /// -/// Critical for staff safety during shifts. +/// Maps to the V2 `emergency_contacts` table. Returned by +/// `GET /staff/profile/emergency-contacts`. class EmergencyContact extends Equatable { - + /// Creates an [EmergencyContact] instance. const EmergencyContact({ - required this.id, - required this.name, - required this.relationship, + required this.contactId, + required this.fullName, required this.phone, + required this.relationshipType, + this.isPrimary = false, }); - /// Unique identifier. - final String id; + /// Deserialises an [EmergencyContact] from a V2 API JSON response. + factory EmergencyContact.fromJson(Map json) { + return EmergencyContact( + contactId: json['contactId'] as String, + fullName: json['fullName'] as String, + phone: json['phone'] as String, + relationshipType: json['relationshipType'] as String, + isPrimary: json['isPrimary'] as bool? ?? false, + ); + } - /// Full name of the contact. - final String name; + /// Unique contact record ID (UUID). + final String contactId; - /// Relationship to the user (e.g. "Spouse", "Parent"). - final RelationshipType relationship; + /// Full name of the contact person. + final String fullName; /// Phone number. final String phone; - @override - List get props => [id, name, relationship, phone]; + /// Relationship to the staff member (e.g. "SPOUSE", "PARENT", "FRIEND"). + final String relationshipType; + + /// Whether this is the primary emergency contact. + final bool isPrimary; + + /// Serialises this [EmergencyContact] to a JSON map. + Map toJson() { + return { + 'contactId': contactId, + 'fullName': fullName, + 'phone': phone, + 'relationshipType': relationshipType, + 'isPrimary': isPrimary, + }; + } /// Returns a copy of this [EmergencyContact] with the given fields replaced. EmergencyContact copyWith({ - String? id, - String? name, + String? contactId, + String? fullName, String? phone, - RelationshipType? relationship, + String? relationshipType, + bool? isPrimary, }) { return EmergencyContact( - id: id ?? this.id, - name: name ?? this.name, + contactId: contactId ?? this.contactId, + fullName: fullName ?? this.fullName, phone: phone ?? this.phone, - relationship: relationship ?? this.relationship, + relationshipType: relationshipType ?? this.relationshipType, + isPrimary: isPrimary ?? this.isPrimary, ); } - /// Returns an empty [EmergencyContact]. + /// Returns an empty [EmergencyContact] for form initialisation. static EmergencyContact empty() { return const EmergencyContact( - id: '', - name: '', + contactId: '', + fullName: '', phone: '', - relationship: RelationshipType.family, + relationshipType: 'FAMILY', ); } - /// Converts a string value to a [RelationshipType]. - static RelationshipType stringToRelationshipType(String? value) { - if (value != null) { - final String strVal = value.toUpperCase(); - switch (strVal) { - case 'FAMILY': - return RelationshipType.family; - case 'SPOUSE': - return RelationshipType.spouse; - case 'FRIEND': - return RelationshipType.friend; - case 'OTHER': - return RelationshipType.other; - default: - return RelationshipType.other; - } - } - return RelationshipType.other; - } + @override + List get props => [ + contactId, + fullName, + phone, + relationshipType, + isPrimary, + ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/experience_skill.dart b/apps/mobile/packages/domain/lib/src/entities/profile/experience_skill.dart deleted file mode 100644 index 05676b23..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/profile/experience_skill.dart +++ /dev/null @@ -1,30 +0,0 @@ - -enum ExperienceSkill { - foodService('food_service'), - bartending('bartending'), - eventSetup('event_setup'), - hospitality('hospitality'), - warehouse('warehouse'), - customerService('customer_service'), - cleaning('cleaning'), - security('security'), - retail('retail'), - cooking('cooking'), - cashier('cashier'), - server('server'), - barista('barista'), - hostHostess('host_hostess'), - busser('busser'), - driving('driving'); - - final String value; - const ExperienceSkill(this.value); - - static ExperienceSkill? fromString(String value) { - try { - return ExperienceSkill.values.firstWhere((ExperienceSkill e) => e.value == value); - } catch (_) { - return null; - } - } -} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/industry.dart b/apps/mobile/packages/domain/lib/src/entities/profile/industry.dart deleted file mode 100644 index f0de201e..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/profile/industry.dart +++ /dev/null @@ -1,21 +0,0 @@ - -enum Industry { - hospitality('hospitality'), - foodService('food_service'), - warehouse('warehouse'), - events('events'), - retail('retail'), - healthcare('healthcare'), - other('other'); - - final String value; - const Industry(this.value); - - static Industry? fromString(String value) { - try { - return Industry.values.firstWhere((Industry e) => e.value == value); - } catch (_) { - return null; - } - } -} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/privacy_settings.dart b/apps/mobile/packages/domain/lib/src/entities/profile/privacy_settings.dart new file mode 100644 index 00000000..3001a05e --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/profile/privacy_settings.dart @@ -0,0 +1,32 @@ +import 'package:equatable/equatable.dart'; + +/// Staff privacy settings returned by `GET /staff/profile/privacy`. +/// +/// Currently contains a single visibility flag stored in the staff +/// metadata blob. May expand as more privacy controls are added. +class PrivacySettings extends Equatable { + /// Creates a [PrivacySettings] instance. + const PrivacySettings({ + this.profileVisible = true, + }); + + /// Deserialises [PrivacySettings] from the V2 API JSON response. + factory PrivacySettings.fromJson(Map json) { + return PrivacySettings( + profileVisible: json['profileVisible'] as bool? ?? true, + ); + } + + /// Whether the staff profile is visible to businesses. + final bool profileVisible; + + /// Serialises this [PrivacySettings] to a JSON map. + Map toJson() { + return { + 'profileVisible': profileVisible, + }; + } + + @override + List get props => [profileVisible]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/profile_completion.dart b/apps/mobile/packages/domain/lib/src/entities/profile/profile_completion.dart new file mode 100644 index 00000000..4b22fd4d --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/profile/profile_completion.dart @@ -0,0 +1,81 @@ +import 'package:equatable/equatable.dart'; + +/// Profile completion data returned by `GET /staff/profile-completion`. +/// +/// Contains per-field completion booleans derived from the staff metadata, +/// plus an overall completion flag and a list of missing fields. +class ProfileCompletion extends Equatable { + /// Creates a [ProfileCompletion] instance. + const ProfileCompletion({ + required this.staffId, + this.completed = false, + this.missingFields = const [], + this.fields = const {}, + }); + + /// Deserialises a [ProfileCompletion] from the V2 API JSON response. + factory ProfileCompletion.fromJson(Map json) { + final Object? rawFields = json['fields']; + final Map fields = {}; + if (rawFields is Map) { + for (final MapEntry entry in rawFields.entries) { + fields[entry.key] = entry.value == true; + } + } + + return ProfileCompletion( + staffId: json['staffId'] as String, + completed: json['completed'] as bool? ?? false, + missingFields: _parseStringList(json['missingFields']), + fields: fields, + ); + } + + /// Staff profile UUID. + final String staffId; + + /// Whether all required fields are complete. + final bool completed; + + /// List of field names that are still missing. + final List missingFields; + + /// Per-field completion map (field name to boolean). + /// + /// Known keys: firstName, lastName, email, phone, preferredLocations, + /// skills, industries, emergencyContact. + final Map fields; + + /// Percentage of fields completed (0 - 100). + int get percentComplete { + if (fields.isEmpty) return 0; + final int completedCount = fields.values.where((bool v) => v).length; + return ((completedCount / fields.length) * 100).round(); + } + + /// Serialises this [ProfileCompletion] to a JSON map. + Map toJson() { + return { + 'staffId': staffId, + 'completed': completed, + 'missingFields': missingFields, + 'fields': fields, + }; + } + + @override + List get props => [ + staffId, + completed, + missingFields, + fields, + ]; + + /// Parses a dynamic value into a list of strings. + static List _parseStringList(Object? value) { + if (value is List) { + return value.map((Object? e) => e.toString()).toList(); + } + return const []; + } +} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/profile_document.dart b/apps/mobile/packages/domain/lib/src/entities/profile/profile_document.dart new file mode 100644 index 00000000..d044f4e5 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/profile/profile_document.dart @@ -0,0 +1,183 @@ +import 'package:equatable/equatable.dart'; + +/// Status of a profile document. +enum ProfileDocumentStatus { + /// Document has not been uploaded yet. + notUploaded, + + /// Upload is pending review. + pending, + + /// Document has been verified/approved. + verified, + + /// Document was rejected. + rejected, + + /// Document has expired. + expired, +} + +/// Type category of a profile document. +enum ProfileDocumentType { + /// General compliance document. + document, + + /// Government-issued ID. + governmentId, + + /// Attire/uniform photo. + attire, + + /// Tax form (I-9, W-4). + taxForm, +} + +/// A profile document (or document requirement) for a staff member. +/// +/// Returned by `GET /staff/profile/documents`. Joins the `documents` catalog +/// with the staff-specific `staff_documents` record (if uploaded). +class ProfileDocument extends Equatable { + /// Creates a [ProfileDocument] instance. + const ProfileDocument({ + required this.documentId, + required this.documentType, + required this.name, + this.staffDocumentId, + this.fileUri, + required this.status, + this.expiresAt, + this.metadata = const {}, + }); + + /// Deserialises a [ProfileDocument] from the V2 API JSON response. + factory ProfileDocument.fromJson(Map json) { + return ProfileDocument( + documentId: json['documentId'] as String, + documentType: _parseDocumentType(json['documentType'] as String?), + name: json['name'] as String, + staffDocumentId: json['staffDocumentId'] as String?, + fileUri: json['fileUri'] as String?, + status: _parseStatus(json['status'] as String?), + expiresAt: json['expiresAt'] != null ? DateTime.parse(json['expiresAt'] as String) : null, + metadata: (json['metadata'] as Map?) ?? const {}, + ); + } + + /// Catalog document definition ID (UUID). + final String documentId; + + /// Type category of this document. + final ProfileDocumentType documentType; + + /// Human-readable document name. + final String name; + + /// Staff-specific document record ID, or null if not yet uploaded. + final String? staffDocumentId; + + /// URI to the uploaded file. + final String? fileUri; + + /// Current status of the document. + final ProfileDocumentStatus status; + + /// When the document expires, if applicable. + final DateTime? expiresAt; + + /// Flexible metadata JSON blob. + final Map metadata; + + /// Whether the document has been uploaded. + bool get isUploaded => staffDocumentId != null; + + /// Serialises this [ProfileDocument] to a JSON map. + Map toJson() { + return { + 'documentId': documentId, + 'documentType': _documentTypeToString(documentType), + 'name': name, + 'staffDocumentId': staffDocumentId, + 'fileUri': fileUri, + 'status': _statusToString(status), + 'expiresAt': expiresAt?.toIso8601String(), + 'metadata': metadata, + }; + } + + @override + List get props => [ + documentId, + documentType, + name, + staffDocumentId, + fileUri, + status, + expiresAt, + metadata, + ]; + + /// Parses a document type string from the API. + static ProfileDocumentType _parseDocumentType(String? value) { + switch (value?.toUpperCase()) { + case 'DOCUMENT': + return ProfileDocumentType.document; + case 'GOVERNMENT_ID': + return ProfileDocumentType.governmentId; + case 'ATTIRE': + return ProfileDocumentType.attire; + case 'TAX_FORM': + return ProfileDocumentType.taxForm; + default: + return ProfileDocumentType.document; + } + } + + /// Parses a status string from the API. + static ProfileDocumentStatus _parseStatus(String? value) { + switch (value?.toUpperCase()) { + case 'NOT_UPLOADED': + return ProfileDocumentStatus.notUploaded; + case 'PENDING': + return ProfileDocumentStatus.pending; + case 'VERIFIED': + return ProfileDocumentStatus.verified; + case 'REJECTED': + return ProfileDocumentStatus.rejected; + case 'EXPIRED': + return ProfileDocumentStatus.expired; + default: + return ProfileDocumentStatus.notUploaded; + } + } + + /// Converts a [ProfileDocumentType] to its API string. + static String _documentTypeToString(ProfileDocumentType type) { + switch (type) { + case ProfileDocumentType.document: + return 'DOCUMENT'; + case ProfileDocumentType.governmentId: + return 'GOVERNMENT_ID'; + case ProfileDocumentType.attire: + return 'ATTIRE'; + case ProfileDocumentType.taxForm: + return 'TAX_FORM'; + } + } + + /// Converts a [ProfileDocumentStatus] to its API string. + static String _statusToString(ProfileDocumentStatus status) { + switch (status) { + case ProfileDocumentStatus.notUploaded: + return 'NOT_UPLOADED'; + case ProfileDocumentStatus.pending: + return 'PENDING'; + case ProfileDocumentStatus.verified: + return 'VERIFIED'; + case ProfileDocumentStatus.rejected: + return 'REJECTED'; + case ProfileDocumentStatus.expired: + return 'EXPIRED'; + } + } +} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/profile_section_status.dart b/apps/mobile/packages/domain/lib/src/entities/profile/profile_section_status.dart new file mode 100644 index 00000000..9386e5e9 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/profile/profile_section_status.dart @@ -0,0 +1,76 @@ +import 'package:equatable/equatable.dart'; + +/// Profile section completion status returned by `GET /staff/profile/sections`. +/// +/// Boolean flags indicating whether each profile section has been completed. +/// Used to show progress indicators and gate onboarding flows. +class ProfileSectionStatus extends Equatable { + /// Creates a [ProfileSectionStatus] instance. + const ProfileSectionStatus({ + this.personalInfoCompleted = false, + this.emergencyContactCompleted = false, + this.experienceCompleted = false, + this.attireCompleted = false, + this.taxFormsCompleted = false, + this.benefitsConfigured = false, + this.certificateCount = 0, + }); + + /// Deserialises a [ProfileSectionStatus] from the V2 API JSON response. + factory ProfileSectionStatus.fromJson(Map json) { + return ProfileSectionStatus( + personalInfoCompleted: json['personalInfoCompleted'] as bool? ?? false, + emergencyContactCompleted: json['emergencyContactCompleted'] as bool? ?? false, + experienceCompleted: json['experienceCompleted'] as bool? ?? false, + attireCompleted: json['attireCompleted'] as bool? ?? false, + taxFormsCompleted: json['taxFormsCompleted'] as bool? ?? false, + benefitsConfigured: json['benefitsConfigured'] as bool? ?? false, + certificateCount: (json['certificateCount'] as num?)?.toInt() ?? 0, + ); + } + + /// Whether personal info (name, email, phone, locations) is complete. + final bool personalInfoCompleted; + + /// Whether at least one emergency contact exists. + final bool emergencyContactCompleted; + + /// Whether skills and industries are filled in. + final bool experienceCompleted; + + /// Whether all required attire items are verified. + final bool attireCompleted; + + /// Whether all tax forms (I-9, W-4) are verified. + final bool taxFormsCompleted; + + /// Whether at least one benefit is configured. + final bool benefitsConfigured; + + /// Number of uploaded certificates. + final int certificateCount; + + /// Serialises this [ProfileSectionStatus] to a JSON map. + Map toJson() { + return { + 'personalInfoCompleted': personalInfoCompleted, + 'emergencyContactCompleted': emergencyContactCompleted, + 'experienceCompleted': experienceCompleted, + 'attireCompleted': attireCompleted, + 'taxFormsCompleted': taxFormsCompleted, + 'benefitsConfigured': benefitsConfigured, + 'certificateCount': certificateCount, + }; + } + + @override + List get props => [ + personalInfoCompleted, + emergencyContactCompleted, + experienceCompleted, + attireCompleted, + taxFormsCompleted, + benefitsConfigured, + certificateCount, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/relationship_type.dart b/apps/mobile/packages/domain/lib/src/entities/profile/relationship_type.dart deleted file mode 100644 index 2d4dde1a..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/profile/relationship_type.dart +++ /dev/null @@ -1,6 +0,0 @@ -enum RelationshipType { - family, - spouse, - friend, - other, -} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/schedule.dart b/apps/mobile/packages/domain/lib/src/entities/profile/schedule.dart deleted file mode 100644 index 5aeb8131..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/profile/schedule.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents general availability schedule for a [Staff] member. -/// -/// Defines recurring availability (e.g., "Mondays 9-5"). -class Schedule extends Equatable { - - const Schedule({ - required this.id, - required this.staffId, - required this.dayOfWeek, - required this.startTime, - required this.endTime, - }); - /// Unique identifier. - final String id; - - /// The [Staff] member. - final String staffId; - - /// Day of the week (1 = Monday, 7 = Sunday). - final int dayOfWeek; - - /// Start time of availability. - final DateTime startTime; - - /// End time of availability. - final DateTime endTime; - - @override - List get props => [id, staffId, dayOfWeek, startTime, endTime]; -} \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate.dart b/apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate.dart deleted file mode 100644 index a4da1b29..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'package:equatable/equatable.dart'; - -import 'compliance_type.dart'; -import 'staff_certificate_status.dart'; -import 'staff_certificate_validation_status.dart'; - -/// Represents a staff's compliance certificate record. -class StaffCertificate extends Equatable { - const StaffCertificate({ - required this.id, - required this.staffId, - required this.name, - this.description, - this.expiryDate, - required this.status, - this.certificateUrl, - this.icon, - required this.certificationType, - this.issuer, - this.certificateNumber, - this.validationStatus, - this.verificationId, - this.createdAt, - this.updatedAt, - }); - - /// The unique identifier of the certificate record. - final String id; - - /// The ID of the staff member. - final String staffId; - - /// The display name of the certificate. - final String name; - - /// A description or details about the certificate. - final String? description; - - /// The expiration date of the certificate. - final DateTime? expiryDate; - - /// The current state of the certificate. - final StaffCertificateStatus status; - - /// The URL of the stored certificate file/image. - final String? certificateUrl; - - /// An icon to display for this certificate type. - final String? icon; - - /// The category of compliance this certificate fits into. - final ComplianceType certificationType; - - /// The issuing body or authority. - final String? issuer; - - /// Document number or reference. - final String? certificateNumber; - - /// The ID of the verification record. - final String? verificationId; - - /// Recent validation/verification results. - final StaffCertificateValidationStatus? validationStatus; - - /// Creation timestamp. - final DateTime? createdAt; - - /// Last update timestamp. - final DateTime? updatedAt; - - @override - List get props => [ - id, - staffId, - name, - description, - expiryDate, - status, - certificateUrl, - icon, - certificationType, - issuer, - certificateNumber, - validationStatus, - verificationId, - createdAt, - updatedAt, - ]; - - /// Creates a copy of this [StaffCertificate] with updated fields. - StaffCertificate copyWith({ - String? id, - String? staffId, - String? name, - String? description, - DateTime? expiryDate, - StaffCertificateStatus? status, - String? certificateUrl, - String? icon, - ComplianceType? certificationType, - String? issuer, - String? certificateNumber, - StaffCertificateValidationStatus? validationStatus, - String? verificationId, - DateTime? createdAt, - DateTime? updatedAt, - }) { - return StaffCertificate( - id: id ?? this.id, - staffId: staffId ?? this.staffId, - name: name ?? this.name, - description: description ?? this.description, - expiryDate: expiryDate ?? this.expiryDate, - status: status ?? this.status, - certificateUrl: certificateUrl ?? this.certificateUrl, - icon: icon ?? this.icon, - certificationType: certificationType ?? this.certificationType, - issuer: issuer ?? this.issuer, - certificateNumber: certificateNumber ?? this.certificateNumber, - validationStatus: validationStatus ?? this.validationStatus, - verificationId: verificationId ?? this.verificationId, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - ); - } -} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate_status.dart b/apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate_status.dart deleted file mode 100644 index b39e096c..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate_status.dart +++ /dev/null @@ -1,23 +0,0 @@ -/// Represents the current validity status of a staff certificate. -enum StaffCertificateStatus { - current('CURRENT'), - expiringSoon('EXPIRING_SOON'), - completed('COMPLETED'), - pending('PENDING'), - expired('EXPIRED'), - expiring('EXPIRING'), - notStarted('NOT_STARTED'); - - const StaffCertificateStatus(this.value); - - /// The string value expected by the backend. - final String value; - - /// Creates a [StaffCertificateStatus] from a string. - static StaffCertificateStatus fromString(String value) { - return StaffCertificateStatus.values.firstWhere( - (StaffCertificateStatus e) => e.value == value, - orElse: () => StaffCertificateStatus.notStarted, - ); - } -} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate_validation_status.dart b/apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate_validation_status.dart deleted file mode 100644 index 19d30358..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate_validation_status.dart +++ /dev/null @@ -1,22 +0,0 @@ -/// Represents the verification or review state for a staff certificate. -enum StaffCertificateValidationStatus { - approved('APPROVED'), - pendingExpertReview('PENDING_EXPERT_REVIEW'), - rejected('REJECTED'), - aiVerified('AI_VERIFIED'), - aiFlagged('AI_FLAGGED'), - manualReviewNeeded('MANUAL_REVIEW_NEEDED'); - - const StaffCertificateValidationStatus(this.value); - - /// The string value expected by the backend. - final String value; - - /// Creates a [StaffCertificateValidationStatus] from a string. - static StaffCertificateValidationStatus fromString(String value) { - return StaffCertificateValidationStatus.values.firstWhere( - (StaffCertificateValidationStatus e) => e.value == value, - orElse: () => StaffCertificateValidationStatus.manualReviewNeeded, - ); - } -} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/staff_document.dart b/apps/mobile/packages/domain/lib/src/entities/profile/staff_document.dart deleted file mode 100644 index d8dd5958..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/profile/staff_document.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:equatable/equatable.dart'; - -import 'document_verification_status.dart'; - -/// Status of a compliance document. -enum DocumentStatus { verified, pending, missing, rejected, expired } - -/// Represents a staff compliance document. -class StaffDocument extends Equatable { - const StaffDocument({ - required this.id, - required this.staffId, - required this.documentId, - required this.name, - this.description, - required this.status, - this.documentUrl, - this.expiryDate, - this.verificationId, - this.verificationStatus, - }); - - /// The unique identifier of the staff document record. - final String id; - - /// The ID of the staff member. - final String staffId; - - /// The ID of the document definition. - final String documentId; - - /// The name of the document. - final String name; - - /// A description of the document. - final String? description; - - /// The status of the document. - final DocumentStatus status; - - /// The URL of the uploaded document image/file. - final String? documentUrl; - - /// The expiry date of the document. - final DateTime? expiryDate; - - /// The ID of the verification record. - final String? verificationId; - - /// The detailed verification status. - final DocumentVerificationStatus? verificationStatus; - - @override - List get props => [ - id, - staffId, - documentId, - name, - description, - status, - documentUrl, - expiryDate, - verificationId, - verificationStatus, - ]; -} 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 new file mode 100644 index 00000000..e394e27d --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/profile/staff_personal_info.dart @@ -0,0 +1,105 @@ +import 'package:equatable/equatable.dart'; + +/// Staff personal information returned by `GET /staff/profile/personal-info`. +/// +/// Contains name, contact details, bio, location preferences, and +/// experience metadata extracted from the staffs table and its metadata blob. +class StaffPersonalInfo extends Equatable { + /// Creates a [StaffPersonalInfo] instance. + const StaffPersonalInfo({ + required this.staffId, + this.firstName, + this.lastName, + this.bio, + this.preferredLocations = const [], + this.maxDistanceMiles, + this.industries = const [], + this.skills = const [], + this.email, + this.phone, + }); + + /// Deserialises a [StaffPersonalInfo] from the V2 API JSON response. + factory StaffPersonalInfo.fromJson(Map json) { + return StaffPersonalInfo( + staffId: json['staffId'] as String, + firstName: json['firstName'] as String?, + lastName: json['lastName'] as String?, + bio: json['bio'] as String?, + preferredLocations: _parseStringList(json['preferredLocations']), + maxDistanceMiles: (json['maxDistanceMiles'] as num?)?.toInt(), + industries: _parseStringList(json['industries']), + skills: _parseStringList(json['skills']), + email: json['email'] as String?, + phone: json['phone'] as String?, + ); + } + + /// Staff profile UUID. + final String staffId; + + /// First name (from metadata or parsed from fullName). + final String? firstName; + + /// Last name (from metadata or parsed from fullName). + final String? lastName; + + /// Short biography or description. + final String? bio; + + /// Preferred work locations (city names or addresses). + final List preferredLocations; + + /// Maximum commute distance in miles. + final int? maxDistanceMiles; + + /// Industry codes the staff is experienced in. + final List industries; + + /// Skill codes the staff has. + final List skills; + + /// Contact email. + final String? email; + + /// Contact phone number. + final String? phone; + + /// Serialises this [StaffPersonalInfo] to a JSON map. + Map toJson() { + return { + 'staffId': staffId, + 'firstName': firstName, + 'lastName': lastName, + 'bio': bio, + 'preferredLocations': preferredLocations, + 'maxDistanceMiles': maxDistanceMiles, + 'industries': industries, + 'skills': skills, + 'email': email, + 'phone': phone, + }; + } + + @override + List get props => [ + staffId, + firstName, + lastName, + bio, + preferredLocations, + maxDistanceMiles, + industries, + skills, + email, + phone, + ]; + + /// Parses a dynamic value into a list of strings. + static List _parseStringList(Object? value) { + if (value is List) { + return value.map((Object? e) => e.toString()).toList(); + } + return const []; + } +} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/tax_form.dart b/apps/mobile/packages/domain/lib/src/entities/profile/tax_form.dart index bc3967b1..80153533 100644 --- a/apps/mobile/packages/domain/lib/src/entities/profile/tax_form.dart +++ b/apps/mobile/packages/domain/lib/src/entities/profile/tax_form.dart @@ -1,78 +1,117 @@ import 'package:equatable/equatable.dart'; -enum TaxFormType { i9, w4 } +/// Status of a tax form. +enum TaxFormStatus { + /// Form has not been started. + notStarted, -enum TaxFormStatus { notStarted, inProgress, submitted, approved, rejected } + /// Form is partially filled. + inProgress, -abstract class TaxForm extends Equatable { + /// Form has been submitted for review. + submitted, + /// Form has been approved. + approved, + + /// Form was rejected. + rejected, +} + +/// A tax form (e.g. I-9, W-4) for a staff member. +/// +/// Returned by `GET /staff/profile/tax-forms`. Joins the `documents` catalog +/// (filtered to TAX_FORM type) with the staff-specific `staff_documents` record. +class TaxForm extends Equatable { + /// Creates a [TaxForm] instance. const TaxForm({ - required this.id, - required this.title, - this.subtitle, - this.description, - this.status = TaxFormStatus.notStarted, - this.staffId, - this.formData = const {}, - this.createdAt, - this.updatedAt, + required this.documentId, + required this.formType, + this.staffDocumentId, + required this.status, + this.fields = const {}, }); - final String id; - TaxFormType get type; - final String title; - final String? subtitle; - final String? description; + + /// Deserialises a [TaxForm] from the V2 API JSON response. + factory TaxForm.fromJson(Map json) { + return TaxForm( + documentId: json['documentId'] as String, + formType: json['formType'] as String, + staffDocumentId: json['staffDocumentId'] as String?, + status: _parseStatus(json['status'] as String?), + fields: (json['fields'] as Map?) ?? const {}, + ); + } + + /// Catalog document definition ID (UUID). + final String documentId; + + /// Form type name (e.g. "I-9", "W-4"). + final String formType; + + /// Staff-specific document record ID, or null if not started. + final String? staffDocumentId; + + /// Current status of the tax form. final TaxFormStatus status; - final String? staffId; - final Map formData; - final DateTime? createdAt; - final DateTime? updatedAt; + + /// Saved form field data (partial or complete). + final Map fields; + + /// Whether the form has been started. + bool get isStarted => staffDocumentId != null; + + /// Serialises this [TaxForm] to a JSON map. + Map toJson() { + return { + 'documentId': documentId, + 'formType': formType, + 'staffDocumentId': staffDocumentId, + 'status': _statusToString(status), + 'fields': fields, + }; + } @override List get props => [ - id, - type, - title, - subtitle, - description, + documentId, + formType, + staffDocumentId, status, - staffId, - formData, - createdAt, - updatedAt, + fields, ]; -} - -class I9TaxForm extends TaxForm { - const I9TaxForm({ - required super.id, - required super.title, - super.subtitle, - super.description, - super.status, - super.staffId, - super.formData, - super.createdAt, - super.updatedAt, - }); - - @override - TaxFormType get type => TaxFormType.i9; -} - -class W4TaxForm extends TaxForm { - const W4TaxForm({ - required super.id, - required super.title, - super.subtitle, - super.description, - super.status, - super.staffId, - super.formData, - super.createdAt, - super.updatedAt, - }); - - @override - TaxFormType get type => TaxFormType.w4; + + /// Parses a status string from the API. + static TaxFormStatus _parseStatus(String? value) { + switch (value?.toUpperCase()) { + case 'NOT_STARTED': + return TaxFormStatus.notStarted; + case 'IN_PROGRESS': + return TaxFormStatus.inProgress; + case 'SUBMITTED': + return TaxFormStatus.submitted; + case 'APPROVED': + return TaxFormStatus.approved; + case 'REJECTED': + return TaxFormStatus.rejected; + default: + return TaxFormStatus.notStarted; + } + } + + /// Converts a [TaxFormStatus] to its API string. + static String _statusToString(TaxFormStatus status) { + switch (status) { + case TaxFormStatus.notStarted: + return 'NOT_STARTED'; + case TaxFormStatus.inProgress: + return 'IN_PROGRESS'; + case TaxFormStatus.submitted: + return 'SUBMITTED'; + case TaxFormStatus.approved: + return 'APPROVED'; + case TaxFormStatus.rejected: + return 'REJECTED'; + } + } } diff --git a/apps/mobile/packages/domain/lib/src/entities/ratings/business_staff_preference.dart b/apps/mobile/packages/domain/lib/src/entities/ratings/business_staff_preference.dart deleted file mode 100644 index 1f56eecb..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/ratings/business_staff_preference.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// The type of preference a business has for a staff member. -enum PreferenceType { - /// Business wants to prioritize this staff member. - favorite, - - /// Business does not want to work with this staff member. - blocked, -} - -/// Represents a business's specific preference for a staff member. -class BusinessStaffPreference extends Equatable { - - const BusinessStaffPreference({ - required this.id, - required this.businessId, - required this.staffId, - required this.type, - }); - /// Unique identifier. - final String id; - - /// The [Business] holding the preference. - final String businessId; - - /// The [Staff] member. - final String staffId; - - /// Whether they are a favorite or blocked. - final PreferenceType type; - - @override - List get props => [id, businessId, staffId, type]; -} \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/src/entities/ratings/penalty_log.dart b/apps/mobile/packages/domain/lib/src/entities/ratings/penalty_log.dart deleted file mode 100644 index d42e46f0..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/ratings/penalty_log.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents a penalty issued to a staff member. -/// -/// Penalties are issued for no-shows, cancellations, or poor conduct. -class PenaltyLog extends Equatable { - - const PenaltyLog({ - required this.id, - required this.staffId, - required this.assignmentId, - required this.reason, - required this.points, - required this.issuedAt, - }); - /// Unique identifier. - final String id; - - /// The [Staff] member penalized. - final String staffId; - - /// The [Assignment] context (if applicable). - final String assignmentId; - - /// Reason for the penalty. - final String reason; - - /// Score points deducted from staff profile. - final int points; - - /// When the penalty was issued. - final DateTime issuedAt; - - @override - List get props => [id, staffId, assignmentId, reason, points, issuedAt]; -} \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/src/entities/ratings/staff_rating.dart b/apps/mobile/packages/domain/lib/src/entities/ratings/staff_rating.dart index b51a44ae..a1a70391 100644 --- a/apps/mobile/packages/domain/lib/src/entities/ratings/staff_rating.dart +++ b/apps/mobile/packages/domain/lib/src/entities/ratings/staff_rating.dart @@ -1,34 +1,103 @@ import 'package:equatable/equatable.dart'; -/// Represents a rating given to a staff member by a client. +/// A review left for a staff member after an assignment. +/// +/// Maps to the V2 `staff_reviews` table. class StaffRating extends Equatable { - + /// Creates a [StaffRating] instance. const StaffRating({ required this.id, - required this.staffId, - required this.eventId, + required this.tenantId, required this.businessId, + required this.staffId, + required this.assignmentId, + this.reviewerUserId, required this.rating, - this.comment, + this.reviewText, + this.tags = const [], + this.createdAt, }); + + /// Deserialises a [StaffRating] from a V2 API JSON map. + factory StaffRating.fromJson(Map json) { + final dynamic tagsRaw = json['tags']; + final List tagsList = tagsRaw is List + ? tagsRaw.map((dynamic e) => e.toString()).toList() + : const []; + + return StaffRating( + id: json['id'] as String, + tenantId: json['tenantId'] as String, + businessId: json['businessId'] as String, + staffId: json['staffId'] as String, + assignmentId: json['assignmentId'] as String, + reviewerUserId: json['reviewerUserId'] as String?, + rating: (json['rating'] as num).toInt(), + reviewText: json['reviewText'] as String?, + tags: tagsList, + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt'] as String) + : null, + ); + } + /// Unique identifier. final String id; - /// The [Staff] being rated. + /// Tenant ID. + final String tenantId; + + /// Business that left the review. + final String businessId; + + /// Staff member being reviewed. final String staffId; - /// The [Event] context. - final String eventId; + /// Assignment this review is for. + final String assignmentId; - /// The [Business] leaving the rating. - final String businessId; + /// User who wrote the review. + final String? reviewerUserId; /// Star rating (1-5). final int rating; - /// Optional feedback text. - final String? comment; + /// Free-text review. + final String? reviewText; + + /// Tags associated with the review (e.g. punctual, professional). + final List tags; + + /// When the review was created. + final DateTime? createdAt; + + /// Serialises this [StaffRating] to a JSON map. + Map toJson() { + return { + 'id': id, + 'tenantId': tenantId, + 'businessId': businessId, + 'staffId': staffId, + 'assignmentId': assignmentId, + 'reviewerUserId': reviewerUserId, + 'rating': rating, + 'reviewText': reviewText, + 'tags': tags, + 'createdAt': createdAt?.toIso8601String(), + }; + } @override - List get props => [id, staffId, eventId, businessId, rating, comment]; -} \ No newline at end of file + List get props => [ + id, + tenantId, + businessId, + staffId, + assignmentId, + reviewerUserId, + rating, + reviewText, + tags, + createdAt, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/coverage_report.dart b/apps/mobile/packages/domain/lib/src/entities/reports/coverage_report.dart index 0a4db09b..9ed90277 100644 --- a/apps/mobile/packages/domain/lib/src/entities/reports/coverage_report.dart +++ b/apps/mobile/packages/domain/lib/src/entities/reports/coverage_report.dart @@ -1,37 +1,109 @@ -// 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'; +/// Coverage report with daily breakdown. +/// +/// Returned by `GET /client/reports/coverage`. class CoverageReport extends Equatable { - + /// Creates a [CoverageReport] instance. const CoverageReport({ - required this.overallCoverage, - required this.totalNeeded, - required this.totalFilled, - required this.dailyCoverage, + required this.averageCoveragePercentage, + required this.filledWorkers, + required this.neededWorkers, + this.chart = const [], }); - final double overallCoverage; - final int totalNeeded; - final int totalFilled; - final List dailyCoverage; + + /// Deserialises a [CoverageReport] from a V2 API JSON map. + factory CoverageReport.fromJson(Map json) { + final dynamic chartRaw = json['chart']; + final List chartList = chartRaw is List + ? chartRaw + .map((dynamic e) => + CoverageDayPoint.fromJson(e as Map)) + .toList() + : const []; + + return CoverageReport( + averageCoveragePercentage: + (json['averageCoveragePercentage'] as num).toInt(), + filledWorkers: (json['filledWorkers'] as num).toInt(), + neededWorkers: (json['neededWorkers'] as num).toInt(), + chart: chartList, + ); + } + + /// Average coverage across the period (0-100). + final int averageCoveragePercentage; + + /// Total filled workers across the period. + final int filledWorkers; + + /// Total needed workers across the period. + final int neededWorkers; + + /// Daily coverage data points. + final List chart; + + /// Serialises this [CoverageReport] to a JSON map. + Map toJson() { + return { + 'averageCoveragePercentage': averageCoveragePercentage, + 'filledWorkers': filledWorkers, + 'neededWorkers': neededWorkers, + 'chart': chart.map((CoverageDayPoint p) => p.toJson()).toList(), + }; + } @override - List get props => [overallCoverage, totalNeeded, totalFilled, dailyCoverage]; + List get props => [ + averageCoveragePercentage, + filledWorkers, + neededWorkers, + chart, + ]; } -class CoverageDay extends Equatable { - - const CoverageDay({ - required this.date, +/// A single day in the coverage chart. +class CoverageDayPoint extends Equatable { + /// Creates a [CoverageDayPoint] instance. + const CoverageDayPoint({ + required this.day, required this.needed, required this.filled, - required this.percentage, + required this.coveragePercentage, }); - final DateTime date; + + /// Deserialises a [CoverageDayPoint] from a V2 API JSON map. + factory CoverageDayPoint.fromJson(Map json) { + return CoverageDayPoint( + day: DateTime.parse(json['day'] as String), + needed: (json['needed'] as num).toInt(), + filled: (json['filled'] as num).toInt(), + coveragePercentage: (json['coveragePercentage'] as num).toDouble(), + ); + } + + /// Date. + final DateTime day; + + /// Workers needed. final int needed; + + /// Workers filled. final int filled; - final double percentage; + + /// Coverage percentage for this day. + final double coveragePercentage; + + /// Serialises this [CoverageDayPoint] to a JSON map. + Map toJson() { + return { + 'day': day.toIso8601String(), + 'needed': needed, + 'filled': filled, + 'coveragePercentage': coveragePercentage, + }; + } @override - List get props => [date, needed, filled, percentage]; + List get props => [day, needed, filled, coveragePercentage]; } - diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/daily_ops_report.dart b/apps/mobile/packages/domain/lib/src/entities/reports/daily_ops_report.dart index 47d01056..09ced16d 100644 --- a/apps/mobile/packages/domain/lib/src/entities/reports/daily_ops_report.dart +++ b/apps/mobile/packages/domain/lib/src/entities/reports/daily_ops_report.dart @@ -1,65 +1,72 @@ -// 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'; -class DailyOpsReport extends Equatable { +import '../coverage_domain/shift_with_workers.dart'; +/// Daily operations report with shift-level detail. +/// +/// Returned by `GET /client/reports/daily-ops`. +class DailyOpsReport extends Equatable { + /// Creates a [DailyOpsReport] instance. const DailyOpsReport({ - required this.scheduledShifts, - required this.workersConfirmed, - required this.inProgressShifts, - required this.completedShifts, - required this.shifts, + required this.totalShifts, + required this.totalWorkersDeployed, + required this.totalHoursWorked, + required this.onTimeArrivalPercentage, + this.shifts = const [], }); - final int scheduledShifts; - final int workersConfirmed; - final int inProgressShifts; - final int completedShifts; - final List shifts; + + /// Deserialises a [DailyOpsReport] from a V2 API JSON map. + factory DailyOpsReport.fromJson(Map json) { + final dynamic shiftsRaw = json['shifts']; + final List shiftsList = shiftsRaw is List + ? shiftsRaw + .map((dynamic e) => + ShiftWithWorkers.fromJson(e as Map)) + .toList() + : const []; + + return DailyOpsReport( + totalShifts: (json['totalShifts'] as num).toInt(), + totalWorkersDeployed: (json['totalWorkersDeployed'] as num).toInt(), + totalHoursWorked: (json['totalHoursWorked'] as num).toInt(), + onTimeArrivalPercentage: + (json['onTimeArrivalPercentage'] as num).toInt(), + shifts: shiftsList, + ); + } + + /// Total number of shifts for the day. + final int totalShifts; + + /// Total workers deployed. + final int totalWorkersDeployed; + + /// Total hours worked (rounded). + final int totalHoursWorked; + + /// Percentage of on-time arrivals (0-100). + final int onTimeArrivalPercentage; + + /// Individual shift details with worker assignments. + final List shifts; + + /// Serialises this [DailyOpsReport] to a JSON map. + Map toJson() { + return { + 'totalShifts': totalShifts, + 'totalWorkersDeployed': totalWorkersDeployed, + 'totalHoursWorked': totalHoursWorked, + 'onTimeArrivalPercentage': onTimeArrivalPercentage, + 'shifts': shifts.map((ShiftWithWorkers s) => s.toJson()).toList(), + }; + } @override List get props => [ - scheduledShifts, - workersConfirmed, - inProgressShifts, - completedShifts, + totalShifts, + totalWorkersDeployed, + totalHoursWorked, + onTimeArrivalPercentage, shifts, ]; } - -class DailyOpsShift extends Equatable { - - const DailyOpsShift({ - required this.id, - required this.title, - required this.location, - required this.startTime, - required this.endTime, - required this.workersNeeded, - required this.filled, - required this.status, - this.hourlyRate, - }); - final String id; - final String title; - final String location; - final DateTime startTime; - final DateTime endTime; - final int workersNeeded; - final int filled; - final String status; - final double? hourlyRate; - - @override - List get props => [ - id, - title, - location, - startTime, - endTime, - workersNeeded, - filled, - status, - hourlyRate, - ]; -} - diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/forecast_report.dart b/apps/mobile/packages/domain/lib/src/entities/reports/forecast_report.dart index c4c14568..20c7a3a1 100644 --- a/apps/mobile/packages/domain/lib/src/entities/reports/forecast_report.dart +++ b/apps/mobile/packages/domain/lib/src/entities/reports/forecast_report.dart @@ -1,79 +1,128 @@ -// 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'; +/// Staffing and spend forecast report. +/// +/// Returned by `GET /client/reports/forecast`. class ForecastReport extends Equatable { - + /// Creates a [ForecastReport] instance. const ForecastReport({ - required this.projectedSpend, - required this.projectedWorkers, - required this.averageLaborCost, - required this.chartData, - this.totalShifts = 0, - this.totalHours = 0.0, - this.avgWeeklySpend = 0.0, - this.weeklyBreakdown = const [], + required this.forecastSpendCents, + required this.averageWeeklySpendCents, + required this.totalShifts, + required this.totalWorkerHours, + this.weeks = const [], }); - final double projectedSpend; - final int projectedWorkers; - final double averageLaborCost; - final List chartData; - // New fields for the updated design + /// Deserialises a [ForecastReport] from a V2 API JSON map. + factory ForecastReport.fromJson(Map json) { + final dynamic weeksRaw = json['weeks']; + final List weeksList = weeksRaw is List + ? weeksRaw + .map((dynamic e) => + ForecastWeek.fromJson(e as Map)) + .toList() + : const []; + + return ForecastReport( + forecastSpendCents: (json['forecastSpendCents'] as num).toInt(), + averageWeeklySpendCents: + (json['averageWeeklySpendCents'] as num).toInt(), + totalShifts: (json['totalShifts'] as num).toInt(), + totalWorkerHours: (json['totalWorkerHours'] as num).toDouble(), + weeks: weeksList, + ); + } + + /// Total forecast spend in cents. + final int forecastSpendCents; + + /// Average weekly spend in cents. + final int averageWeeklySpendCents; + + /// Total forecasted shifts. final int totalShifts; - final double totalHours; - final double avgWeeklySpend; - final List weeklyBreakdown; + + /// Total forecasted worker-hours. + final double totalWorkerHours; + + /// Weekly breakdown. + final List weeks; + + /// Serialises this [ForecastReport] to a JSON map. + Map toJson() { + return { + 'forecastSpendCents': forecastSpendCents, + 'averageWeeklySpendCents': averageWeeklySpendCents, + 'totalShifts': totalShifts, + 'totalWorkerHours': totalWorkerHours, + 'weeks': weeks.map((ForecastWeek w) => w.toJson()).toList(), + }; + } @override List get props => [ - projectedSpend, - projectedWorkers, - averageLaborCost, - chartData, + forecastSpendCents, + averageWeeklySpendCents, totalShifts, - totalHours, - avgWeeklySpend, - weeklyBreakdown, + totalWorkerHours, + weeks, ]; } -class ForecastPoint extends Equatable { - - const ForecastPoint({ - required this.date, - required this.projectedCost, - required this.workersNeeded, - }); - final DateTime date; - final double projectedCost; - final int workersNeeded; - - @override - List get props => [date, projectedCost, workersNeeded]; -} - +/// A single week in the forecast breakdown. class ForecastWeek extends Equatable { - + /// Creates a [ForecastWeek] instance. const ForecastWeek({ - required this.weekNumber, - required this.totalCost, - required this.shiftsCount, - required this.hoursCount, - required this.avgCostPerShift, + required this.week, + required this.shiftCount, + required this.workerHours, + required this.forecastSpendCents, + required this.averageShiftCostCents, }); - final int weekNumber; - final double totalCost; - final int shiftsCount; - final double hoursCount; - final double avgCostPerShift; + + /// Deserialises a [ForecastWeek] from a V2 API JSON map. + factory ForecastWeek.fromJson(Map json) { + return ForecastWeek( + week: DateTime.parse(json['week'] as String), + shiftCount: (json['shiftCount'] as num).toInt(), + workerHours: (json['workerHours'] as num).toDouble(), + forecastSpendCents: (json['forecastSpendCents'] as num).toInt(), + averageShiftCostCents: (json['averageShiftCostCents'] as num).toInt(), + ); + } + + /// Start of the week. + final DateTime week; + + /// Number of shifts in this week. + final int shiftCount; + + /// Total worker-hours in this week. + final double workerHours; + + /// Forecast spend in cents for this week. + final int forecastSpendCents; + + /// Average cost per shift in cents. + final int averageShiftCostCents; + + /// Serialises this [ForecastWeek] to a JSON map. + Map toJson() { + return { + 'week': week.toIso8601String(), + 'shiftCount': shiftCount, + 'workerHours': workerHours, + 'forecastSpendCents': forecastSpendCents, + 'averageShiftCostCents': averageShiftCostCents, + }; + } @override List get props => [ - weekNumber, - totalCost, - shiftsCount, - hoursCount, - avgCostPerShift, + week, + shiftCount, + workerHours, + forecastSpendCents, + averageShiftCostCents, ]; } - diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/no_show_report.dart b/apps/mobile/packages/domain/lib/src/entities/reports/no_show_report.dart index 5e6f9fe7..f4f9047c 100644 --- a/apps/mobile/packages/domain/lib/src/entities/reports/no_show_report.dart +++ b/apps/mobile/packages/domain/lib/src/entities/reports/no_show_report.dart @@ -1,35 +1,174 @@ -// 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'; +/// No-show report with per-worker incident details. +/// +/// Returned by `GET /client/reports/no-show`. class NoShowReport extends Equatable { - + /// Creates a [NoShowReport] instance. const NoShowReport({ - required this.totalNoShows, - required this.noShowRate, - required this.flaggedWorkers, + required this.totalNoShowCount, + required this.noShowRatePercentage, + required this.workersWhoNoShowed, + this.items = const [], }); - final int totalNoShows; - final double noShowRate; - final List flaggedWorkers; + + /// Deserialises a [NoShowReport] from a V2 API JSON map. + factory NoShowReport.fromJson(Map json) { + final dynamic itemsRaw = json['items']; + final List itemsList = itemsRaw is List + ? itemsRaw + .map((dynamic e) => + NoShowWorkerItem.fromJson(e as Map)) + .toList() + : const []; + + return NoShowReport( + totalNoShowCount: (json['totalNoShowCount'] as num).toInt(), + noShowRatePercentage: (json['noShowRatePercentage'] as num).toInt(), + workersWhoNoShowed: (json['workersWhoNoShowed'] as num).toInt(), + items: itemsList, + ); + } + + /// Total no-show incidents in the period. + final int totalNoShowCount; + + /// No-show rate as a percentage (0-100). + final int noShowRatePercentage; + + /// Distinct workers who had at least one no-show. + final int workersWhoNoShowed; + + /// Per-worker breakdown with individual incidents. + final List items; + + /// Serialises this [NoShowReport] to a JSON map. + Map toJson() { + return { + 'totalNoShowCount': totalNoShowCount, + 'noShowRatePercentage': noShowRatePercentage, + 'workersWhoNoShowed': workersWhoNoShowed, + 'items': items.map((NoShowWorkerItem i) => i.toJson()).toList(), + }; + } @override - List get props => [totalNoShows, noShowRate, flaggedWorkers]; + List get props => [ + totalNoShowCount, + noShowRatePercentage, + workersWhoNoShowed, + items, + ]; } -class NoShowWorker extends Equatable { - - const NoShowWorker({ - required this.id, - required this.fullName, - required this.noShowCount, - required this.reliabilityScore, +/// A worker with no-show incidents. +class NoShowWorkerItem extends Equatable { + /// Creates a [NoShowWorkerItem] instance. + const NoShowWorkerItem({ + required this.staffId, + required this.staffName, + required this.incidentCount, + required this.riskStatus, + this.incidents = const [], }); - final String id; - final String fullName; - final int noShowCount; - final double reliabilityScore; + + /// Deserialises a [NoShowWorkerItem] from a V2 API JSON map. + factory NoShowWorkerItem.fromJson(Map json) { + final dynamic incidentsRaw = json['incidents']; + final List incidentsList = incidentsRaw is List + ? incidentsRaw + .map((dynamic e) => + NoShowIncident.fromJson(e as Map)) + .toList() + : const []; + + return NoShowWorkerItem( + staffId: json['staffId'] as String, + staffName: json['staffName'] as String, + incidentCount: (json['incidentCount'] as num).toInt(), + riskStatus: json['riskStatus'] as String, + incidents: incidentsList, + ); + } + + /// Staff member ID. + final String staffId; + + /// Staff display name. + final String staffName; + + /// Total no-show count. + final int incidentCount; + + /// Risk level (HIGH, MEDIUM). + final String riskStatus; + + /// Individual no-show incidents. + final List incidents; + + /// Serialises this [NoShowWorkerItem] to a JSON map. + Map toJson() { + return { + 'staffId': staffId, + 'staffName': staffName, + 'incidentCount': incidentCount, + 'riskStatus': riskStatus, + 'incidents': incidents.map((NoShowIncident i) => i.toJson()).toList(), + }; + } @override - List get props => [id, fullName, noShowCount, reliabilityScore]; + List get props => [ + staffId, + staffName, + incidentCount, + riskStatus, + incidents, + ]; } +/// A single no-show incident. +class NoShowIncident extends Equatable { + /// Creates a [NoShowIncident] instance. + const NoShowIncident({ + required this.shiftId, + required this.shiftTitle, + required this.roleName, + required this.date, + }); + + /// Deserialises a [NoShowIncident] from a V2 API JSON map. + factory NoShowIncident.fromJson(Map json) { + return NoShowIncident( + shiftId: json['shiftId'] as String, + shiftTitle: json['shiftTitle'] as String, + roleName: json['roleName'] as String, + date: DateTime.parse(json['date'] as String), + ); + } + + /// Shift ID. + final String shiftId; + + /// Shift title. + final String shiftTitle; + + /// Role name. + final String roleName; + + /// Date of the incident. + final DateTime date; + + /// Serialises this [NoShowIncident] to a JSON map. + Map toJson() { + return { + 'shiftId': shiftId, + 'shiftTitle': shiftTitle, + 'roleName': roleName, + 'date': date.toIso8601String(), + }; + } + + @override + List get props => [shiftId, shiftTitle, roleName, date]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/performance_report.dart b/apps/mobile/packages/domain/lib/src/entities/reports/performance_report.dart index 51bb79b5..1e29bedc 100644 --- a/apps/mobile/packages/domain/lib/src/entities/reports/performance_report.dart +++ b/apps/mobile/packages/domain/lib/src/entities/reports/performance_report.dart @@ -1,37 +1,78 @@ -// 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'; +/// Workforce performance report. +/// +/// Returned by `GET /client/reports/performance`. class PerformanceReport extends Equatable { - + /// Creates a [PerformanceReport] instance. const PerformanceReport({ - required this.fillRate, - required this.completionRate, - required this.onTimeRate, - required this.avgFillTimeHours, - required this.keyPerformanceIndicators, + required this.averagePerformanceScore, + required this.fillRatePercentage, + required this.completionRatePercentage, + required this.onTimeRatePercentage, + required this.averageFillTimeMinutes, + required this.totalShiftsCovered, + required this.noShowRatePercentage, }); - final double fillRate; - final double completionRate; - final double onTimeRate; - final double avgFillTimeHours; // in hours - final List keyPerformanceIndicators; + + /// Deserialises a [PerformanceReport] from a V2 API JSON map. + factory PerformanceReport.fromJson(Map json) { + return PerformanceReport( + averagePerformanceScore: + (json['averagePerformanceScore'] as num).toDouble(), + fillRatePercentage: (json['fillRatePercentage'] as num).toInt(), + completionRatePercentage: + (json['completionRatePercentage'] as num).toInt(), + onTimeRatePercentage: (json['onTimeRatePercentage'] as num).toInt(), + averageFillTimeMinutes: + (json['averageFillTimeMinutes'] as num).toDouble(), + totalShiftsCovered: (json['totalShiftsCovered'] as num).toInt(), + noShowRatePercentage: (json['noShowRatePercentage'] as num).toInt(), + ); + } + + /// Average staff review score (0-5). + final double averagePerformanceScore; + + /// Percentage of shifts fully filled (0-100). + final int fillRatePercentage; + + /// Percentage of shifts completed (0-100). + final int completionRatePercentage; + + /// Percentage of on-time arrivals (0-100). + final int onTimeRatePercentage; + + /// Average time to fill a shift in minutes. + final double averageFillTimeMinutes; + + /// Total shifts that were completed/covered. + final int totalShiftsCovered; + + /// No-show rate as a percentage (0-100). + final int noShowRatePercentage; + + /// Serialises this [PerformanceReport] to a JSON map. + Map toJson() { + return { + 'averagePerformanceScore': averagePerformanceScore, + 'fillRatePercentage': fillRatePercentage, + 'completionRatePercentage': completionRatePercentage, + 'onTimeRatePercentage': onTimeRatePercentage, + 'averageFillTimeMinutes': averageFillTimeMinutes, + 'totalShiftsCovered': totalShiftsCovered, + 'noShowRatePercentage': noShowRatePercentage, + }; + } @override - List get props => [fillRate, completionRate, onTimeRate, avgFillTimeHours, keyPerformanceIndicators]; + List get props => [ + averagePerformanceScore, + fillRatePercentage, + completionRatePercentage, + onTimeRatePercentage, + averageFillTimeMinutes, + totalShiftsCovered, + noShowRatePercentage, + ]; } - -class PerformanceMetric extends Equatable { // e.g. 0.05 for +5% - - const PerformanceMetric({ - required this.label, - required this.value, - required this.trend, - }); - final String label; - final String value; - final double trend; - - @override - List get props => [label, value, trend]; -} - diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/report_summary.dart b/apps/mobile/packages/domain/lib/src/entities/reports/report_summary.dart new file mode 100644 index 00000000..0ef8cdec --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/reports/report_summary.dart @@ -0,0 +1,71 @@ +import 'package:equatable/equatable.dart'; + +/// High-level summary of all report metrics. +/// +/// Returned by `GET /client/reports/summary`. +class ReportSummary extends Equatable { + /// Creates a [ReportSummary] instance. + const ReportSummary({ + required this.totalShifts, + required this.totalSpendCents, + required this.averageCoveragePercentage, + required this.averagePerformanceScore, + required this.noShowCount, + required this.forecastAccuracyPercentage, + }); + + /// Deserialises a [ReportSummary] from a V2 API JSON map. + factory ReportSummary.fromJson(Map json) { + return ReportSummary( + totalShifts: (json['totalShifts'] as num).toInt(), + totalSpendCents: (json['totalSpendCents'] as num).toInt(), + averageCoveragePercentage: + (json['averageCoveragePercentage'] as num).toInt(), + averagePerformanceScore: + (json['averagePerformanceScore'] as num).toDouble(), + noShowCount: (json['noShowCount'] as num).toInt(), + forecastAccuracyPercentage: + (json['forecastAccuracyPercentage'] as num).toInt(), + ); + } + + /// Total number of shifts in the period. + final int totalShifts; + + /// Total spend in cents. + final int totalSpendCents; + + /// Average coverage percentage (0-100). + final int averageCoveragePercentage; + + /// Average performance score (0-5). + final double averagePerformanceScore; + + /// Total no-show incidents. + final int noShowCount; + + /// Forecast accuracy percentage. + final int forecastAccuracyPercentage; + + /// Serialises this [ReportSummary] to a JSON map. + Map toJson() { + return { + 'totalShifts': totalShifts, + 'totalSpendCents': totalSpendCents, + 'averageCoveragePercentage': averageCoveragePercentage, + 'averagePerformanceScore': averagePerformanceScore, + 'noShowCount': noShowCount, + 'forecastAccuracyPercentage': forecastAccuracyPercentage, + }; + } + + @override + List get props => [ + totalShifts, + totalSpendCents, + averageCoveragePercentage, + averagePerformanceScore, + noShowCount, + forecastAccuracyPercentage, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/reports_summary.dart b/apps/mobile/packages/domain/lib/src/entities/reports/reports_summary.dart deleted file mode 100644 index 0fb635e5..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/reports/reports_summary.dart +++ /dev/null @@ -1,31 +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:equatable/equatable.dart'; - -class ReportsSummary extends Equatable { - - const ReportsSummary({ - required this.totalHours, - required this.otHours, - required this.totalSpend, - required this.fillRate, - required this.avgFillTimeHours, - required this.noShowRate, - }); - final double totalHours; - final double otHours; - final double totalSpend; - final double fillRate; - final double avgFillTimeHours; - final double noShowRate; - - @override - List get props => [ - totalHours, - otHours, - totalSpend, - fillRate, - avgFillTimeHours, - noShowRate, - ]; -} - diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/spend_data_point.dart b/apps/mobile/packages/domain/lib/src/entities/reports/spend_data_point.dart new file mode 100644 index 00000000..30480fae --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/reports/spend_data_point.dart @@ -0,0 +1,95 @@ +import 'package:equatable/equatable.dart'; + +import '../financial/spend_item.dart'; + +/// Spend report with total, chart data points, and category breakdown. +/// +/// Returned by `GET /client/reports/spend`. +class SpendReport extends Equatable { + /// Creates a [SpendReport] instance. + const SpendReport({ + required this.totalSpendCents, + this.chart = const [], + this.breakdown = const [], + }); + + /// Deserialises a [SpendReport] from a V2 API JSON map. + factory SpendReport.fromJson(Map json) { + final dynamic chartRaw = json['chart']; + final List chartList = chartRaw is List + ? chartRaw + .map((dynamic e) => + SpendDataPoint.fromJson(e as Map)) + .toList() + : const []; + + final dynamic breakdownRaw = json['breakdown']; + final List breakdownList = breakdownRaw is List + ? breakdownRaw + .map((dynamic e) => + SpendItem.fromJson(e as Map)) + .toList() + : const []; + + return SpendReport( + totalSpendCents: (json['totalSpendCents'] as num).toInt(), + chart: chartList, + breakdown: breakdownList, + ); + } + + /// Total spend in cents for the period. + final int totalSpendCents; + + /// Time-series chart data. + final List chart; + + /// Category breakdown. + final List breakdown; + + /// Serialises this [SpendReport] to a JSON map. + Map toJson() { + return { + 'totalSpendCents': totalSpendCents, + 'chart': chart.map((SpendDataPoint p) => p.toJson()).toList(), + 'breakdown': breakdown.map((SpendItem i) => i.toJson()).toList(), + }; + } + + @override + List get props => [totalSpendCents, chart, breakdown]; +} + +/// A single data point in the spend chart. +class SpendDataPoint extends Equatable { + /// Creates a [SpendDataPoint] instance. + const SpendDataPoint({ + required this.bucket, + required this.amountCents, + }); + + /// Deserialises a [SpendDataPoint] from a V2 API JSON map. + factory SpendDataPoint.fromJson(Map json) { + return SpendDataPoint( + bucket: DateTime.parse(json['bucket'] as String), + amountCents: (json['amountCents'] as num).toInt(), + ); + } + + /// Time bucket (day or week). + final DateTime bucket; + + /// Amount in cents for this bucket. + final int amountCents; + + /// Serialises this [SpendDataPoint] to a JSON map. + Map toJson() { + return { + 'bucket': bucket.toIso8601String(), + 'amountCents': amountCents, + }; + } + + @override + List get props => [bucket, amountCents]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/reports/spend_report.dart b/apps/mobile/packages/domain/lib/src/entities/reports/spend_report.dart deleted file mode 100644 index 8594fe96..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/reports/spend_report.dart +++ /dev/null @@ -1,85 +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:equatable/equatable.dart'; - -class SpendReport extends Equatable { - - const SpendReport({ - required this.totalSpend, - required this.averageCost, - required this.paidInvoices, - required this.pendingInvoices, - required this.overdueInvoices, - required this.invoices, - required this.chartData, - required this.industryBreakdown, - }); - final double totalSpend; - final double averageCost; - final int paidInvoices; - final int pendingInvoices; - final int overdueInvoices; - final List invoices; - final List chartData; - final List industryBreakdown; - - @override - List get props => [ - totalSpend, - averageCost, - paidInvoices, - pendingInvoices, - overdueInvoices, - invoices, - chartData, - industryBreakdown, - ]; -} - -class SpendIndustryCategory extends Equatable { - - const SpendIndustryCategory({ - required this.name, - required this.amount, - required this.percentage, - }); - final String name; - final double amount; - final double percentage; - - @override - List get props => [name, amount, percentage]; -} - -class SpendInvoice extends Equatable { - - const SpendInvoice({ - required this.id, - required this.invoiceNumber, - required this.issueDate, - required this.amount, - required this.status, - required this.vendorName, - this.industry, - }); - final String id; - final String invoiceNumber; - final DateTime issueDate; - final double amount; - final String status; - final String vendorName; - final String? industry; - - @override - List get props => [id, invoiceNumber, issueDate, amount, status, vendorName, industry]; -} - -class SpendChartPoint extends Equatable { - - const SpendChartPoint({required this.date, required this.amount}); - final DateTime date; - final double amount; - - @override - List get props => [date, amount]; -} - diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/assigned_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/assigned_shift.dart new file mode 100644 index 00000000..a41371be --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/assigned_shift.dart @@ -0,0 +1,100 @@ +import 'package:equatable/equatable.dart'; + +import 'package:krow_domain/src/entities/enums/assignment_status.dart'; +import 'package:krow_domain/src/entities/enums/order_type.dart'; + +/// A shift that has been assigned to the staff member. +/// +/// Returned by `GET /staff/shifts/assigned`. Represents an upcoming or +/// in-progress assignment with scheduling and pay information. +class AssignedShift extends Equatable { + /// Creates an [AssignedShift]. + const AssignedShift({ + required this.assignmentId, + required this.shiftId, + required this.roleName, + required this.location, + required this.date, + required this.startTime, + required this.endTime, + required this.hourlyRateCents, + required this.orderType, + required this.status, + }); + + /// Deserialises from the V2 API JSON response. + factory AssignedShift.fromJson(Map json) { + return AssignedShift( + assignmentId: json['assignmentId'] as String, + shiftId: json['shiftId'] as String, + roleName: json['roleName'] as String, + location: json['location'] as String? ?? '', + date: DateTime.parse(json['date'] as String), + startTime: DateTime.parse(json['startTime'] as String), + endTime: DateTime.parse(json['endTime'] as String), + hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, + orderType: OrderType.fromJson(json['orderType'] as String?), + status: AssignmentStatus.fromJson(json['status'] as String?), + ); + } + + /// The assignment row id. + final String assignmentId; + + /// The shift row id. + final String shiftId; + + /// Display name of the role the worker is filling. + final String roleName; + + /// Human-readable location label. + final String location; + + /// The date of the shift (same as startTime but kept for grouping). + final DateTime date; + + /// Scheduled start time. + final DateTime startTime; + + /// Scheduled end time. + final DateTime endTime; + + /// Pay rate in cents per hour. + final int hourlyRateCents; + + /// Order type. + final OrderType orderType; + + /// Assignment status. + final AssignmentStatus status; + + /// Serialises to JSON. + Map toJson() { + return { + 'assignmentId': assignmentId, + 'shiftId': shiftId, + 'roleName': roleName, + 'location': location, + 'date': date.toIso8601String(), + 'startTime': startTime.toIso8601String(), + 'endTime': endTime.toIso8601String(), + 'hourlyRateCents': hourlyRateCents, + 'orderType': orderType.toJson(), + 'status': status.toJson(), + }; + } + + @override + List get props => [ + assignmentId, + shiftId, + roleName, + location, + date, + startTime, + endTime, + hourlyRateCents, + orderType, + status, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/break/break.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/break/break.dart deleted file mode 100644 index b90750bd..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/break/break.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Enum representing common break durations in minutes. -enum BreakDuration { - /// No break. - none(0), - - /// 10 minutes break. - ten(10), - - /// 15 minutes break. - fifteen(15), - - /// 20 minutes break. - twenty(20), - - /// 30 minutes break. - thirty(30), - - /// 45 minutes break. - fortyFive(45), - - /// 60 minutes break. - sixty(60); - - /// The duration in minutes. - final int minutes; - - const BreakDuration(this.minutes); -} - -/// Represents a break configuration for a shift. -class Break extends Equatable { - const Break({ - required this.duration, - required this.isBreakPaid, - }); - - /// The duration of the break. - final BreakDuration duration; - - /// Whether the break is paid or unpaid. - final bool isBreakPaid; - - @override - List get props => [duration, isBreakPaid]; -} diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/cancelled_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/cancelled_shift.dart new file mode 100644 index 00000000..6fb4741d --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/cancelled_shift.dart @@ -0,0 +1,69 @@ +import 'package:equatable/equatable.dart'; + +/// A shift whose assignment was cancelled. +/// +/// Returned by `GET /staff/shifts/cancelled`. Shows past assignments +/// that were cancelled along with the reason, if provided. +class CancelledShift extends Equatable { + /// Creates a [CancelledShift]. + const CancelledShift({ + required this.assignmentId, + required this.shiftId, + required this.title, + required this.location, + required this.date, + this.cancellationReason, + }); + + /// Deserialises from the V2 API JSON response. + factory CancelledShift.fromJson(Map json) { + return CancelledShift( + assignmentId: json['assignmentId'] as String, + shiftId: json['shiftId'] as String, + title: json['title'] as String? ?? '', + location: json['location'] as String? ?? '', + date: DateTime.parse(json['date'] as String), + cancellationReason: json['cancellationReason'] as String?, + ); + } + + /// The assignment row id. + final String assignmentId; + + /// The shift row id. + final String shiftId; + + /// Display title of the shift. + final String title; + + /// Human-readable location label. + final String location; + + /// The original date of the shift. + final DateTime date; + + /// Reason for cancellation, from assignment metadata. + final String? cancellationReason; + + /// Serialises to JSON. + Map toJson() { + return { + 'assignmentId': assignmentId, + 'shiftId': shiftId, + 'title': title, + 'location': location, + 'date': date.toIso8601String(), + 'cancellationReason': cancellationReason, + }; + } + + @override + List get props => [ + assignmentId, + shiftId, + title, + location, + date, + cancellationReason, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart new file mode 100644 index 00000000..01b6f005 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart @@ -0,0 +1,78 @@ +import 'package:equatable/equatable.dart'; + +import 'package:krow_domain/src/entities/enums/payment_status.dart'; + +/// A shift the staff member has completed. +/// +/// Returned by `GET /staff/shifts/completed`. Includes worked time and +/// payment tracking status. +class CompletedShift extends Equatable { + /// Creates a [CompletedShift]. + const CompletedShift({ + required this.assignmentId, + required this.shiftId, + required this.title, + required this.location, + required this.date, + required this.minutesWorked, + required this.paymentStatus, + }); + + /// Deserialises from the V2 API JSON response. + factory CompletedShift.fromJson(Map json) { + return CompletedShift( + assignmentId: json['assignmentId'] as String, + shiftId: json['shiftId'] as String, + title: json['title'] as String? ?? '', + location: json['location'] as String? ?? '', + date: DateTime.parse(json['date'] as String), + minutesWorked: json['minutesWorked'] as int? ?? 0, + paymentStatus: PaymentStatus.fromJson(json['paymentStatus'] as String?), + ); + } + + /// The assignment row id. + final String assignmentId; + + /// The shift row id. + final String shiftId; + + /// Display title of the shift. + final String title; + + /// Human-readable location label. + final String location; + + /// The date the shift was worked. + final DateTime date; + + /// Total minutes worked (regular + overtime). + final int minutesWorked; + + /// Payment processing status. + final PaymentStatus paymentStatus; + + /// Serialises to JSON. + Map toJson() { + return { + 'assignmentId': assignmentId, + 'shiftId': shiftId, + 'title': title, + 'location': location, + 'date': date.toIso8601String(), + 'minutesWorked': minutesWorked, + 'paymentStatus': paymentStatus.toJson(), + }; + } + + @override + List get props => [ + assignmentId, + shiftId, + title, + location, + date, + minutesWorked, + paymentStatus, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/open_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/open_shift.dart new file mode 100644 index 00000000..8481b343 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/open_shift.dart @@ -0,0 +1,106 @@ +import 'package:equatable/equatable.dart'; + +import 'package:krow_domain/src/entities/enums/order_type.dart'; + +/// An open shift available for the staff member to apply to. +/// +/// Returned by `GET /staff/shifts/open`. Includes both genuinely open +/// shift-roles and swap-requested assignments from other workers. +class OpenShift extends Equatable { + /// Creates an [OpenShift]. + const OpenShift({ + required this.shiftId, + required this.roleId, + required this.roleName, + required this.location, + required this.date, + required this.startTime, + required this.endTime, + required this.hourlyRateCents, + required this.orderType, + required this.instantBook, + required this.requiredWorkerCount, + }); + + /// Deserialises from the V2 API JSON response. + factory OpenShift.fromJson(Map json) { + return OpenShift( + shiftId: json['shiftId'] as String, + roleId: json['roleId'] as String, + roleName: json['roleName'] as String, + location: json['location'] as String? ?? '', + date: DateTime.parse(json['date'] as String), + startTime: DateTime.parse(json['startTime'] as String), + endTime: DateTime.parse(json['endTime'] as String), + hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, + orderType: OrderType.fromJson(json['orderType'] as String?), + instantBook: json['instantBook'] as bool? ?? false, + requiredWorkerCount: json['requiredWorkerCount'] as int? ?? 1, + ); + } + + /// The shift row id. + final String shiftId; + + /// The shift-role row id. + final String roleId; + + /// Display name of the role. + final String roleName; + + /// Human-readable location label. + final String location; + + /// Date of the shift. + final DateTime date; + + /// Scheduled start time. + final DateTime startTime; + + /// Scheduled end time. + final DateTime endTime; + + /// Pay rate in cents per hour. + final int hourlyRateCents; + + /// Order type. + final OrderType orderType; + + /// Whether the shift supports instant booking (no approval needed). + final bool instantBook; + + /// Number of workers still required for this role. + final int requiredWorkerCount; + + /// Serialises to JSON. + Map toJson() { + return { + 'shiftId': shiftId, + 'roleId': roleId, + 'roleName': roleName, + 'location': location, + 'date': date.toIso8601String(), + 'startTime': startTime.toIso8601String(), + 'endTime': endTime.toIso8601String(), + 'hourlyRateCents': hourlyRateCents, + 'orderType': orderType.toJson(), + 'instantBook': instantBook, + 'requiredWorkerCount': requiredWorkerCount, + }; + } + + @override + List get props => [ + shiftId, + roleId, + roleName, + location, + date, + startTime, + endTime, + hourlyRateCents, + orderType, + instantBook, + requiredWorkerCount, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/pending_assignment.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/pending_assignment.dart new file mode 100644 index 00000000..b22d6bc4 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/pending_assignment.dart @@ -0,0 +1,83 @@ +import 'package:equatable/equatable.dart'; + +/// An assignment awaiting the staff member's acceptance. +/// +/// Returned by `GET /staff/shifts/pending`. These are assignments with +/// status `ASSIGNED` that require the worker to accept or decline. +class PendingAssignment extends Equatable { + /// Creates a [PendingAssignment]. + const PendingAssignment({ + required this.assignmentId, + required this.shiftId, + required this.title, + required this.roleName, + required this.startTime, + required this.endTime, + required this.location, + required this.responseDeadline, + }); + + /// Deserialises from the V2 API JSON response. + factory PendingAssignment.fromJson(Map json) { + return PendingAssignment( + assignmentId: json['assignmentId'] as String, + shiftId: json['shiftId'] as String, + title: json['title'] as String? ?? '', + roleName: json['roleName'] as String, + startTime: DateTime.parse(json['startTime'] as String), + endTime: DateTime.parse(json['endTime'] as String), + location: json['location'] as String? ?? '', + responseDeadline: DateTime.parse(json['responseDeadline'] as String), + ); + } + + /// The assignment row id. + final String assignmentId; + + /// The shift row id. + final String shiftId; + + /// Display title of the shift. + final String title; + + /// Display name of the role the worker is filling. + final String roleName; + + /// Scheduled start time. + final DateTime startTime; + + /// Scheduled end time. + final DateTime endTime; + + /// Human-readable location label. + final String location; + + /// Deadline by which the worker must respond. + final DateTime responseDeadline; + + /// Serialises to JSON. + Map toJson() { + return { + 'assignmentId': assignmentId, + 'shiftId': shiftId, + 'title': title, + 'roleName': roleName, + 'startTime': startTime.toIso8601String(), + 'endTime': endTime.toIso8601String(), + 'location': location, + 'responseDeadline': responseDeadline.toIso8601String(), + }; + } + + @override + List get props => [ + assignmentId, + shiftId, + title, + roleName, + startTime, + endTime, + location, + responseDeadline, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart index a6d6fdeb..4d6f11cd 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart @@ -1,145 +1,143 @@ import 'package:equatable/equatable.dart'; -import 'package:krow_domain/src/entities/shifts/break/break.dart'; +import 'package:krow_domain/src/entities/enums/shift_status.dart'; + +/// Core shift entity aligned with the V2 `shifts` table. +/// +/// This entity is used by the clock-in feature and other contexts where +/// the full shift record (including geolocation) is needed. For +/// list-view-specific shapes see [TodayShift], [AssignedShift], etc. class Shift extends Equatable { + /// Creates a [Shift]. const Shift({ required this.id, + required this.orderId, required this.title, - required this.clientName, - this.logoUrl, - required this.hourlyRate, - required this.location, - required 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, + required this.status, + required this.startsAt, + required this.endsAt, + this.timezone = 'UTC', + this.locationName, + this.locationAddress, this.latitude, this.longitude, - this.status, - this.durationDays, - this.requiredSlots, - this.filledSlots, - this.roleId, - this.hasApplied, - this.totalValue, - this.breakInfo, - this.orderId, - this.orderType, - this.startDate, - this.endDate, - this.recurringDays, - this.permanentDays, - this.schedules, + this.geofenceRadiusMeters, + required this.requiredWorkers, + required this.assignedWorkers, + this.notes, }); + /// Deserialises from the V2 API JSON response. + factory Shift.fromJson(Map json) { + return Shift( + id: json['id'] as String, + orderId: json['orderId'] as String, + title: json['title'] as String? ?? '', + status: ShiftStatus.fromJson(json['status'] as String?), + startsAt: DateTime.parse(json['startsAt'] as String), + endsAt: DateTime.parse(json['endsAt'] as String), + timezone: json['timezone'] as String? ?? 'UTC', + locationName: json['locationName'] as String?, + locationAddress: json['locationAddress'] as String?, + latitude: _parseDouble(json['latitude']), + longitude: _parseDouble(json['longitude']), + geofenceRadiusMeters: json['geofenceRadiusMeters'] as int?, + requiredWorkers: json['requiredWorkers'] as int? ?? 1, + assignedWorkers: json['assignedWorkers'] as int? ?? 0, + notes: json['notes'] as String?, + ); + } + + /// The shift row id. final String id; + + /// The parent order id. + final String orderId; + + /// Display title. 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; + + /// Shift lifecycle status. + final ShiftStatus status; + + /// Scheduled start timestamp. + final DateTime startsAt; + + /// Scheduled end timestamp. + final DateTime endsAt; + + /// IANA timezone identifier (e.g. `America/New_York`). + final String timezone; + + /// Location display name (from clock-point label or shift). + final String? locationName; + + /// Street address. + final String? locationAddress; + + /// Latitude for geofence validation. final double? latitude; + + /// Longitude for geofence validation. final double? longitude; - final String? status; - final int? durationDays; // For multi-day shifts - final int? requiredSlots; - final int? filledSlots; - final String? roleId; - final bool? hasApplied; - final double? totalValue; - final Break? breakInfo; - final String? orderId; - final String? orderType; - final String? startDate; - final String? endDate; - final List? recurringDays; - final List? permanentDays; - final List? schedules; + + /// Geofence radius in meters; null means use clock-point default. + final int? geofenceRadiusMeters; + + /// Total workers required across all roles. + final int requiredWorkers; + + /// Workers currently assigned. + final int assignedWorkers; + + /// Free-form notes for the shift. + final String? notes; + + /// Serialises to JSON. + Map toJson() { + return { + 'id': id, + 'orderId': orderId, + 'title': title, + 'status': status.toJson(), + 'startsAt': startsAt.toIso8601String(), + 'endsAt': endsAt.toIso8601String(), + 'timezone': timezone, + 'locationName': locationName, + 'locationAddress': locationAddress, + 'latitude': latitude, + 'longitude': longitude, + 'geofenceRadiusMeters': geofenceRadiusMeters, + 'requiredWorkers': requiredWorkers, + 'assignedWorkers': assignedWorkers, + 'notes': notes, + }; + } + + static double? _parseDouble(dynamic value) { + if (value == null) return null; + if (value is double) return value; + if (value is int) return value.toDouble(); + if (value is String) return double.tryParse(value); + return null; + } @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, - requiredSlots, - filledSlots, - roleId, - hasApplied, - totalValue, - breakInfo, - orderId, - orderType, - startDate, - endDate, - recurringDays, - permanentDays, - schedules, - ]; -} - -class ShiftSchedule extends Equatable { - const ShiftSchedule({ - required this.date, - required this.startTime, - required this.endTime, - }); - - final String date; - final String startTime; - final String endTime; - - @override - List get props => [date, startTime, endTime]; -} - -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]; + id, + orderId, + title, + status, + startsAt, + endsAt, + timezone, + locationName, + locationAddress, + latitude, + longitude, + geofenceRadiusMeters, + requiredWorkers, + assignedWorkers, + notes, + ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart new file mode 100644 index 00000000..793dc07e --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart @@ -0,0 +1,149 @@ +import 'package:equatable/equatable.dart'; + +import 'package:krow_domain/src/entities/enums/application_status.dart'; +import 'package:krow_domain/src/entities/enums/assignment_status.dart'; +import 'package:krow_domain/src/entities/enums/order_type.dart'; + +/// Full detail view of a shift for the staff member. +/// +/// Returned by `GET /staff/shifts/:shiftId`. Contains everything needed +/// to render the shift details page including location coordinates for +/// the map, pay information, and the worker's own assignment/application +/// status if applicable. +class ShiftDetail extends Equatable { + /// Creates a [ShiftDetail]. + const ShiftDetail({ + required this.shiftId, + required this.title, + this.description, + required this.location, + this.address, + required this.date, + required this.startTime, + required this.endTime, + required this.roleId, + required this.roleName, + required this.hourlyRateCents, + required this.orderType, + required this.requiredCount, + required this.confirmedCount, + this.assignmentStatus, + this.applicationStatus, + }); + + /// Deserialises from the V2 API JSON response. + factory ShiftDetail.fromJson(Map json) { + return ShiftDetail( + shiftId: json['shiftId'] as String, + title: json['title'] as String? ?? '', + description: json['description'] as String?, + location: json['location'] as String? ?? '', + address: json['address'] as String?, + date: DateTime.parse(json['date'] as String), + startTime: DateTime.parse(json['startTime'] as String), + endTime: DateTime.parse(json['endTime'] as String), + roleId: json['roleId'] as String, + roleName: json['roleName'] as String, + hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, + orderType: OrderType.fromJson(json['orderType'] as String?), + requiredCount: json['requiredCount'] as int? ?? 1, + confirmedCount: json['confirmedCount'] as int? ?? 0, + assignmentStatus: json['assignmentStatus'] != null + ? AssignmentStatus.fromJson(json['assignmentStatus'] as String?) + : null, + applicationStatus: json['applicationStatus'] != null + ? ApplicationStatus.fromJson(json['applicationStatus'] as String?) + : null, + ); + } + + /// The shift row id. + final String shiftId; + + /// Display title of the shift. + final String title; + + /// Optional description from the order. + final String? description; + + /// Human-readable location label. + final String location; + + /// Street address of the shift location. + final String? address; + + /// Date of the shift (same as startTime, kept for display grouping). + final DateTime date; + + /// Scheduled start time. + final DateTime startTime; + + /// Scheduled end time. + final DateTime endTime; + + /// The shift-role row id. + final String roleId; + + /// Display name of the role. + final String roleName; + + /// Pay rate in cents per hour. + final int hourlyRateCents; + + /// Order type. + final OrderType orderType; + + /// Total workers required for this shift-role. + final int requiredCount; + + /// Workers already confirmed for this shift-role. + final int confirmedCount; + + /// Current worker's assignment status, if assigned. + final AssignmentStatus? assignmentStatus; + + /// Current worker's application status, if applied. + final ApplicationStatus? applicationStatus; + + /// Serialises to JSON. + Map toJson() { + return { + 'shiftId': shiftId, + 'title': title, + 'description': description, + 'location': location, + 'address': address, + 'date': date.toIso8601String(), + 'startTime': startTime.toIso8601String(), + 'endTime': endTime.toIso8601String(), + 'roleId': roleId, + 'roleName': roleName, + 'hourlyRateCents': hourlyRateCents, + 'orderType': orderType.toJson(), + 'requiredCount': requiredCount, + 'confirmedCount': confirmedCount, + 'assignmentStatus': assignmentStatus?.toJson(), + 'applicationStatus': applicationStatus?.toJson(), + }; + } + + @override + List get props => [ + shiftId, + title, + description, + location, + address, + date, + startTime, + endTime, + roleId, + roleName, + hourlyRateCents, + orderType, + requiredCount, + confirmedCount, + assignmentStatus, + applicationStatus, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/today_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/today_shift.dart new file mode 100644 index 00000000..e1520964 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/today_shift.dart @@ -0,0 +1,88 @@ +import 'package:equatable/equatable.dart'; + +import 'package:krow_domain/src/entities/enums/attendance_status_type.dart'; + +/// A shift assigned to the staff member for today. +/// +/// Returned by `GET /staff/clock-in/shifts/today`. Includes attendance +/// status so the clock-in screen can show whether the worker has already +/// checked in. +class TodayShift extends Equatable { + /// Creates a [TodayShift]. + const TodayShift({ + required this.assignmentId, + required this.shiftId, + required this.roleName, + required this.location, + required this.startTime, + required this.endTime, + required this.attendanceStatus, + this.clockInAt, + }); + + /// Deserialises from the V2 API JSON response. + factory TodayShift.fromJson(Map json) { + return TodayShift( + assignmentId: json['assignmentId'] as String, + shiftId: json['shiftId'] as String, + roleName: json['roleName'] as String, + location: json['location'] as String? ?? '', + startTime: DateTime.parse(json['startTime'] as String), + endTime: DateTime.parse(json['endTime'] as String), + attendanceStatus: AttendanceStatusType.fromJson(json['attendanceStatus'] as String?), + clockInAt: json['clockInAt'] != null + ? DateTime.parse(json['clockInAt'] as String) + : null, + ); + } + + /// The assignment row id. + final String assignmentId; + + /// The shift row id. + final String shiftId; + + /// Display name of the role the worker is filling. + final String roleName; + + /// Human-readable location label (clock-point or shift location). + final String location; + + /// Scheduled start time. + final DateTime startTime; + + /// Scheduled end time. + final DateTime endTime; + + /// Current attendance status. + final AttendanceStatusType attendanceStatus; + + /// Timestamp of clock-in, if any. + final DateTime? clockInAt; + + /// Serialises to JSON. + Map toJson() { + return { + 'assignmentId': assignmentId, + 'shiftId': shiftId, + 'roleName': roleName, + 'location': location, + 'startTime': startTime.toIso8601String(), + 'endTime': endTime.toIso8601String(), + 'attendanceStatus': attendanceStatus.toJson(), + 'clockInAt': clockInAt?.toIso8601String(), + }; + } + + @override + List get props => [ + assignmentId, + shiftId, + roleName, + location, + startTime, + endTime, + attendanceStatus, + clockInAt, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/skills/certificate.dart b/apps/mobile/packages/domain/lib/src/entities/skills/certificate.dart deleted file mode 100644 index fd6065f8..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/skills/certificate.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents a required certificate definition. -/// -/// Examples: "Food Hygiene Level 2", "SIA Badge". -class Certificate extends Equatable { - - const Certificate({ - required this.id, - required this.name, - required this.isRequired, - }); - /// Unique identifier. - final String id; - - /// Display name of the certificate. - final String name; - - /// Whether this certificate is mandatory for platform access or specific roles. - final bool isRequired; - - @override - List get props => [id, name, isRequired]; -} \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/src/entities/skills/skill.dart b/apps/mobile/packages/domain/lib/src/entities/skills/skill.dart deleted file mode 100644 index f61b68e7..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/skills/skill.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents a job category / skill type. -/// -/// Examples: "Waiter", "Security Guard", "Bartender". -/// Linked to a [SkillCategory]. -class Skill extends Equatable { - - const Skill({ - required this.id, - required this.categoryId, - required this.name, - required this.basePrice, - }); - /// Unique identifier. - final String id; - - /// The broader category (e.g. "Hospitality"). - final String categoryId; - - /// Display name of the skill. - final String name; - - /// Default hourly rate suggested for this skill. - final double basePrice; - - @override - List get props => [id, categoryId, name, basePrice]; -} \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/src/entities/skills/skill_category.dart b/apps/mobile/packages/domain/lib/src/entities/skills/skill_category.dart deleted file mode 100644 index 091fce05..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/skills/skill_category.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents a broad category of skills (e.g. "Hospitality", "Logistics"). -class SkillCategory extends Equatable { - - const SkillCategory({ - required this.id, - required this.name, - }); - /// Unique identifier. - final String id; - - /// Display name. - final String name; - - @override - List get props => [id, name]; -} \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/src/entities/skills/skill_kit.dart b/apps/mobile/packages/domain/lib/src/entities/skills/skill_kit.dart deleted file mode 100644 index eca88467..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/skills/skill_kit.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents required equipment or uniform for a specific [Skill]. -/// -/// Examples: "Black Shirt" (Uniform), "Safety Boots" (Equipment). -class SkillKit extends Equatable { - - const SkillKit({ - required this.id, - required this.skillId, - required this.name, - required this.isRequired, - required this.type, - }); - /// Unique identifier. - final String id; - - /// The [Skill] this kit applies to. - final String skillId; - - /// Description of the item. - final String name; - - /// Whether the staff member MUST possess this item. - final bool isRequired; - - /// Type of kit ('uniform' or 'equipment'). - final String type; - - @override - List get props => [id, skillId, name, isRequired, type]; -} \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/src/entities/skills/staff_skill.dart b/apps/mobile/packages/domain/lib/src/entities/skills/staff_skill.dart deleted file mode 100644 index b868c9d7..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/skills/staff_skill.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// The expertise level of a staff member in a specific skill. -enum SkillLevel { - /// Entry level. - beginner, - - /// Experienced. - skilled, - - /// Expert / Managerial level. - professional, -} - -/// The verification status of a claimed skill. -enum StaffSkillStatus { - /// Claimed by staff, waiting for admin approval. - pending, - - /// Verified by admin (documents checked). - verified, - - /// Rejected by admin. - rejected, -} - -/// Represents a staff member's qualification in a specific [Skill]. -class StaffSkill extends Equatable { - - const StaffSkill({ - required this.id, - required this.staffId, - required this.skillId, - required this.level, - required this.experienceYears, - required this.status, - }); - /// Unique identifier. - final String id; - - /// The [Staff] member. - final String staffId; - - /// The [Skill] they possess. - final String skillId; - - /// Their expertise level. - final SkillLevel level; - - /// Years of experience. - final int experienceYears; - - /// Verification status. - final StaffSkillStatus status; - - @override - List get props => [id, staffId, skillId, level, experienceYears, status]; -} \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/src/entities/support/addon.dart b/apps/mobile/packages/domain/lib/src/entities/support/addon.dart deleted file mode 100644 index fd85edba..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/support/addon.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents a financial addon/bonus/deduction applied to an Invoice or Payment. -class Addon extends Equatable { - - const Addon({ - required this.id, - required this.name, - required this.amount, - required this.type, - }); - /// Unique identifier. - final String id; - - /// Description (e.g. "Travel Expense"). - final String name; - - /// Monetary value. - final double amount; - - /// Type ('credit' or 'debit'). - final String type; - - @override - List get props => [id, name, amount, type]; -} \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/src/entities/support/media.dart b/apps/mobile/packages/domain/lib/src/entities/support/media.dart deleted file mode 100644 index 329cdca6..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/support/media.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents a media file reference. -/// -/// Used for avatars, certificates, or event photos. -class Media extends Equatable { - - const Media({ - required this.id, - required this.url, - required this.type, - }); - /// Unique identifier. - final String id; - - /// External URL to the file. - final String url; - - /// MIME type or general type (image, pdf). - final String type; - - @override - List get props => [id, url, type]; -} \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/src/entities/support/tag.dart b/apps/mobile/packages/domain/lib/src/entities/support/tag.dart deleted file mode 100644 index 44d4db9d..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/support/tag.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents a descriptive tag used for categorizing events or staff. -class Tag extends Equatable { - - const Tag({ - required this.id, - required this.label, - }); - /// Unique identifier. - final String id; - - /// Text label. - final String label; - - @override - List get props => [id, label]; -} \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/src/entities/support/working_area.dart b/apps/mobile/packages/domain/lib/src/entities/support/working_area.dart deleted file mode 100644 index aa5d8d56..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/support/working_area.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents a geographical area where a [Staff] member is willing to work. -class WorkingArea extends Equatable { - - const WorkingArea({ - required this.id, - required this.name, - required this.centerLat, - required this.centerLng, - required this.radiusKm, - }); - /// Unique identifier. - final String id; - - /// Name of the area (e.g. "London Zone 1"). - final String name; - - /// Latitude of the center point. - final double centerLat; - - /// Longitude of the center point. - final double centerLng; - - /// Radius in Kilometers. - final double radiusKm; - - @override - List get props => [id, name, centerLat, centerLng, radiusKm]; -} \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/src/entities/users/biz_member.dart b/apps/mobile/packages/domain/lib/src/entities/users/biz_member.dart index 2f3bcf34..ee071a75 100644 --- a/apps/mobile/packages/domain/lib/src/entities/users/biz_member.dart +++ b/apps/mobile/packages/domain/lib/src/entities/users/biz_member.dart @@ -1,28 +1,172 @@ import 'package:equatable/equatable.dart'; -/// Represents a member of a Business. -/// -/// Grants a user access to business-level operations. -class BizMember extends Equatable { +/// Membership status within a business. +enum BusinessMembershipStatus { + /// The user has been invited but has not accepted. + invited, - const BizMember({ + /// The membership is active. + active, + + /// The membership has been suspended. + suspended, + + /// The membership has been removed. + removed, +} + +/// Role within a business. +enum BusinessRole { + /// Full administrative control. + owner, + + /// Can manage operations but not billing. + manager, + + /// Standard team member. + member, + + /// Read-only access. + viewer, +} + +/// Represents a user's membership in a business. +/// +/// Maps to the V2 `business_memberships` table. +class BusinessMembership extends Equatable { + /// Creates a [BusinessMembership] instance. + const BusinessMembership({ required this.id, + required this.tenantId, required this.businessId, - required this.userId, - required this.role, + this.userId, + this.invitedEmail, + required this.membershipStatus, + required this.businessRole, + this.businessName, + this.businessSlug, + this.metadata = const {}, + this.createdAt, + this.updatedAt, }); - /// Unique identifier for this membership. + + /// Deserialises a [BusinessMembership] from a V2 API JSON response. + factory BusinessMembership.fromJson(Map json) { + return BusinessMembership( + id: json['membershipId'] as String, + tenantId: json['tenantId'] as String, + businessId: json['businessId'] as String, + userId: json['userId'] as String?, + invitedEmail: json['invitedEmail'] as String?, + membershipStatus: _parseMembershipStatus(json['membershipStatus'] as String? ?? json['status'] as String?), + businessRole: _parseBusinessRole(json['role'] as String? ?? json['businessRole'] as String?), + businessName: json['businessName'] as String?, + businessSlug: json['businessSlug'] as String?, + metadata: (json['metadata'] as Map?) ?? const {}, + createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'] as String) : null, + updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'] as String) : null, + ); + } + + /// Unique membership ID (UUID). final String id; - /// The [Business] the user belongs to. + /// Tenant this membership belongs to. + final String tenantId; + + /// The business this membership grants access to. final String businessId; - /// The [User] who is a member. - final String userId; + /// The user who holds this membership. Null for invite-only records. + final String? userId; - /// The role within the business. - final String role; + /// Email used for the invitation, before the user claims it. + final String? invitedEmail; + + /// Current status of this membership. + final BusinessMembershipStatus membershipStatus; + + /// The role granted within the business. + final BusinessRole businessRole; + + /// Business display name (joined from businesses table in session context). + final String? businessName; + + /// Business URL slug (joined from businesses table in session context). + final String? businessSlug; + + /// Flexible metadata JSON blob. + final Map metadata; + + /// When this membership was created. + final DateTime? createdAt; + + /// When this membership was last updated. + final DateTime? updatedAt; + + /// Serialises this [BusinessMembership] to a JSON map. + Map toJson() { + return { + 'membershipId': id, + 'tenantId': tenantId, + 'businessId': businessId, + 'userId': userId, + 'invitedEmail': invitedEmail, + 'membershipStatus': membershipStatus.name.toUpperCase(), + 'businessRole': businessRole.name.toUpperCase(), + 'businessName': businessName, + 'businessSlug': businessSlug, + 'metadata': metadata, + 'createdAt': createdAt?.toIso8601String(), + 'updatedAt': updatedAt?.toIso8601String(), + }; + } @override - List get props => [id, businessId, userId, role]; -} \ No newline at end of file + List get props => [ + id, + tenantId, + businessId, + userId, + invitedEmail, + membershipStatus, + businessRole, + businessName, + businessSlug, + metadata, + createdAt, + updatedAt, + ]; + + /// Parses a membership status string. + static BusinessMembershipStatus _parseMembershipStatus(String? value) { + switch (value?.toUpperCase()) { + case 'INVITED': + return BusinessMembershipStatus.invited; + case 'ACTIVE': + return BusinessMembershipStatus.active; + case 'SUSPENDED': + return BusinessMembershipStatus.suspended; + case 'REMOVED': + return BusinessMembershipStatus.removed; + default: + return BusinessMembershipStatus.active; + } + } + + /// Parses a business role string. + static BusinessRole _parseBusinessRole(String? value) { + switch (value?.toUpperCase()) { + case 'OWNER': + return BusinessRole.owner; + case 'MANAGER': + return BusinessRole.manager; + case 'MEMBER': + return BusinessRole.member; + case 'VIEWER': + return BusinessRole.viewer; + default: + return BusinessRole.member; + } + } +} diff --git a/apps/mobile/packages/domain/lib/src/entities/users/client_session.dart b/apps/mobile/packages/domain/lib/src/entities/users/client_session.dart new file mode 100644 index 00000000..e0704a1d --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/users/client_session.dart @@ -0,0 +1,128 @@ +import 'package:equatable/equatable.dart'; + +/// Client session context returned by `GET /client/session`. +/// +/// Contains the authenticated client user's profile, their business context, +/// and tenant information. Populated from `requireClientContext()` in the +/// V2 query API. +class ClientSession extends Equatable { + /// Creates a [ClientSession] instance. + const ClientSession({ + required this.businessId, + required this.businessName, + this.businessSlug, + this.businessRole, + this.membershipId, + this.userId, + this.displayName, + this.email, + this.phone, + this.tenantId, + this.tenantName, + this.tenantSlug, + }); + + /// Deserialises a [ClientSession] from the V2 session API response. + /// + /// The response shape comes from `requireClientContext()` which returns + /// `{ user, tenant, business, vendor, staff }`. + factory ClientSession.fromJson(Map json) { + final Map business = + (json['business'] as Map?) ?? const {}; + final Map user = + (json['user'] as Map?) ?? const {}; + final Map tenant = + (json['tenant'] as Map?) ?? const {}; + + return ClientSession( + businessId: business['businessId'] as String, + businessName: business['businessName'] as String, + businessSlug: business['businessSlug'] as String?, + businessRole: business['role'] as String?, + membershipId: business['membershipId'] as String?, + userId: user['userId'] as String?, + displayName: user['displayName'] as String?, + email: user['email'] as String?, + phone: user['phone'] as String?, + tenantId: tenant['tenantId'] as String?, + tenantName: tenant['tenantName'] as String?, + tenantSlug: tenant['tenantSlug'] as String?, + ); + } + + /// Business UUID. + final String businessId; + + /// Business display name. + final String businessName; + + /// Business URL slug. + final String? businessSlug; + + /// The user's role within the business (owner, manager, member, viewer). + final String? businessRole; + + /// Business membership record ID. + final String? membershipId; + + /// Firebase Auth UID. + final String? userId; + + /// User display name. + final String? displayName; + + /// User email address. + final String? email; + + /// User phone number. + final String? phone; + + /// Tenant UUID. + final String? tenantId; + + /// Tenant display name. + final String? tenantName; + + /// Tenant URL slug. + final String? tenantSlug; + + /// Serialises this [ClientSession] to a JSON map. + Map toJson() { + return { + 'business': { + 'businessId': businessId, + 'businessName': businessName, + 'businessSlug': businessSlug, + 'role': businessRole, + 'membershipId': membershipId, + }, + 'user': { + 'userId': userId, + 'displayName': displayName, + 'email': email, + 'phone': phone, + }, + 'tenant': { + 'tenantId': tenantId, + 'tenantName': tenantName, + 'tenantSlug': tenantSlug, + }, + }; + } + + @override + List get props => [ + businessId, + businessName, + businessSlug, + businessRole, + membershipId, + userId, + displayName, + email, + phone, + tenantId, + tenantName, + tenantSlug, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/users/hub_member.dart b/apps/mobile/packages/domain/lib/src/entities/users/hub_member.dart deleted file mode 100644 index a6bd7a7f..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/users/hub_member.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents a member of a Hub. -/// -/// Grants a user access to specific [Hub] operations, distinct from [BizMember]. -class HubMember extends Equatable { - - const HubMember({ - required this.id, - required this.hubId, - required this.userId, - required this.role, - }); - /// Unique identifier for this membership. - final String id; - - /// The [Hub] the user belongs to. - final String hubId; - - /// The [User] who is a member. - final String userId; - - /// The role within the hub. - final String role; - - @override - List get props => [id, hubId, userId, role]; -} \ No newline at end of file diff --git a/apps/mobile/packages/domain/lib/src/entities/users/membership.dart b/apps/mobile/packages/domain/lib/src/entities/users/membership.dart deleted file mode 100644 index c09ea2ae..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/users/membership.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Represents a polymorphic membership to an organization unit. -/// -/// Allows a [User] to be a member of either a [Business] or a [Hub]. -class Membership extends Equatable { - - const Membership({ - required this.id, - required this.userId, - required this.memberableId, - required this.memberableType, - required this.role, - }); - /// Unique identifier for the membership record. - final String id; - - /// The [User] holding this membership. - final String userId; - - /// The ID of the organization unit (Business or Hub). - final String memberableId; - - /// The type of the organization unit ('business' or 'hub'). - final String memberableType; - - /// The role within that organization (e.g., 'manager', 'viewer'). - final String role; - - @override - List get props => [id, userId, memberableId, memberableType, role]; -} \ No newline at end of file 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 4a6eca3b..f0245dea 100644 --- a/apps/mobile/packages/domain/lib/src/entities/users/staff.dart +++ b/apps/mobile/packages/domain/lib/src/entities/users/staff.dart @@ -1,114 +1,211 @@ import 'package:equatable/equatable.dart'; -/// The lifecycle status of a [Staff] account. +/// Lifecycle status of a staff account in V2. enum StaffStatus { - /// Account created but profile not started. - registered, - - /// Profile submitted and awaiting verification. - pending, - - /// Profile information filled but not submitted for verification. - completedProfile, - - /// Profile verified by admin. - verified, - - /// Staff is currently active and eligible for work. + /// Staff is active and eligible for work. active, - /// Account is temporarily suspended. - blocked, + /// Staff has been invited but has not completed onboarding. + invited, - /// Account is permanently inactive. + /// Staff account has been deactivated. inactive, + + /// Staff account has been blocked by an admin. + blocked, } -/// Represents a worker profile. +/// Onboarding progress of a staff member. +enum OnboardingStatus { + /// Onboarding has not started. + pending, + + /// Onboarding is in progress. + inProgress, + + /// Onboarding is complete. + completed, +} + +/// Represents a worker profile in the KROW platform. /// -/// Contains all personal and professional details of a staff member. -/// Linked to a [User] via [authProviderId]. +/// Maps to the V2 `staffs` table. Linked to a [User] via [userId]. class Staff extends Equatable { + /// Creates a [Staff] instance. const Staff({ required this.id, - required this.authProviderId, - required this.name, - required this.email, + required this.tenantId, + this.userId, + required this.fullName, + this.email, this.phone, - this.avatar, required this.status, - this.address, - this.totalShifts, - this.averageRating, - this.onTimeRate, - this.noShowCount, - this.cancellationCount, - this.reliabilityScore, - this.preferredLocations, + this.primaryRole, + required this.onboardingStatus, + this.averageRating = 0, + this.ratingCount = 0, + this.metadata = const {}, + this.workforceId, + this.vendorId, + this.workforceNumber, + this.createdAt, + this.updatedAt, }); - /// Unique identifier for the staff profile. + + /// Deserialises a [Staff] from a V2 API JSON response. + /// + /// Handles both session context shape and standalone staff rows. + factory Staff.fromJson(Map json) { + return Staff( + id: json['staffId'] as String, + tenantId: json['tenantId'] as String, + userId: json['userId'] as String?, + fullName: json['fullName'] as String, + email: json['email'] as String?, + phone: json['phone'] as String?, + status: _parseStaffStatus(json['status'] as String?), + primaryRole: json['primaryRole'] as String?, + onboardingStatus: _parseOnboardingStatus(json['onboardingStatus'] as String?), + averageRating: _parseDouble(json['averageRating']), + ratingCount: (json['ratingCount'] as num?)?.toInt() ?? 0, + metadata: (json['metadata'] as Map?) ?? const {}, + workforceId: json['workforceId'] as String?, + vendorId: json['vendorId'] as String?, + workforceNumber: json['workforceNumber'] as String?, + createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'] as String) : null, + updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'] as String) : null, + ); + } + + /// Unique staff profile ID (UUID). final String id; - /// Link to the [User] authentication record. - final String authProviderId; + /// Tenant this staff belongs to. + final String tenantId; + + /// Firebase Auth UID linking to the [User] record. Null for invited-only staff. + final String? userId; /// Full display name. - final String name; + final String fullName; /// Contact email. - final String email; + final String? email; /// Contact phone number. final String? phone; - /// Profile picture URL. - final String? avatar; - - /// Current workflow status of the staff member. + /// Current account status. final StaffStatus status; - /// The user's physical address. - /// - /// Can be used for location-based job matching. - final String? address; + /// Primary job role (e.g. "bartender", "server"). + final String? primaryRole; - /// The total number of shifts completed. - final int? totalShifts; + /// Onboarding progress. + final OnboardingStatus onboardingStatus; - /// The average rating from businesses. - final double? averageRating; + /// Average rating from businesses (0.00 - 5.00). + final double averageRating; - /// The percentage of shifts arrived on time. - final int? onTimeRate; + /// Total number of ratings received. + final int ratingCount; - /// The number of no-shows. - final int? noShowCount; + /// Flexible metadata JSON blob. + final Map metadata; - /// The number of cancellations within 24h. - final int? cancellationCount; + /// Workforce record ID if the staff is in a vendor workforce. + final String? workforceId; - /// The reliability score (0-100). - final int? reliabilityScore; + /// Vendor ID associated via workforce. + final String? vendorId; - /// Preferred work locations. - final List? preferredLocations; + /// Workforce number (employee ID within the vendor). + final String? workforceNumber; + + /// When the staff record was created. + final DateTime? createdAt; + + /// When the staff record was last updated. + final DateTime? updatedAt; + + /// Serialises this [Staff] to a JSON map. + Map toJson() { + return { + 'staffId': id, + 'tenantId': tenantId, + 'userId': userId, + 'fullName': fullName, + 'email': email, + 'phone': phone, + 'status': status.name.toUpperCase(), + 'primaryRole': primaryRole, + 'onboardingStatus': onboardingStatus.name.toUpperCase(), + 'averageRating': averageRating, + 'ratingCount': ratingCount, + 'metadata': metadata, + 'workforceId': workforceId, + 'vendorId': vendorId, + 'workforceNumber': workforceNumber, + 'createdAt': createdAt?.toIso8601String(), + 'updatedAt': updatedAt?.toIso8601String(), + }; + } @override List get props => [ id, - authProviderId, - name, + tenantId, + userId, + fullName, email, phone, - avatar, status, - address, - totalShifts, + primaryRole, + onboardingStatus, averageRating, - onTimeRate, - noShowCount, - cancellationCount, - reliabilityScore, - preferredLocations, + ratingCount, + metadata, + workforceId, + vendorId, + workforceNumber, + createdAt, + 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(); + if (value is String) return double.tryParse(value) ?? 0; + return 0; + } } diff --git a/apps/mobile/packages/domain/lib/src/entities/users/staff_session.dart b/apps/mobile/packages/domain/lib/src/entities/users/staff_session.dart new file mode 100644 index 00000000..a1b9e4de --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/users/staff_session.dart @@ -0,0 +1,177 @@ +import 'package:equatable/equatable.dart'; + +import 'package:krow_domain/src/entities/enums/onboarding_status.dart'; +import 'package:krow_domain/src/entities/enums/staff_status.dart'; + +/// Staff session context returned by `GET /staff/session`. +/// +/// Contains the authenticated staff member's profile summary, tenant context, +/// and optional workforce/vendor linkage. Populated from the actor-context +/// query in the V2 query API. +class StaffSession extends Equatable { + /// Creates a [StaffSession] instance. + const StaffSession({ + required this.staffId, + required this.tenantId, + required this.fullName, + this.email, + this.phone, + this.primaryRole, + this.onboardingStatus, + this.status, + this.averageRating = 0, + this.ratingCount = 0, + this.workforceId, + this.vendorId, + this.workforceNumber, + this.metadata = const {}, + this.userId, + this.tenantName, + this.tenantSlug, + }); + + /// Deserialises a [StaffSession] from the V2 session API response. + /// + /// The response shape comes from `requireStaffContext()` which returns + /// `{ user, tenant, business, vendor, staff }`. + factory StaffSession.fromJson(Map json) { + final Map staff = + (json['staff'] as Map?) ?? json; + final Map user = + (json['user'] as Map?) ?? const {}; + final Map tenant = + (json['tenant'] as Map?) ?? const {}; + + return StaffSession( + staffId: staff['staffId'] as String, + tenantId: staff['tenantId'] as String, + fullName: staff['fullName'] as String, + email: staff['email'] as String?, + phone: staff['phone'] as String?, + primaryRole: staff['primaryRole'] as String?, + onboardingStatus: staff['onboardingStatus'] != null + ? OnboardingStatus.fromJson(staff['onboardingStatus'] as String?) + : null, + status: staff['status'] != null + ? StaffStatus.fromJson(staff['status'] as String?) + : null, + averageRating: _parseDouble(staff['averageRating']), + ratingCount: (staff['ratingCount'] as num?)?.toInt() ?? 0, + workforceId: staff['workforceId'] as String?, + vendorId: staff['vendorId'] as String?, + workforceNumber: staff['workforceNumber'] as String?, + metadata: (staff['metadata'] as Map?) ?? const {}, + userId: user['userId'] as String?, + tenantName: tenant['tenantName'] as String?, + tenantSlug: tenant['tenantSlug'] as String?, + ); + } + + /// Staff profile UUID. + final String staffId; + + /// Tenant this staff belongs to. + final String tenantId; + + /// Full display name. + final String fullName; + + /// Contact email. + final String? email; + + /// Contact phone number. + final String? phone; + + /// Primary job role code (e.g. "bartender"). + final String? primaryRole; + + /// Onboarding progress. + final OnboardingStatus? onboardingStatus; + + /// Account status. + final StaffStatus? status; + + /// Average rating from businesses (0.00 - 5.00). + final double averageRating; + + /// Total number of ratings received. + final int ratingCount; + + /// Workforce record ID if linked to a vendor. + final String? workforceId; + + /// Vendor ID associated via workforce. + final String? vendorId; + + /// Employee number within the vendor. + final String? workforceNumber; + + /// Flexible metadata JSON blob from the staffs table. + final Map metadata; + + /// Firebase Auth UID from the user context. + final String? userId; + + /// Tenant display name from the tenant context. + final String? tenantName; + + /// Tenant URL slug from the tenant context. + final String? tenantSlug; + + /// Serialises this [StaffSession] to a JSON map. + Map toJson() { + return { + 'staff': { + 'staffId': staffId, + 'tenantId': tenantId, + 'fullName': fullName, + 'email': email, + 'phone': phone, + 'primaryRole': primaryRole, + 'onboardingStatus': onboardingStatus?.toJson(), + 'status': status?.toJson(), + 'averageRating': averageRating, + 'ratingCount': ratingCount, + 'workforceId': workforceId, + 'vendorId': vendorId, + 'workforceNumber': workforceNumber, + 'metadata': metadata, + }, + 'user': { + 'userId': userId, + }, + 'tenant': { + 'tenantName': tenantName, + 'tenantSlug': tenantSlug, + }, + }; + } + + @override + List get props => [ + staffId, + tenantId, + fullName, + email, + phone, + primaryRole, + onboardingStatus, + status, + averageRating, + ratingCount, + workforceId, + vendorId, + workforceNumber, + metadata, + userId, + tenantName, + tenantSlug, + ]; + + /// Safely parses a numeric value to double. + static double _parseDouble(Object? value) { + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value) ?? 0; + return 0; + } +} diff --git a/apps/mobile/packages/domain/lib/src/entities/users/user.dart b/apps/mobile/packages/domain/lib/src/entities/users/user.dart index fc300f59..fe2c5785 100644 --- a/apps/mobile/packages/domain/lib/src/entities/users/user.dart +++ b/apps/mobile/packages/domain/lib/src/entities/users/user.dart @@ -1,30 +1,108 @@ import 'package:equatable/equatable.dart'; -/// Represents a base authenticated user in the KROW platform. -/// -/// This entity corresponds to the Firebase Auth user record and acts as the -/// linkage between the authentication system and the specific [Staff] or Client profiles. -class User extends Equatable { +/// Account status for a platform user. +enum UserStatus { + /// User is active and can sign in. + active, + /// User has been invited but has not signed in yet. + invited, + + /// User account has been disabled by an admin. + disabled, +} + +/// Represents an authenticated user in the KROW platform. +/// +/// Maps to the V2 `users` table. The [id] is the Firebase Auth UID. +class User extends Equatable { + /// Creates a [User] instance. const User({ required this.id, - required this.email, + this.email, + this.displayName, this.phone, - required this.role, + required this.status, + this.metadata = const {}, + this.createdAt, + this.updatedAt, }); - /// The unique identifier from the authentication provider (e.g., Firebase UID). + + /// Deserialises a [User] from a V2 API JSON response. + factory User.fromJson(Map json) { + return User( + id: json['userId'] as String, + email: json['email'] as String?, + displayName: json['displayName'] as String?, + phone: json['phone'] as String?, + status: _parseUserStatus(json['status'] as String?), + metadata: (json['metadata'] as Map?) ?? const {}, + createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'] as String) : null, + updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'] as String) : null, + ); + } + + /// Firebase Auth UID. Primary key in the V2 `users` table. final String id; /// The user's email address. - final String email; + final String? email; - /// The user's phone number, if available. + /// The user's display name. + final String? displayName; + + /// The user's phone number. final String? phone; - /// The primary role of the user (e.g., 'staff', 'client_admin'). - /// This determines the initial routing and permissions. - final String role; + /// Current account status. + final UserStatus status; + + /// Flexible metadata JSON blob. + final Map metadata; + + /// When the user record was created. + final DateTime? createdAt; + + /// When the user record was last updated. + final DateTime? updatedAt; + + /// Serialises this [User] to a JSON map. + Map toJson() { + return { + 'userId': id, + 'email': email, + 'displayName': displayName, + 'phone': phone, + 'status': status.name.toUpperCase(), + 'metadata': metadata, + 'createdAt': createdAt?.toIso8601String(), + 'updatedAt': updatedAt?.toIso8601String(), + }; + } @override - List get props => [id, email, phone, role]; -} \ No newline at end of file + List get props => [ + id, + email, + displayName, + phone, + status, + metadata, + createdAt, + updatedAt, + ]; + + /// 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/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 3a8a1bfb..a4bb9c25 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 @@ -255,7 +255,7 @@ class ViewOrdersCubit extends Cubit return filtered; } else if (state.filterTab == 'active') { final List filtered = ordersOnDate - .where((OrderItem s) => s.status == 'IN_PROGRESS') + .where((OrderItem s) => s.status == ShiftStatus.active) .toList(); print( 'ViewOrders tab=active statuses=${ordersOnDate.map((OrderItem s) => s.status).toList()} filtered=${filtered.length}', @@ -263,7 +263,7 @@ class ViewOrdersCubit extends Cubit return filtered; } else if (state.filterTab == 'completed') { final List filtered = ordersOnDate - .where((OrderItem s) => s.status == 'COMPLETED') + .where((OrderItem s) => s.status == ShiftStatus.completed) .toList(); print( 'ViewOrders tab=completed statuses=${ordersOnDate.map((OrderItem s) => s.status).toList()} filtered=${filtered.length}', @@ -284,14 +284,14 @@ class ViewOrdersCubit extends Cubit return state.orders .where( (OrderItem s) => - s.date == selectedDateStr && s.status == 'IN_PROGRESS', + s.date == selectedDateStr && s.status == ShiftStatus.active, ) .length; } else if (category == 'completed') { return state.orders .where( (OrderItem s) => - s.date == selectedDateStr && s.status == 'COMPLETED', + s.date == selectedDateStr && s.status == ShiftStatus.completed, ) .length; } 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 ca085684..ba60a932 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 @@ -111,7 +111,7 @@ class _ViewOrderCardState extends State { /// 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 == 'COMPLETED') return false; + if (order.status == ShiftStatus.completed) return false; if (order.date.isEmpty) return true; try { final DateTime orderDate = DateTime.parse(order.date); From b31a615092a89732d769d1c9ea977eb8395c402a Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Mar 2026 22:45:06 -0400 Subject: [PATCH 02/25] feat: Migrate staff profile features from Data Connect to V2 REST API - Removed data_connect package from mobile pubspec.yaml. - Added documentation for V2 profile migration status and QA findings. - Implemented new session management with ClientSessionStore and StaffSessionStore. - Created V2SessionService for handling user sessions via the V2 API. - Developed use cases for cancelling late worker assignments and submitting worker reviews. - Added arguments and use cases for payment chart retrieval and profile completion checks. - Implemented repository interfaces and their implementations for staff main and profile features. - Ensured proper error handling and validation in use cases. --- .claude/agent-memory/mobile-builder/MEMORY.md | 173 +- .../project_v2_profile_migration.md | 33 + .../agent-memory/mobile-qa-analyst/MEMORY.md | 2 + .../project_client_v2_migration_issues.md | 22 + .../project_v2_migration_qa_findings.md | 27 + .../agents/mobile-architecture-reviewer.md | 2 + .claude/agents/mobile-builder.md | 11 +- .claude/agents/mobile-qa-analyst.md | 14 +- .../skills/krow-mobile-architecture/SKILL.md | 575 ++++--- .../krow-mobile-development-rules/SKILL.md | 137 +- .../plugins/GeneratedPluginRegistrant.java | 5 - .../ios/Runner/GeneratedPluginRegistrant.m | 7 - apps/mobile/apps/client/lib/main.dart | 6 +- .../lib/src/widgets/session_listener.dart | 10 +- .../Flutter/GeneratedPluginRegistrant.swift | 2 - apps/mobile/apps/client/pubspec.yaml | 1 - .../plugins/GeneratedPluginRegistrant.java | 10 - .../ios/Runner/GeneratedPluginRegistrant.m | 14 - apps/mobile/apps/staff/lib/main.dart | 6 +- .../lib/src/widgets/session_listener.dart | 25 +- .../Flutter/GeneratedPluginRegistrant.swift | 2 - apps/mobile/apps/staff/pubspec.yaml | 2 - apps/mobile/packages/core/lib/core.dart | 5 + .../packages/core/lib/src/core_module.dart | 9 + .../core/lib/src/routing/staff/navigator.dart | 11 +- .../mixins/session_handler_mixin.dart | 6 +- .../session/client_session_store.dart | 28 + .../services/session/staff_session_store.dart | 28 + .../services/session/v2_session_service.dart | 101 ++ .../lib/src/l10n/en.i18n.json | 3 + .../lib/src/l10n/es.i18n.json | 3 + .../data_connect/lib/krow_data_connect.dart | 52 - .../billing_connector_repository_impl.dart | 373 ----- .../billing_connector_repository.dart | 30 - .../coverage_connector_repository_impl.dart | 158 -- .../coverage_connector_repository.dart | 12 - .../hubs_connector_repository_impl.dart | 342 ---- .../hubs_connector_repository.dart | 45 - .../reports_connector_repository_impl.dart | 537 ------ .../reports_connector_repository.dart | 55 - .../shifts_connector_repository_impl.dart | 797 --------- .../shifts_connector_repository.dart | 56 - .../staff_connector_repository_impl.dart | 876 ---------- .../staff_connector_repository.dart | 122 -- ...get_attire_options_completion_usecase.dart | 27 - ...emergency_contacts_completion_usecase.dart | 27 - .../get_experience_completion_usecase.dart | 27 - .../get_personal_info_completion_usecase.dart | 27 - .../get_profile_completion_usecase.dart | 27 - ...staff_certificates_completion_usecase.dart | 27 - ...et_staff_documents_completion_usecase.dart | 27 - .../usecases/get_staff_profile_usecase.dart | 28 - .../get_tax_forms_completion_usecase.dart | 27 - .../usecases/sign_out_staff_usecase.dart | 25 - .../lib/src/data_connect_module.dart | 37 - .../src/services/data_connect_service.dart | 250 --- .../services/mixins/data_error_handler.dart | 86 - .../mixins/session_handler_mixin.dart | 265 --- .../lib/src/session/client_session_store.dart | 41 - .../lib/src/session/staff_session_store.dart | 25 - .../mobile/packages/data_connect/pubspec.yaml | 21 - .../src/entities/availability/time_slot.dart | 16 +- .../src/entities/home/staff_dashboard.dart | 52 +- .../entities/profile/staff_personal_info.dart | 7 + .../domain/lib/src/entities/users/staff.dart | 65 +- .../lib/client_authentication.dart | 12 +- .../auth_repository_impl.dart | 528 ++---- .../client/authentication/pubspec.yaml | 9 +- .../billing/lib/src/billing_module.dart | 59 +- .../billing_repository_impl.dart | 117 +- .../repositories/billing_repository.dart | 17 +- .../src/domain/usecases/approve_invoice.dart | 4 +- .../src/domain/usecases/dispute_invoice.dart | 9 +- .../domain/usecases/get_bank_accounts.dart | 8 +- .../usecases/get_current_bill_amount.dart | 13 +- .../domain/usecases/get_invoice_history.dart | 7 +- .../domain/usecases/get_pending_invoices.dart | 7 +- .../domain/usecases/get_savings_amount.dart | 13 +- .../usecases/get_spending_breakdown.dart | 39 +- .../src/presentation/blocs/billing_bloc.dart | 247 +-- .../src/presentation/blocs/billing_event.dart | 12 +- .../src/presentation/blocs/billing_state.dart | 99 +- .../shift_completion_review_bloc.dart | 17 +- .../models/billing_invoice_model.dart | 84 - .../models/spending_breakdown_model.dart | 23 - .../src/presentation/pages/billing_page.dart | 23 +- .../pages/completion_review_page.dart | 87 +- .../pages/invoice_ready_page.dart | 44 +- .../pages/pending_invoices_page.dart | 15 +- .../presentation/widgets/billing_header.dart | 20 +- .../completion_review_actions.dart | 134 +- .../completion_review_amount.dart | 17 +- .../completion_review_info.dart | 25 +- .../completion_review_worker_card.dart | 126 +- .../widgets/invoice_history_section.dart | 31 +- .../widgets/payment_method_card.dart | 27 +- .../widgets/pending_invoices_section.dart | 114 +- .../widgets/spending_breakdown_card.dart | 37 +- .../features/client/billing/pubspec.yaml | 10 +- .../lib/src/coverage_module.dart | 33 +- .../coverage_repository_impl.dart | 115 +- .../cancel_late_worker_arguments.dart | 19 + .../get_coverage_stats_arguments.dart | 3 - .../get_shifts_for_date_arguments.dart | 3 - .../submit_worker_review_arguments.dart | 42 + .../repositories/coverage_repository.dart | 41 +- .../usecases/cancel_late_worker_usecase.dart | 23 + .../usecases/get_coverage_stats_usecase.dart | 16 +- .../usecases/get_shifts_for_date_usecase.dart | 20 +- .../submit_worker_review_usecase.dart | 30 + .../src/presentation/blocs/coverage_bloc.dart | 120 +- .../presentation/blocs/coverage_event.dart | 59 + .../presentation/blocs/coverage_state.dart | 37 +- .../src/presentation/pages/coverage_page.dart | 38 +- .../widgets/coverage_calendar_selector.dart | 2 +- .../coverage_page_skeleton.dart | 2 +- .../widgets/coverage_quick_stats.dart | 6 +- .../widgets/coverage_shift_list.dart | 54 +- .../presentation/widgets/shift_header.dart | 2 +- .../src/presentation/widgets/worker_row.dart | 74 +- .../client/client_coverage/pubspec.yaml | 7 +- .../features/client/home/lib/client_home.dart | 29 +- .../home_repository_impl.dart | 213 +-- .../home_repository_interface.dart | 32 +- .../usecases/get_dashboard_data_usecase.dart | 18 +- .../usecases/get_recent_reorders_usecase.dart | 12 +- .../get_user_session_data_usecase.dart | 16 - .../presentation/blocs/client_home_bloc.dart | 48 +- .../presentation/blocs/client_home_state.dart | 94 +- .../presentation/pages/client_home_page.dart | 8 +- .../widgets/client_home_body.dart | 12 +- .../widgets/client_home_edit_banner.dart | 6 +- .../widgets/client_home_edit_mode_body.dart | 8 +- .../widgets/client_home_error_state.dart | 6 +- .../widgets/client_home_header.dart | 34 +- .../widgets/client_home_normal_mode_body.dart | 4 +- .../widgets/client_home_sheets.dart | 27 - .../widgets/coverage_dashboard.dart | 217 --- .../widgets/dashboard_widget_builder.dart | 66 +- .../widgets/draggable_widget_wrapper.dart | 4 +- .../widgets/live_activity_widget.dart | 331 ++-- .../presentation/widgets/reorder_widget.dart | 91 +- .../widgets/shift_order_form_sheet.dart | 1449 ----------------- .../presentation/widgets/spending_widget.dart | 47 +- .../features/client/home/pubspec.yaml | 5 +- .../features/client/hubs/lib/client_hubs.dart | 61 +- .../hub_repository_impl.dart | 186 ++- .../arguments/assign_nfc_tag_arguments.dart | 6 +- .../arguments/create_hub_arguments.dart | 57 +- .../hub_repository_interface.dart | 39 +- .../usecases/assign_nfc_tag_usecase.dart | 13 +- .../domain/usecases/create_hub_usecase.dart | 22 +- .../domain/usecases/delete_hub_usecase.dart | 12 +- .../usecases/get_cost_centers_usecase.dart | 8 +- .../src/domain/usecases/get_hubs_usecase.dart | 11 +- .../domain/usecases/update_hub_usecase.dart | 81 +- .../presentation/blocs/client_hubs_bloc.dart | 20 +- .../blocs/edit_hub/edit_hub_bloc.dart | 32 +- .../blocs/edit_hub/edit_hub_event.dart | 110 +- .../blocs/hub_details/hub_details_bloc.dart | 22 +- .../blocs/hub_details/hub_details_event.dart | 14 +- .../presentation/pages/client_hubs_page.dart | 24 +- .../src/presentation/pages/edit_hub_page.dart | 77 +- .../presentation/pages/hub_details_page.dart | 44 +- .../edit_hub/edit_hub_form_section.dart | 79 +- .../widgets/hub_address_autocomplete.dart | 5 +- .../src/presentation/widgets/hub_card.dart | 5 +- .../hub_details/hub_details_header.dart | 2 +- .../src/presentation/widgets/hub_form.dart | 55 +- .../hubs/lib/src/util/hubs_constants.dart | 2 + .../features/client/hubs/pubspec.yaml | 7 +- .../lib/src/create_order_module.dart | 16 +- .../client_create_order_repository_impl.dart | 802 +-------- .../client_order_query_repository_impl.dart | 141 +- .../arguments/one_time_order_arguments.dart | 18 +- .../arguments/permanent_order_arguments.dart | 12 +- .../arguments/recurring_order_arguments.dart | 12 +- ...ent_create_order_repository_interface.dart | 47 +- ...ient_order_query_repository_interface.dart | 25 +- .../create_one_time_order_usecase.dart | 10 +- .../create_permanent_order_usecase.dart | 14 +- .../create_recurring_order_usecase.dart | 14 +- ...get_order_details_for_reorder_usecase.dart | 10 +- .../usecases/parse_rapid_order_usecase.dart | 9 +- .../src/domain/usecases/reorder_usecase.dart | 25 - .../one_time_order/one_time_order_bloc.dart | 348 ++-- .../one_time_order/one_time_order_state.dart | 4 + .../permanent_order/permanent_order_bloc.dart | 147 +- .../blocs/rapid_order/rapid_order_bloc.dart | 10 +- .../blocs/rapid_order/rapid_order_state.dart | 42 +- .../recurring_order/recurring_order_bloc.dart | 162 +- .../pages/permanent_order_page.dart | 2 +- .../pages/recurring_order_page.dart | 2 +- .../widgets/rapid_order/rapid_order_view.dart | 4 +- .../client/orders/create_order/pubspec.yaml | 4 - .../one_time_order/one_time_order_form.dart | 2 +- .../permanent_order/permanent_order_form.dart | 2 +- .../recurring_order/recurring_order_form.dart | 2 +- .../Flutter/GeneratedPluginRegistrant.swift | 2 - .../client/orders/orders_common/pubspec.yaml | 10 +- .../view_orders_repository_impl.dart | 257 +-- .../arguments/orders_day_arguments.dart | 10 - .../i_view_orders_repository.dart | 35 +- ...ccepted_applications_for_day_use_case.dart | 17 - .../presentation/blocs/view_orders_cubit.dart | 195 +-- .../widgets/order_edit_sheet.dart | 1356 ++++----------- .../presentation/widgets/view_order_card.dart | 376 ++--- .../widgets/view_orders_empty_state.dart | 10 +- .../widgets/view_orders_header.dart | 34 +- .../lib/src/view_orders_module.dart | 27 +- .../client/orders/view_orders/pubspec.yaml | 6 +- .../reports_repository_impl.dart | 130 +- .../repositories/reports_repository.dart | 17 +- .../blocs/coverage/coverage_bloc.dart | 43 +- .../blocs/coverage/coverage_event.dart | 15 +- .../blocs/daily_ops/daily_ops_bloc.dart | 38 +- .../blocs/daily_ops/daily_ops_event.dart | 13 +- .../blocs/forecast/forecast_bloc.dart | 41 +- .../blocs/forecast/forecast_event.dart | 11 +- .../blocs/no_show/no_show_bloc.dart | 40 +- .../blocs/no_show/no_show_event.dart | 11 +- .../blocs/performance/performance_bloc.dart | 41 +- .../blocs/performance/performance_event.dart | 11 +- .../presentation/blocs/spend/spend_bloc.dart | 40 +- .../presentation/blocs/spend/spend_event.dart | 11 +- .../blocs/summary/reports_summary_bloc.dart | 42 +- .../blocs/summary/reports_summary_event.dart | 13 +- .../blocs/summary/reports_summary_state.dart | 18 +- .../pages/coverage_report_page.dart | 14 +- .../pages/daily_ops_report_page.dart | 38 +- .../pages/forecast_report_page.dart | 266 ++- .../pages/no_show_report_page.dart | 56 +- .../pages/performance_report_page.dart | 59 +- .../src/presentation/pages/reports_page.dart | 2 +- .../presentation/pages/spend_report_page.dart | 227 ++- .../widgets/reports_page/metrics_grid.dart | 64 +- .../reports/lib/src/reports_module.dart | 16 +- .../features/client/reports/pubspec.yaml | 2 - .../client/settings/lib/client_settings.dart | 12 +- .../settings_repository_impl.dart | 39 +- .../settings_profile_header.dart | 12 +- .../features/client/settings/pubspec.yaml | 2 - .../auth_repository_impl.dart | 274 ++-- .../place_repository_impl.dart | 2 +- .../profile_setup_repository_impl.dart | 79 +- .../arguments/verify_otp_arguments.dart | 2 +- .../auth_repository_interface.dart | 20 +- .../usecases/search_cities_usecase.dart | 10 +- .../usecases/sign_in_with_phone_usecase.dart | 12 +- .../submit_profile_setup_usecase.dart | 10 +- .../domain/usecases/verify_otp_usecase.dart | 11 +- .../lib/src/presentation/blocs/auth_bloc.dart | 62 +- .../profile_setup/profile_setup_bloc.dart | 31 +- .../presentation/pages/get_started_page.dart | 6 +- .../pages/phone_verification_page.dart | 4 +- .../pages/profile_setup_page.dart | 10 +- .../otp_verification/otp_input_field.dart | 4 +- .../otp_verification_actions.dart | 2 +- .../profile_setup_experience.dart | 220 ++- .../lib/src/staff_authentication_module.dart | 18 +- .../staff/authentication/pubspec.yaml | 6 +- .../availability_repository_impl.dart | 293 +--- .../repositories/availability_repository.dart | 25 +- .../usecases/apply_quick_set_usecase.dart | 38 +- .../get_weekly_availability_usecase.dart | 32 +- .../update_day_availability_usecase.dart | 37 +- .../presentation/blocs/availability_bloc.dart | 147 +- .../blocs/availability_cubit.dart | 130 -- .../blocs/availability_event.dart | 71 +- .../blocs/availability_state.dart | 76 +- .../presentation/pages/availability_page.dart | 414 +++-- .../lib/src/staff_availability_module.dart | 50 +- .../features/staff/availability/pubspec.yaml | 4 - .../clock_in_repository_impl.dart | 284 +--- .../domain/arguments/clock_out_arguments.dart | 12 +- .../clock_in_repository_interface.dart | 6 +- .../domain/usecases/clock_out_usecase.dart | 2 +- .../bloc/clock_in/clock_in_bloc.dart | 52 +- .../bloc/clock_in/clock_in_state.dart | 4 +- .../widgets/clock_in_action_section.dart | 14 +- .../presentation/widgets/clock_in_body.dart | 24 +- .../presentation/widgets/commute_tracker.dart | 32 +- .../src/presentation/widgets/shift_card.dart | 33 +- .../lib/src/staff_clock_in_module.dart | 7 +- .../features/staff/clock_in/pubspec.yaml | 6 +- .../repositories/home_repository_impl.dart | 198 +-- .../home/lib/src/domain/entities/shift.dart | 94 -- .../domain/repositories/home_repository.dart | 24 +- .../src/domain/usecases/get_home_shifts.dart | 51 +- .../benefits_overview_cubit.dart | 15 +- .../presentation/blocs/home/home_cubit.dart | 58 +- .../presentation/blocs/home/home_state.dart | 63 +- .../benefits_overview/benefit_card.dart | 24 +- .../benefit_card_header.dart | 37 +- .../home_page/recommended_shift_card.dart | 57 +- .../home_page/recommended_shifts_section.dart | 17 +- .../home_page/todays_shifts_section.dart | 96 +- .../home_page/tomorrows_shifts_section.dart | 116 +- .../src/presentation/widgets/shift_card.dart | 395 ----- .../worker_benefits/benefits_widget.dart | 15 +- .../staff/home/lib/src/staff_home_module.dart | 34 +- .../packages/features/staff/home/pubspec.yaml | 8 +- .../payments_repository_impl.dart | 150 +- .../get_payment_chart_arguments.dart | 23 + .../get_payment_history_arguments.dart | 17 +- .../repositories/payments_repository.dart | 26 +- .../usecases/get_payment_chart_usecase.dart | 26 + .../usecases/get_payment_history_usecase.dart | 26 +- .../usecases/get_payment_summary_usecase.dart | 14 +- .../payments/lib/src/payments_module.dart | 46 +- .../blocs/payments/payments_bloc.dart | 116 +- .../blocs/payments/payments_event.dart | 8 +- .../blocs/payments/payments_state.dart | 32 +- .../src/presentation/pages/payments_page.dart | 105 +- .../presentation/widgets/earnings_graph.dart | 104 +- .../widgets/payment_history_item.dart | 115 +- .../features/staff/payments/pubspec.yaml | 4 - .../repositories/profile_repository_impl.dart | 36 + .../src/presentation/blocs/profile_cubit.dart | 176 +- .../pages/staff_profile_page.dart | 60 +- .../widgets/header/profile_level_badge.dart | 8 +- .../profile/lib/src/staff_profile_module.dart | 83 +- .../features/staff/profile/pubspec.yaml | 13 +- .../certificates_repository_impl.dart | 170 +- .../repositories/certificates_repository.dart | 26 +- .../usecases/delete_certificate_usecase.dart | 7 +- .../usecases/upload_certificate_usecase.dart | 8 +- .../usecases/upsert_certificate_usecase.dart | 63 - .../certificate_upload_cubit.dart | 4 +- .../certificates/certificates_cubit.dart | 4 +- .../certificates/certificates_state.dart | 2 +- .../pages/certificate_upload_page.dart | 14 +- .../widgets/certificate_card.dart | 65 +- .../lib/src/staff_certificates_module.dart | 38 +- .../compliance/certificates/pubspec.yaml | 7 +- .../documents_repository_impl.dart | 133 +- .../repositories/documents_repository.dart | 9 +- .../usecases/get_documents_usecase.dart | 4 +- .../usecases/upload_document_usecase.dart | 4 +- .../document_upload_cubit.dart | 2 +- .../document_upload_state.dart | 4 +- .../blocs/documents/documents_cubit.dart | 2 +- .../blocs/documents/documents_state.dart | 10 +- .../pages/document_upload_page.dart | 3 +- .../presentation/pages/documents_page.dart | 4 +- .../presentation/widgets/document_card.dart | 37 +- .../lib/src/staff_documents_module.dart | 23 +- .../compliance/documents/pubspec.yaml | 5 +- .../lib/src/data/mappers/tax_form_mapper.dart | 87 - .../tax_forms_repository_impl.dart | 282 +--- .../repositories/tax_forms_repository.dart | 14 +- .../domain/usecases/save_i9_form_usecase.dart | 4 +- .../domain/usecases/save_w4_form_usecase.dart | 4 +- .../usecases/submit_i9_form_usecase.dart | 4 +- .../usecases/submit_w4_form_usecase.dart | 4 +- .../presentation/blocs/i9/form_i9_cubit.dart | 19 +- .../presentation/blocs/w4/form_w4_cubit.dart | 21 +- .../presentation/pages/tax_forms_page.dart | 25 +- .../widgets/tax_forms_page/tax_form_card.dart | 21 +- .../lib/src/staff_tax_forms_module.dart | 35 +- .../compliance/tax_forms/pubspec.yaml | 6 +- .../bank_account_repository_impl.dart | 93 +- .../arguments/add_bank_account_params.dart | 4 +- .../repositories/bank_account_repository.dart | 10 +- .../usecases/get_bank_accounts_usecase.dart | 4 +- .../blocs/bank_account_cubit.dart | 18 +- .../blocs/bank_account_state.dart | 6 +- .../presentation/pages/bank_account_page.dart | 2 +- .../presentation/widgets/account_card.dart | 2 +- .../lib/src/staff_bank_account_module.dart | 31 +- .../finances/staff_bank_account/pubspec.yaml | 6 +- .../time_card_repository_impl.dart | 89 +- .../repositories/time_card_repository.dart | 7 +- .../usecases/get_time_cards_usecase.dart | 19 +- .../presentation/blocs/time_card_bloc.dart | 19 +- .../presentation/blocs/time_card_state.dart | 30 +- .../widgets/shift_history_list.dart | 4 +- .../presentation/widgets/timesheet_card.dart | 104 +- .../lib/src/staff_time_card_module.dart | 25 +- .../finances/time_card/pubspec.yaml | 2 - .../attire/lib/src/attire_module.dart | 10 +- .../attire_repository_impl.dart | 133 +- .../repositories/attire_repository.dart | 9 +- .../usecases/get_attire_options_usecase.dart | 4 +- .../usecases/upload_attire_photo_usecase.dart | 4 +- .../blocs/attire/attire_cubit.dart | 22 +- .../blocs/attire/attire_state.dart | 32 +- .../attire_capture/attire_capture_cubit.dart | 4 +- .../attire_capture/attire_capture_state.dart | 4 +- .../pages/attire_capture_page.dart | 28 +- .../src/presentation/pages/attire_page.dart | 20 +- .../attire_capture_page/footer_section.dart | 2 +- .../src/presentation/widgets/attire_grid.dart | 53 +- .../widgets/attire_item_card.dart | 52 +- .../onboarding/attire/pubspec.yaml | 5 +- .../emergency_contact_repository_impl.dart | 91 +- ...mergency_contact_repository_interface.dart | 6 +- .../blocs/emergency_contact_state.dart | 4 +- .../widgets/emergency_contact_form_item.dart | 42 +- .../src/staff_emergency_contact_module.dart | 30 +- .../onboarding/emergency_contact/pubspec.yaml | 4 +- .../experience_repository_impl.dart | 58 +- .../presentation/blocs/experience_bloc.dart | 61 +- .../presentation/pages/experience_page.dart | 65 +- .../lib/staff_profile_experience.dart | 15 +- .../onboarding/experience/pubspec.yaml | 5 +- .../personal_info_repository_impl.dart | 150 +- .../personal_info_repository_interface.dart | 21 +- .../usecases/get_personal_info_usecase.dart | 17 +- .../update_personal_info_usecase.dart | 21 +- .../upload_profile_photo_usecase.dart | 19 + .../blocs/personal_info_bloc.dart | 161 +- .../blocs/personal_info_event.dart | 15 + .../blocs/personal_info_state.dart | 22 +- .../pages/personal_info_page.dart | 20 +- .../personal_info_content.dart | 163 +- .../widgets/profile_photo_widget.dart | 42 +- .../lib/src/staff_profile_info_module.dart | 50 +- .../onboarding/profile_info/pubspec.yaml | 7 +- .../faqs_repository_impl.dart | 114 +- .../lib/src/domain/entities/faq_category.dart | 27 +- .../lib/src/domain/entities/faq_item.dart | 17 +- .../faqs_repository_interface.dart | 2 +- .../src/domain/usecases/get_faqs_usecase.dart | 4 +- .../domain/usecases/search_faqs_usecase.dart | 4 +- .../lib/src/presentation/blocs/faqs_bloc.dart | 9 +- .../lib/src/presentation/pages/faqs_page.dart | 4 +- .../widgets/faqs_skeleton/faqs_skeleton.dart | 2 +- .../src/presentation/widgets/faqs_widget.dart | 2 +- .../faqs/lib/src/staff_faqs_module.dart | 26 +- .../support/faqs/pubspec.yaml | 6 +- .../privacy_settings_repository_impl.dart | 103 +- .../src/staff_privacy_security_module.dart | 45 +- .../support/privacy_security/pubspec.yaml | 6 +- .../shifts_repository_impl.dart | 187 ++- .../get_available_shifts_arguments.dart | 26 +- .../arguments/get_my_shifts_arguments.dart | 18 +- .../shifts_repository_interface.dart | 45 +- .../domain/usecases/accept_shift_usecase.dart | 10 +- .../usecases/apply_for_shift_usecase.dart | 14 +- .../usecases/decline_shift_usecase.dart | 10 +- .../get_available_shifts_usecase.dart | 25 +- .../get_cancelled_shifts_usecase.dart | 13 +- .../usecases/get_history_shifts_usecase.dart | 17 +- .../usecases/get_my_shifts_usecase.dart | 22 +- .../get_pending_assignments_usecase.dart | 16 +- .../get_profile_completion_usecase.dart | 17 + .../usecases/get_shift_details_usecase.dart | 20 +- .../shift_details/shift_details_bloc.dart | 57 +- .../shift_details/shift_details_state.dart | 38 +- .../blocs/shifts/shifts_bloc.dart | 199 ++- .../blocs/shifts/shifts_event.dart | 60 +- .../blocs/shifts/shifts_state.dart | 118 +- .../pages/shift_details_page.dart | 220 +-- .../src/presentation/pages/shifts_page.dart | 33 +- .../presentation/widgets/my_shift_card.dart | 370 ++--- .../widgets/shift_assignment_card.dart | 298 ++-- .../shift_details/shift_break_section.dart | 62 - .../shift_date_time_section.dart | 98 +- .../shift_details_bottom_bar.dart | 46 +- .../shift_details/shift_details_header.dart | 34 +- .../shift_details/shift_location_map.dart | 121 -- .../shift_details/shift_location_section.dart | 62 +- .../shift_schedule_summary_section.dart | 162 -- .../widgets/tabs/find_shifts_tab.dart | 621 +++---- .../widgets/tabs/history_shifts_tab.dart | 114 +- .../widgets/tabs/my_shifts_tab.dart | 215 +-- .../shifts/lib/src/shift_details_module.dart | 61 +- .../shifts/lib/src/staff_shifts_module.dart | 98 +- .../features/staff/shifts/pubspec.yaml | 8 +- .../staff_main_repository_impl.dart | 42 + .../staff_main_repository_interface.dart | 7 + .../get_profile_completion_usecase.dart | 20 + .../presentation/blocs/staff_main_cubit.dart | 26 +- .../staff_main/lib/src/staff_main_module.dart | 37 +- .../features/staff/staff_main/pubspec.yaml | 4 +- apps/mobile/pubspec.lock | 152 -- apps/mobile/pubspec.yaml | 1 - 478 files changed, 10512 insertions(+), 19854 deletions(-) create mode 100644 .claude/agent-memory/mobile-builder/project_v2_profile_migration.md create mode 100644 .claude/agent-memory/mobile-qa-analyst/project_client_v2_migration_issues.md create mode 100644 .claude/agent-memory/mobile-qa-analyst/project_v2_migration_qa_findings.md create mode 100644 apps/mobile/packages/core/lib/src/services/session/client_session_store.dart create mode 100644 apps/mobile/packages/core/lib/src/services/session/staff_session_store.dart create mode 100644 apps/mobile/packages/core/lib/src/services/session/v2_session_service.dart delete mode 100644 apps/mobile/packages/data_connect/lib/krow_data_connect.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/billing/data/repositories/billing_connector_repository_impl.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/billing/domain/repositories/billing_connector_repository.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/coverage/data/repositories/coverage_connector_repository_impl.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/coverage/domain/repositories/coverage_connector_repository.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/hubs/domain/repositories/hubs_connector_repository.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/reports/data/repositories/reports_connector_repository_impl.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/reports/domain/repositories/reports_connector_repository.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/shifts/domain/repositories/shifts_connector_repository.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_attire_options_completion_usecase.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_emergency_contacts_completion_usecase.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_experience_completion_usecase.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_personal_info_completion_usecase.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_staff_certificates_completion_usecase.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_staff_documents_completion_usecase.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_staff_profile_usecase.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_tax_forms_completion_usecase.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/sign_out_staff_usecase.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/data_connect_module.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/services/mixins/data_error_handler.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/session/client_session_store.dart delete mode 100644 apps/mobile/packages/data_connect/lib/src/session/staff_session_store.dart delete mode 100644 apps/mobile/packages/data_connect/pubspec.yaml delete mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/models/billing_invoice_model.dart delete mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/models/spending_breakdown_model.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/cancel_late_worker_arguments.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/src/domain/arguments/submit_worker_review_arguments.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/cancel_late_worker_usecase.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/submit_worker_review_usecase.dart delete mode 100644 apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_user_session_data_usecase.dart delete mode 100644 apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_sheets.dart delete mode 100644 apps/mobile/packages/features/client/home/lib/src/presentation/widgets/coverage_dashboard.dart delete mode 100644 apps/mobile/packages/features/client/home/lib/src/presentation/widgets/shift_order_form_sheet.dart delete mode 100644 apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/reorder_usecase.dart delete mode 100644 apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/arguments/orders_day_arguments.dart delete mode 100644 apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/usecases/get_accepted_applications_for_day_use_case.dart delete mode 100644 apps/mobile/packages/features/staff/availability/lib/src/presentation/blocs/availability_cubit.dart delete mode 100644 apps/mobile/packages/features/staff/home/lib/src/domain/entities/shift.dart delete mode 100644 apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart create mode 100644 apps/mobile/packages/features/staff/payments/lib/src/domain/arguments/get_payment_chart_arguments.dart create mode 100644 apps/mobile/packages/features/staff/payments/lib/src/domain/usecases/get_payment_chart_usecase.dart create mode 100644 apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart delete mode 100644 apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upsert_certificate_usecase.dart delete mode 100644 apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/mappers/tax_form_mapper.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/usecases/upload_profile_photo_usecase.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/get_profile_completion_usecase.dart delete mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_break_section.dart delete mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_map.dart delete mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_schedule_summary_section.dart create mode 100644 apps/mobile/packages/features/staff/staff_main/lib/src/data/repositories/staff_main_repository_impl.dart create mode 100644 apps/mobile/packages/features/staff/staff_main/lib/src/domain/repositories/staff_main_repository_interface.dart create mode 100644 apps/mobile/packages/features/staff/staff_main/lib/src/domain/usecases/get_profile_completion_usecase.dart 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 From 4fca87bde1305b51be1cdc304515a7254fb5c432 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Mar 2026 22:56:15 -0400 Subject: [PATCH 03/25] Refactor auth repo imports; remove unused flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate and reorder imports in the client authentication repository by moving the auth_repository_interface import to the top and removing the duplicate import. Also remove the unused UnauthorizedAppException reference from the imported symbols. In the staff authentication repository, drop the unused requiresProfileSetup variable (extracted from API response) and tidy up minor whitespace—removing dead code and silencing analyzer warnings. --- .../lib/src/data/repositories_impl/auth_repository_impl.dart | 4 +--- .../lib/src/data/repositories_impl/auth_repository_impl.dart | 3 --- 2 files changed, 1 insertion(+), 6 deletions(-) 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 433a30d1..4f84d295 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,5 +1,6 @@ import 'dart:developer' as developer; +import 'package:client_authentication/src/domain/repositories/auth_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' @@ -14,13 +15,10 @@ import 'package:krow_domain/krow_domain.dart' PasswordMismatchException, SignInFailedException, SignUpFailedException, - UnauthorizedAppException, User, UserStatus, WeakPasswordException; -import 'package:client_authentication/src/domain/repositories/auth_repository_interface.dart'; - /// Production implementation of the [AuthRepositoryInterface] for the client app. /// /// Uses Firebase Auth client-side for sign-in (to maintain local auth state for 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 06a6dbd6..df41895d 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 @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart' as domain; - 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'; @@ -193,8 +192,6 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { 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 = From b289ed3b02a0e6caf2dfa479e604018420964e77 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 10:21:57 -0400 Subject: [PATCH 04/25] feat(auth): add staff-specific sign-out endpoint and enhance session management --- .../core_api_services/v2_api_endpoints.dart | 3 ++ .../inspectors/auth_interceptor.dart | 34 ++++++++++++++----- .../services/session/v2_session_service.dart | 31 +++++++++++++++++ .../auth_repository_impl.dart | 10 +++++- .../pages/phone_verification_page.dart | 4 ++- 5 files changed, 72 insertions(+), 10 deletions(-) diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/v2_api_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/v2_api_endpoints.dart index a902410f..dcc9c619 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/v2_api_endpoints.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/v2_api_endpoints.dart @@ -30,6 +30,9 @@ class V2ApiEndpoints { /// Generic sign-out. static const String signOut = '$baseUrl/auth/sign-out'; + /// Staff-specific sign-out. + static const String staffSignOut = '$baseUrl/auth/staff/sign-out'; + /// Get current session data. static const String session = '$baseUrl/auth/session'; diff --git a/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart b/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart index d6974e57..b52849b7 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart @@ -2,21 +2,39 @@ import 'package:dio/dio.dart'; import 'package:firebase_auth/firebase_auth.dart'; /// An interceptor that adds the Firebase Auth ID token to the Authorization header. +/// +/// Skips unauthenticated auth endpoints (sign-in, sign-up, phone/start) since +/// the user has no Firebase session yet. Sign-out, session, and phone/verify +/// endpoints DO require the token. class AuthInterceptor extends Interceptor { + /// Auth paths that must NOT receive a Bearer token (no session exists yet). + static const List _unauthenticatedPaths = [ + '/auth/client/sign-in', + '/auth/client/sign-up', + '/auth/staff/phone/start', + ]; + @override Future onRequest( RequestOptions options, RequestInterceptorHandler handler, ) async { - final User? user = FirebaseAuth.instance.currentUser; - if (user != null) { - try { - final String? token = await user.getIdToken(); - if (token != null) { - options.headers['Authorization'] = 'Bearer $token'; + // Skip token injection for endpoints that don't require authentication. + final bool skipAuth = _unauthenticatedPaths.any( + (String path) => options.path.contains(path), + ); + + if (!skipAuth) { + final User? user = FirebaseAuth.instance.currentUser; + if (user != null) { + try { + final String? token = await user.getIdToken(); + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + } catch (e) { + rethrow; } - } catch (e) { - rethrow; } } return handler.next(options); 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 index b126d74b..126ada3b 100644 --- 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 @@ -5,6 +5,8 @@ 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'; +import 'client_session_store.dart'; +import 'staff_session_store.dart'; /// A singleton service that manages user session state via the V2 REST API. /// @@ -67,6 +69,11 @@ class V2SessionService with SessionHandlerMixin { if (response.data is Map) { final Map data = response.data as Map; + + // Hydrate session stores from the session endpoint response. + // Per V2 auth doc, GET /auth/session is used for app startup hydration. + _hydrateSessionStores(data); + final String? role = data['role'] as String?; return role; } @@ -77,6 +84,28 @@ class V2SessionService with SessionHandlerMixin { } } + /// Hydrates session stores from a `GET /auth/session` response. + /// + /// The session endpoint returns `{ user, tenant, business, vendor, staff }` + /// which maps to both [ClientSession] and [StaffSession] entities. + void _hydrateSessionStores(Map data) { + try { + // Hydrate staff session if staff context is present. + if (data['staff'] is Map) { + final StaffSession staffSession = StaffSession.fromJson(data); + StaffSessionStore.instance.setSession(staffSession); + } + + // Hydrate client session if business context is present. + if (data['business'] is Map) { + final ClientSession clientSession = ClientSession.fromJson(data); + ClientSessionStore.instance.setSession(clientSession); + } + } catch (e) { + debugPrint('[V2SessionService] Error hydrating session stores: $e'); + } + } + /// Signs out the current user from Firebase Auth and clears local state. Future signOut() async { try { @@ -95,6 +124,8 @@ class V2SessionService with SessionHandlerMixin { debugPrint('[V2SessionService] Error signing out: $e'); rethrow; } finally { + StaffSessionStore.instance.clear(); + ClientSessionStore.instance.clear(); handleSignOut(); } } 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 df41895d..69ba37ea 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 @@ -210,6 +210,13 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { } } + // Step 5: Populate StaffSessionStore from the V2 auth envelope. + if (staffData != null) { + final domain.StaffSession staffSession = + domain.StaffSession.fromJson(data); + StaffSessionStore.instance.setSession(staffSession); + } + // Build the domain user from the V2 response. final domain.User domainUser = domain.User( id: userData?['id'] as String? ?? firebaseUser.uid, @@ -226,11 +233,12 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { @override Future signOut() async { try { - await _apiService.post(V2ApiEndpoints.signOut); + await _apiService.post(V2ApiEndpoints.staffSignOut); } 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(); + StaffSessionStore.instance.clear(); } } 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 93bf4e9f..17a0a531 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 @@ -105,6 +105,8 @@ class _PhoneVerificationPageState extends State { if (state.status == AuthStatus.authenticated) { if (state.mode == AuthMode.signup) { Modular.to.toProfileSetup(); + } else { + Modular.to.toStaffHome(); } } else if (state.status == AuthStatus.error && state.mode == AuthMode.signup) { @@ -155,7 +157,7 @@ class _PhoneVerificationPageState extends State { BlocProvider.of( context, ).add(AuthResetRequested(mode: widget.mode)); - Modular.to.popSafe();; + Modular.to.popSafe(); }, ), body: SafeArea( From ba5bf8e1d7270b6cd2d629562bc681fd426d8324 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 10:55:18 -0400 Subject: [PATCH 05/25] refactor(session): migrate V2 session listener initialization to improve dependency injection and role handling --- apps/mobile/apps/client/lib/main.dart | 10 ------- .../lib/src/widgets/session_listener.dart | 15 ++++++++--- apps/mobile/apps/staff/lib/main.dart | 9 ------- .../lib/src/widgets/session_listener.dart | 15 ++++++++--- .../packages/core/lib/src/core_module.dart | 5 ++-- .../services/session/v2_session_service.dart | 27 ++++++++----------- 6 files changed, 37 insertions(+), 44 deletions(-) diff --git a/apps/mobile/apps/client/lib/main.dart b/apps/mobile/apps/client/lib/main.dart index 6d3c2ac4..9a2feccd 100644 --- a/apps/mobile/apps/client/lib/main.dart +++ b/apps/mobile/apps/client/lib/main.dart @@ -30,16 +30,6 @@ void main() async { logStateChanges: false, // Set to true for verbose debugging ); - // 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', - 'BOTH', - ], // Only allow users with CLIENT, BUSINESS, or BOTH roles - ); - runApp( ModularApp( module: AppModule(), 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 5d5f124d..e6b26b37 100644 --- a/apps/mobile/apps/client/lib/src/widgets/session_listener.dart +++ b/apps/mobile/apps/client/lib/src/widgets/session_listener.dart @@ -27,11 +27,20 @@ class _SessionListenerState extends State { @override void initState() { super.initState(); - _setupSessionListener(); + _initializeSession(); } - void _setupSessionListener() { - _sessionSubscription = V2SessionService.instance.onSessionStateChanged + void _initializeSession() { + // Resolve V2SessionService via DI — this triggers CoreModule's lazy + // singleton, which wires setApiService(). Must happen before + // initializeAuthListener so the session endpoint is reachable. + final V2SessionService sessionService = Modular.get(); + + sessionService.initializeAuthListener( + allowedRoles: const ['CLIENT', 'BUSINESS', 'BOTH'], + ); + + _sessionSubscription = sessionService.onSessionStateChanged .listen((SessionState state) { _handleSessionChange(state); }); diff --git a/apps/mobile/apps/staff/lib/main.dart b/apps/mobile/apps/staff/lib/main.dart index 66fc30c8..3b0d4dd3 100644 --- a/apps/mobile/apps/staff/lib/main.dart +++ b/apps/mobile/apps/staff/lib/main.dart @@ -28,15 +28,6 @@ void main() async { logStateChanges: false, // Set to true for verbose debugging ); - // 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', - ], // Only allow users with STAFF or BOTH roles - ); - runApp( ModularApp( module: AppModule(), 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 f5385ed9..fd064c12 100644 --- a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart +++ b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart @@ -27,11 +27,20 @@ class _SessionListenerState extends State { @override void initState() { super.initState(); - _setupSessionListener(); + _initializeSession(); } - void _setupSessionListener() { - _sessionSubscription = V2SessionService.instance.onSessionStateChanged + void _initializeSession() { + // Resolve V2SessionService via DI — this triggers CoreModule's lazy + // singleton, which wires setApiService(). Must happen before + // initializeAuthListener so the session endpoint is reachable. + final V2SessionService sessionService = Modular.get(); + + sessionService.initializeAuthListener( + allowedRoles: const ['STAFF', 'BOTH'], + ); + + _sessionSubscription = sessionService.onSessionStateChanged .listen((SessionState state) { _handleSessionChange(state); }); diff --git a/apps/mobile/packages/core/lib/src/core_module.dart b/apps/mobile/packages/core/lib/src/core_module.dart index a1b90277..40145f27 100644 --- a/apps/mobile/packages/core/lib/src/core_module.dart +++ b/apps/mobile/packages/core/lib/src/core_module.dart @@ -18,9 +18,8 @@ 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. + // 2b. Register V2SessionService — wires the singleton with ApiService. + // Resolved eagerly by SessionListener.initState() after Modular is ready. i.addLazySingleton(() { final V2SessionService service = V2SessionService.instance; service.setApiService(i.get()); 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 index 126ada3b..35b8879a 100644 --- 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 @@ -42,23 +42,10 @@ class V2SessionService with SessionHandlerMixin { @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; ' + '[V2SessionService] ApiService not injected; ' 'cannot fetch user role.', ); return null; @@ -74,8 +61,16 @@ class V2SessionService with SessionHandlerMixin { // Per V2 auth doc, GET /auth/session is used for app startup hydration. _hydrateSessionStores(data); - final String? role = data['role'] as String?; - return role; + // Derive role from the presence of staff/business context. + // The session endpoint returns { user, tenant, business, vendor, staff } + // — there is no explicit "role" field. + final bool hasStaff = data['staff'] is Map; + final bool hasBusiness = data['business'] is Map; + + if (hasStaff && hasBusiness) return 'BOTH'; + if (hasStaff) return 'STAFF'; + if (hasBusiness) return 'BUSINESS'; + return null; } return null; } catch (e) { From cc4e2664b6f787381f5b6359f18940e8b2286777 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 11:09:12 -0400 Subject: [PATCH 06/25] feat(auth): enhance session management with proactive token refresh and error handling --- .../inspectors/auth_interceptor.dart | 52 +++++++++++--- .../mixins/session_handler_mixin.dart | 71 +++++++++---------- 2 files changed, 76 insertions(+), 47 deletions(-) diff --git a/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart b/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart index b52849b7..5cbe0d1c 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart @@ -1,7 +1,8 @@ import 'package:dio/dio.dart'; import 'package:firebase_auth/firebase_auth.dart'; -/// An interceptor that adds the Firebase Auth ID token to the Authorization header. +/// An interceptor that adds the Firebase Auth ID token to the Authorization +/// header and retries once on 401 with a force-refreshed token. /// /// Skips unauthenticated auth endpoints (sign-in, sign-up, phone/start) since /// the user has no Firebase session yet. Sign-out, session, and phone/verify @@ -14,6 +15,9 @@ class AuthInterceptor extends Interceptor { '/auth/staff/phone/start', ]; + /// Tracks whether a 401 retry is in progress to prevent infinite loops. + bool _isRetrying = false; + @override Future onRequest( RequestOptions options, @@ -27,16 +31,48 @@ class AuthInterceptor extends Interceptor { if (!skipAuth) { final User? user = FirebaseAuth.instance.currentUser; if (user != null) { - try { - final String? token = await user.getIdToken(); - if (token != null) { - options.headers['Authorization'] = 'Bearer $token'; - } - } catch (e) { - rethrow; + final String? token = await user.getIdToken(); + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; } } } return handler.next(options); } + + @override + Future onError( + DioException err, + ErrorInterceptorHandler handler, + ) async { + // Retry once with a force-refreshed token on 401 Unauthorized. + if (err.response?.statusCode == 401 && !_isRetrying) { + final bool skipAuth = _unauthenticatedPaths.any( + (String path) => err.requestOptions.path.contains(path), + ); + + if (!skipAuth) { + final User? user = FirebaseAuth.instance.currentUser; + if (user != null) { + _isRetrying = true; + try { + final String? freshToken = await user.getIdToken(true); + if (freshToken != null) { + // Retry the original request with the refreshed token. + err.requestOptions.headers['Authorization'] = + 'Bearer $freshToken'; + final Response response = + await Dio().fetch(err.requestOptions); + return handler.resolve(response); + } + } catch (_) { + // Force-refresh or retry failed — fall through to original error. + } finally { + _isRetrying = false; + } + } + } + } + return handler.next(err); + } } 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 6c37d595..afb14c0a 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 @@ -129,12 +129,39 @@ mixin SessionHandlerMixin { /// extract the role from the response. 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; - if (user == null) return; + /// Handle user sign-in event. + Future _handleSignIn(firebase_auth.User user) async { + try { + _emitSessionState(SessionState.loading()); + if (_allowedRoles.isNotEmpty) { + final String? userRole = await fetchUserRole(user.uid); + + if (userRole == null) { + _emitSessionState(SessionState.unauthenticated()); + return; + } + + if (!_allowedRoles.contains(userRole)) { + await auth.signOut(); + _emitSessionState(SessionState.unauthenticated()); + return; + } + } + + // Proactively refresh the token if it expires soon. + await _ensureSessionValid(user); + + _emitSessionState(SessionState.authenticated(userId: user.uid)); + } catch (e) { + _emitSessionState(SessionState.error(e.toString())); + } + } + + /// Ensures the Firebase auth token is valid and refreshes if it expires + /// within [_refreshThreshold]. Retries up to 3 times with exponential + /// backoff before emitting an error state. + Future _ensureSessionValid(firebase_auth.User user) async { final DateTime now = DateTime.now(); if (_lastTokenRefreshTime != null) { final Duration timeSinceLastCheck = now.difference( @@ -189,40 +216,6 @@ mixin SessionHandlerMixin { } } - /// Handle user sign-in event. - Future _handleSignIn(firebase_auth.User user) async { - try { - _emitSessionState(SessionState.loading()); - - if (_allowedRoles.isNotEmpty) { - final String? userRole = await fetchUserRole(user.uid); - - if (userRole == null) { - _emitSessionState(SessionState.unauthenticated()); - return; - } - - if (!_allowedRoles.contains(userRole)) { - await auth.signOut(); - _emitSessionState(SessionState.unauthenticated()); - return; - } - } - - final firebase_auth.IdTokenResult idToken = - await user.getIdTokenResult(); - if (idToken.expirationTime != null && - idToken.expirationTime!.difference(DateTime.now()) < - const Duration(minutes: 5)) { - await user.getIdTokenResult(true); - } - - _emitSessionState(SessionState.authenticated(userId: user.uid)); - } catch (e) { - _emitSessionState(SessionState.error(e.toString())); - } - } - /// Handle user sign-out event. void handleSignOut() { _emitSessionState(SessionState.unauthenticated()); From eccd2c6dbdaf2740aad77002c49edcc3f4576140 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 11:19:54 -0400 Subject: [PATCH 07/25] Use UserRole enum for session role handling Replace string-based role handling with a typed UserRole enum. Adds src/entities/enums/user_role.dart and exports it from krow_domain. Update SessionHandlerMixin to use List and change fetchUserRole to return UserRole?. V2SessionService now derives the role via UserRole.fromSessionData, and client/staff SessionListener widgets pass const [...] when initializing the auth listener. This centralizes role derivation and eliminates scattered role string literals. --- .../lib/src/widgets/session_listener.dart | 3 ++- .../lib/src/widgets/session_listener.dart | 3 ++- .../mixins/session_handler_mixin.dart | 19 +++++++------ .../services/session/v2_session_service.dart | 15 +++-------- .../packages/domain/lib/krow_domain.dart | 1 + .../lib/src/entities/enums/user_role.dart | 27 +++++++++++++++++++ 6 files changed, 44 insertions(+), 24 deletions(-) create mode 100644 apps/mobile/packages/domain/lib/src/entities/enums/user_role.dart 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 e6b26b37..968d1a3f 100644 --- a/apps/mobile/apps/client/lib/src/widgets/session_listener.dart +++ b/apps/mobile/apps/client/lib/src/widgets/session_listener.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart' show UserRole; /// A widget that listens to session state changes and handles global reactions. /// @@ -37,7 +38,7 @@ class _SessionListenerState extends State { final V2SessionService sessionService = Modular.get(); sessionService.initializeAuthListener( - allowedRoles: const ['CLIENT', 'BUSINESS', 'BOTH'], + allowedRoles: const [UserRole.business, UserRole.both], ); _sessionSubscription = sessionService.onSessionStateChanged 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 fd064c12..a07aa31f 100644 --- a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart +++ b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart' show UserRole; /// A widget that listens to session state changes and handles global reactions. /// @@ -37,7 +38,7 @@ class _SessionListenerState extends State { final V2SessionService sessionService = Modular.get(); sessionService.initializeAuthListener( - allowedRoles: const ['STAFF', 'BOTH'], + allowedRoles: const [UserRole.staff, UserRole.both], ); _sessionSubscription = sessionService.onSessionStateChanged 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 afb14c0a..b8458ca8 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 @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:firebase_auth/firebase_auth.dart' as firebase_auth; import 'package:flutter/cupertino.dart'; +import 'package:krow_domain/krow_domain.dart' show UserRole; /// Enum representing the current session state. enum SessionStateType { loading, authenticated, unauthenticated, error } @@ -85,11 +86,11 @@ mixin SessionHandlerMixin { firebase_auth.FirebaseAuth get auth; /// List of allowed roles for this app (set during initialization). - List _allowedRoles = []; + List _allowedRoles = []; /// Initialize the auth state listener (call once on app startup). void initializeAuthListener({ - List allowedRoles = const [], + List allowedRoles = const [], }) { _allowedRoles = allowedRoles; @@ -112,10 +113,10 @@ mixin SessionHandlerMixin { /// Validates if user has one of the allowed roles. Future validateUserRole( String userId, - List allowedRoles, + List allowedRoles, ) async { try { - final String? userRole = await fetchUserRole(userId); + final UserRole? userRole = await fetchUserRole(userId); return userRole != null && allowedRoles.contains(userRole); } catch (e) { debugPrint('Failed to validate user role: $e'); @@ -123,11 +124,9 @@ mixin SessionHandlerMixin { } } - /// Fetches user role from the backend. - /// - /// Implementors should call `GET /auth/session` via [ApiService] and - /// extract the role from the response. - Future fetchUserRole(String userId); + /// Fetches the user role from the backend by calling `GET /auth/session` + /// and deriving the [UserRole] from the response context. + Future fetchUserRole(String userId); /// Handle user sign-in event. Future _handleSignIn(firebase_auth.User user) async { @@ -135,7 +134,7 @@ mixin SessionHandlerMixin { _emitSessionState(SessionState.loading()); if (_allowedRoles.isNotEmpty) { - final String? userRole = await fetchUserRole(user.uid); + final UserRole? userRole = await fetchUserRole(user.uid); if (userRole == null) { _emitSessionState(SessionState.unauthenticated()); 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 index 35b8879a..0e55469a 100644 --- 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 @@ -37,10 +37,10 @@ class V2SessionService with SessionHandlerMixin { /// Fetches the user role by calling `GET /auth/session`. /// - /// Returns the role string (e.g. `STAFF`, `BUSINESS`, `BOTH`) or `null` if + /// Returns the [UserRole] derived from the session context, or `null` if /// the call fails or the user has no role. @override - Future fetchUserRole(String userId) async { + Future fetchUserRole(String userId) async { try { final BaseApiService? api = _apiService; if (api == null) { @@ -61,16 +61,7 @@ class V2SessionService with SessionHandlerMixin { // Per V2 auth doc, GET /auth/session is used for app startup hydration. _hydrateSessionStores(data); - // Derive role from the presence of staff/business context. - // The session endpoint returns { user, tenant, business, vendor, staff } - // — there is no explicit "role" field. - final bool hasStaff = data['staff'] is Map; - final bool hasBusiness = data['business'] is Map; - - if (hasStaff && hasBusiness) return 'BOTH'; - if (hasStaff) return 'STAFF'; - if (hasBusiness) return 'BUSINESS'; - return null; + return UserRole.fromSessionData(data); } return null; } catch (e) { diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index 2470cacf..efe8328e 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -20,6 +20,7 @@ export 'src/entities/enums/order_type.dart'; export 'src/entities/enums/payment_status.dart'; export 'src/entities/enums/shift_status.dart'; export 'src/entities/enums/staff_status.dart'; +export 'src/entities/enums/user_role.dart'; // Core export 'src/core/services/api_services/api_response.dart'; diff --git a/apps/mobile/packages/domain/lib/src/entities/enums/user_role.dart b/apps/mobile/packages/domain/lib/src/entities/enums/user_role.dart new file mode 100644 index 00000000..e81b6b26 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/enums/user_role.dart @@ -0,0 +1,27 @@ +/// The derived role of an authenticated user based on their session context. +/// +/// Derived from the presence of `staff` and `business` keys in the +/// `GET /auth/session` response — the API does not return an explicit role. +enum UserRole { + /// User has a staff profile only. + staff, + + /// User has a business membership only. + business, + + /// User has both staff and business context. + both; + + /// Derives the role from a session response map. + /// + /// Returns `null` if neither `staff` nor `business` context is present. + static UserRole? fromSessionData(Map data) { + final bool hasStaff = data['staff'] is Map; + final bool hasBusiness = data['business'] is Map; + + if (hasStaff && hasBusiness) return UserRole.both; + if (hasStaff) return UserRole.staff; + if (hasBusiness) return UserRole.business; + return null; + } +} From 31231c1e6dcc797ca28fecb87d338963f855cc99 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 11:26:44 -0400 Subject: [PATCH 08/25] feat(api): refactor API endpoints to use structured ApiEndpoint class and introduce new endpoint files --- apps/mobile/packages/core/lib/core.dart | 8 +- .../services/api_service/api_endpoint.dart | 16 ++ .../core_api_services/core_api_endpoints.dart | 31 +-- .../core_api_services/v2_api_endpoints.dart | 249 ++++++++++-------- .../src/services/api_service/dio_client.dart | 5 + .../api_service/endpoints/auth_endpoints.dart | 34 +++ .../endpoints/client_endpoints.dart | 165 ++++++++++++ .../api_service/endpoints/core_endpoints.dart | 40 +++ .../endpoints/staff_endpoints.dart | 176 +++++++++++++ .../inspectors/auth_interceptor.dart | 9 +- 10 files changed, 596 insertions(+), 137 deletions(-) create mode 100644 apps/mobile/packages/core/lib/src/services/api_service/api_endpoint.dart create mode 100644 apps/mobile/packages/core/lib/src/services/api_service/endpoints/auth_endpoints.dart create mode 100644 apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart create mode 100644 apps/mobile/packages/core/lib/src/services/api_service/endpoints/core_endpoints.dart create mode 100644 apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart diff --git a/apps/mobile/packages/core/lib/core.dart b/apps/mobile/packages/core/lib/core.dart index 0956107f..61f255d1 100644 --- a/apps/mobile/packages/core/lib/core.dart +++ b/apps/mobile/packages/core/lib/core.dart @@ -20,7 +20,13 @@ export 'src/services/api_service/dio_client.dart'; export 'src/services/api_service/mixins/api_error_handler.dart'; export 'src/services/api_service/mixins/session_handler_mixin.dart'; -// Core API Services +// API Endpoint classes +export 'src/services/api_service/api_endpoint.dart'; +export 'src/services/api_service/endpoints/auth_endpoints.dart'; +export 'src/services/api_service/endpoints/client_endpoints.dart'; +export 'src/services/api_service/endpoints/core_endpoints.dart'; +export 'src/services/api_service/endpoints/staff_endpoints.dart'; +// Backward-compatible facades (deprecated) export 'src/services/api_service/core_api_services/core_api_endpoints.dart'; export 'src/services/api_service/core_api_services/v2_api_endpoints.dart'; export 'src/services/api_service/core_api_services/file_upload/file_upload_service.dart'; diff --git a/apps/mobile/packages/core/lib/src/services/api_service/api_endpoint.dart b/apps/mobile/packages/core/lib/src/services/api_service/api_endpoint.dart new file mode 100644 index 00000000..e0323a85 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/api_endpoint.dart @@ -0,0 +1,16 @@ +/// Represents an API endpoint with its path and required scopes for future +/// feature gating. +class ApiEndpoint { + /// Creates an [ApiEndpoint] with the given [path] and optional + /// [requiredScopes]. + const ApiEndpoint(this.path, {this.requiredScopes = const []}); + + /// The relative URL path (e.g. '/auth/client/sign-in'). + final String path; + + /// Scopes required to access this endpoint. Empty means no gate. + final List requiredScopes; + + @override + String toString() => path; +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/core_api_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/core_api_endpoints.dart index 89dd46d9..a5b53e05 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/core_api_endpoints.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/core_api_endpoints.dart @@ -1,40 +1,41 @@ -import '../../../config/app_config.dart'; +import 'package:krow_core/src/services/api_service/endpoints/core_endpoints.dart'; -/// Constants for Core API endpoints. +/// Backward-compatible facade that re-exports all Core API endpoints as +/// [String] paths. +/// +/// New code should use [CoreEndpoints] directly. +@Deprecated('Use CoreEndpoints directly') class CoreApiEndpoints { CoreApiEndpoints._(); - /// The base URL for the Core API. - static const String baseUrl = AppConfig.coreApiBaseUrl; - /// Upload a file. - static const String uploadFile = '$baseUrl/core/upload-file'; + static String get uploadFile => CoreEndpoints.uploadFile.path; /// Create a signed URL for a file. - static const String createSignedUrl = '$baseUrl/core/create-signed-url'; + static String get createSignedUrl => CoreEndpoints.createSignedUrl.path; /// Invoke a Large Language Model. - static const String invokeLlm = '$baseUrl/core/invoke-llm'; + static String get invokeLlm => CoreEndpoints.invokeLlm.path; /// Root for verification operations. - static const String verifications = '$baseUrl/core/verifications'; + static String get verifications => CoreEndpoints.verifications.path; /// Get status of a verification job. static String verificationStatus(String id) => - '$baseUrl/core/verifications/$id'; + CoreEndpoints.verificationStatus(id).path; /// Review a verification decision. static String verificationReview(String id) => - '$baseUrl/core/verifications/$id/review'; + CoreEndpoints.verificationReview(id).path; /// Retry a verification job. static String verificationRetry(String id) => - '$baseUrl/core/verifications/$id/retry'; + CoreEndpoints.verificationRetry(id).path; /// Transcribe audio to text for rapid orders. - static const String transcribeRapidOrder = - '$baseUrl/core/rapid-orders/transcribe'; + static String get transcribeRapidOrder => + CoreEndpoints.transcribeRapidOrder.path; /// Parse text to structured rapid order. - static const String parseRapidOrder = '$baseUrl/core/rapid-orders/parse'; + static String get parseRapidOrder => CoreEndpoints.parseRapidOrder.path; } diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/v2_api_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/v2_api_endpoints.dart index dcc9c619..930ea8aa 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/v2_api_endpoints.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/v2_api_endpoints.dart @@ -1,343 +1,358 @@ -import '../../../config/app_config.dart'; +import 'package:krow_core/src/services/api_service/endpoints/auth_endpoints.dart'; +import 'package:krow_core/src/services/api_service/endpoints/client_endpoints.dart'; +import 'package:krow_core/src/services/api_service/endpoints/staff_endpoints.dart'; -/// Constants for V2 Unified API endpoints. +/// Backward-compatible facade that re-exports all V2 endpoints as [String] +/// paths. /// -/// All mobile read/write operations go through the V2 gateway which proxies -/// to the internal Query API and Command API services. +/// New code should use [AuthEndpoints], [StaffEndpoints], or +/// [ClientEndpoints] directly. +@Deprecated( + 'Use AuthEndpoints, StaffEndpoints, or ClientEndpoints directly', +) class V2ApiEndpoints { V2ApiEndpoints._(); - /// The base URL for the V2 Unified API gateway. - static const String baseUrl = AppConfig.v2ApiBaseUrl; - // ── Auth ────────────────────────────────────────────────────────────── /// Client email/password sign-in. - static const String clientSignIn = '$baseUrl/auth/client/sign-in'; + static String get clientSignIn => AuthEndpoints.clientSignIn.path; /// Client business registration. - static const String clientSignUp = '$baseUrl/auth/client/sign-up'; + static String get clientSignUp => AuthEndpoints.clientSignUp.path; /// Client sign-out. - static const String clientSignOut = '$baseUrl/auth/client/sign-out'; + static String get clientSignOut => AuthEndpoints.clientSignOut.path; /// Start staff phone verification (SMS). - static const String staffPhoneStart = '$baseUrl/auth/staff/phone/start'; + static String get staffPhoneStart => AuthEndpoints.staffPhoneStart.path; /// Complete staff phone verification. - static const String staffPhoneVerify = '$baseUrl/auth/staff/phone/verify'; + static String get staffPhoneVerify => AuthEndpoints.staffPhoneVerify.path; /// Generic sign-out. - static const String signOut = '$baseUrl/auth/sign-out'; + static String get signOut => AuthEndpoints.signOut.path; /// Staff-specific sign-out. - static const String staffSignOut = '$baseUrl/auth/staff/sign-out'; + static String get staffSignOut => AuthEndpoints.staffSignOut.path; /// Get current session data. - static const String session = '$baseUrl/auth/session'; + static String get session => AuthEndpoints.session.path; // ── Staff Read ──────────────────────────────────────────────────────── /// Staff session data. - static const String staffSession = '$baseUrl/staff/session'; + static String get staffSession => StaffEndpoints.session.path; /// Staff dashboard overview. - static const String staffDashboard = '$baseUrl/staff/dashboard'; + static String get staffDashboard => StaffEndpoints.dashboard.path; /// Staff profile completion status. - static const String staffProfileCompletion = - '$baseUrl/staff/profile-completion'; + static String get staffProfileCompletion => + StaffEndpoints.profileCompletion.path; /// Staff availability schedule. - static const String staffAvailability = '$baseUrl/staff/availability'; + static String get staffAvailability => StaffEndpoints.availability.path; /// Today's shifts for clock-in. - static const String staffClockInShiftsToday = - '$baseUrl/staff/clock-in/shifts/today'; + static String get staffClockInShiftsToday => + StaffEndpoints.clockInShiftsToday.path; /// Current clock-in status. - static const String staffClockInStatus = '$baseUrl/staff/clock-in/status'; + static String get staffClockInStatus => StaffEndpoints.clockInStatus.path; /// Payments summary. - static const String staffPaymentsSummary = '$baseUrl/staff/payments/summary'; + static String get staffPaymentsSummary => + StaffEndpoints.paymentsSummary.path; /// Payments history. - static const String staffPaymentsHistory = '$baseUrl/staff/payments/history'; + static String get staffPaymentsHistory => + StaffEndpoints.paymentsHistory.path; /// Payments chart data. - static const String staffPaymentsChart = '$baseUrl/staff/payments/chart'; + static String get staffPaymentsChart => StaffEndpoints.paymentsChart.path; /// Assigned shifts. - static const String staffShiftsAssigned = '$baseUrl/staff/shifts/assigned'; + static String get staffShiftsAssigned => + StaffEndpoints.shiftsAssigned.path; /// Open shifts available to apply. - static const String staffShiftsOpen = '$baseUrl/staff/shifts/open'; + static String get staffShiftsOpen => StaffEndpoints.shiftsOpen.path; /// Pending shift assignments. - static const String staffShiftsPending = '$baseUrl/staff/shifts/pending'; + static String get staffShiftsPending => StaffEndpoints.shiftsPending.path; /// Cancelled shifts. - static const String staffShiftsCancelled = '$baseUrl/staff/shifts/cancelled'; + static String get staffShiftsCancelled => + StaffEndpoints.shiftsCancelled.path; /// Completed shifts. - static const String staffShiftsCompleted = '$baseUrl/staff/shifts/completed'; + static String get staffShiftsCompleted => + StaffEndpoints.shiftsCompleted.path; /// Shift details by ID. static String staffShiftDetails(String shiftId) => - '$baseUrl/staff/shifts/$shiftId'; + StaffEndpoints.shiftDetails(shiftId).path; /// Staff profile sections overview. - static const String staffProfileSections = '$baseUrl/staff/profile/sections'; + static String get staffProfileSections => + StaffEndpoints.profileSections.path; /// Personal info. - static const String staffPersonalInfo = '$baseUrl/staff/profile/personal-info'; + static String get staffPersonalInfo => StaffEndpoints.personalInfo.path; /// Industries/experience. - static const String staffIndustries = '$baseUrl/staff/profile/industries'; + static String get staffIndustries => StaffEndpoints.industries.path; /// Skills. - static const String staffSkills = '$baseUrl/staff/profile/skills'; + static String get staffSkills => StaffEndpoints.skills.path; /// Documents. - static const String staffDocuments = '$baseUrl/staff/profile/documents'; + static String get staffDocuments => StaffEndpoints.documents.path; /// Attire items. - static const String staffAttire = '$baseUrl/staff/profile/attire'; + static String get staffAttire => StaffEndpoints.attire.path; /// Tax forms. - static const String staffTaxForms = '$baseUrl/staff/profile/tax-forms'; + static String get staffTaxForms => StaffEndpoints.taxForms.path; /// Emergency contacts. - static const String staffEmergencyContacts = - '$baseUrl/staff/profile/emergency-contacts'; + static String get staffEmergencyContacts => + StaffEndpoints.emergencyContacts.path; /// Certificates. - static const String staffCertificates = '$baseUrl/staff/profile/certificates'; + static String get staffCertificates => StaffEndpoints.certificates.path; /// Bank accounts. - static const String staffBankAccounts = '$baseUrl/staff/profile/bank-accounts'; + static String get staffBankAccounts => StaffEndpoints.bankAccounts.path; /// Benefits. - static const String staffBenefits = '$baseUrl/staff/profile/benefits'; + static String get staffBenefits => StaffEndpoints.benefits.path; /// Time card. - static const String staffTimeCard = '$baseUrl/staff/profile/time-card'; + static String get staffTimeCard => StaffEndpoints.timeCard.path; /// Privacy settings. - static const String staffPrivacy = '$baseUrl/staff/profile/privacy'; + static String get staffPrivacy => StaffEndpoints.privacy.path; /// FAQs. - static const String staffFaqs = '$baseUrl/staff/faqs'; + static String get staffFaqs => StaffEndpoints.faqs.path; /// FAQs search. - static const String staffFaqsSearch = '$baseUrl/staff/faqs/search'; + static String get staffFaqsSearch => StaffEndpoints.faqsSearch.path; // ── Staff Write ─────────────────────────────────────────────────────── /// Staff profile setup. - static const String staffProfileSetup = '$baseUrl/staff/profile/setup'; + static String get staffProfileSetup => StaffEndpoints.profileSetup.path; /// Clock in. - static const String staffClockIn = '$baseUrl/staff/clock-in'; + static String get staffClockIn => StaffEndpoints.clockIn.path; /// Clock out. - static const String staffClockOut = '$baseUrl/staff/clock-out'; + static String get staffClockOut => StaffEndpoints.clockOut.path; /// Quick-set availability. - static const String staffAvailabilityQuickSet = - '$baseUrl/staff/availability/quick-set'; + static String get staffAvailabilityQuickSet => + StaffEndpoints.availabilityQuickSet.path; /// Apply for a shift. static String staffShiftApply(String shiftId) => - '$baseUrl/staff/shifts/$shiftId/apply'; + StaffEndpoints.shiftApply(shiftId).path; /// Accept a shift. static String staffShiftAccept(String shiftId) => - '$baseUrl/staff/shifts/$shiftId/accept'; + StaffEndpoints.shiftAccept(shiftId).path; /// Decline a shift. static String staffShiftDecline(String shiftId) => - '$baseUrl/staff/shifts/$shiftId/decline'; + StaffEndpoints.shiftDecline(shiftId).path; /// Request a shift swap. static String staffShiftRequestSwap(String shiftId) => - '$baseUrl/staff/shifts/$shiftId/request-swap'; + StaffEndpoints.shiftRequestSwap(shiftId).path; /// Update emergency contact by ID. static String staffEmergencyContactUpdate(String contactId) => - '$baseUrl/staff/profile/emergency-contacts/$contactId'; + StaffEndpoints.emergencyContactUpdate(contactId).path; /// Update tax form by type. static String staffTaxFormUpdate(String formType) => - '$baseUrl/staff/profile/tax-forms/$formType'; + StaffEndpoints.taxFormUpdate(formType).path; /// Submit tax form by type. static String staffTaxFormSubmit(String formType) => - '$baseUrl/staff/profile/tax-forms/$formType/submit'; + StaffEndpoints.taxFormSubmit(formType).path; /// Upload staff profile photo. - static const String staffProfilePhoto = '$baseUrl/staff/profile/photo'; + static String get staffProfilePhoto => StaffEndpoints.profilePhoto.path; /// Upload document by ID. static String staffDocumentUpload(String documentId) => - '$baseUrl/staff/profile/documents/$documentId/upload'; + StaffEndpoints.documentUpload(documentId).path; /// Upload attire by ID. static String staffAttireUpload(String documentId) => - '$baseUrl/staff/profile/attire/$documentId/upload'; + StaffEndpoints.attireUpload(documentId).path; /// Delete certificate by ID. static String staffCertificateDelete(String certificateId) => - '$baseUrl/staff/profile/certificates/$certificateId'; + StaffEndpoints.certificateDelete(certificateId).path; // ── Client Read ─────────────────────────────────────────────────────── /// Client session data. - static const String clientSession = '$baseUrl/client/session'; + static String get clientSession => ClientEndpoints.session.path; /// Client dashboard. - static const String clientDashboard = '$baseUrl/client/dashboard'; + static String get clientDashboard => ClientEndpoints.dashboard.path; /// Client reorders. - static const String clientReorders = '$baseUrl/client/reorders'; + static String get clientReorders => ClientEndpoints.reorders.path; /// Billing accounts. - static const String clientBillingAccounts = '$baseUrl/client/billing/accounts'; + static String get clientBillingAccounts => + ClientEndpoints.billingAccounts.path; /// Pending invoices. - static const String clientBillingInvoicesPending = - '$baseUrl/client/billing/invoices/pending'; + static String get clientBillingInvoicesPending => + ClientEndpoints.billingInvoicesPending.path; /// Invoice history. - static const String clientBillingInvoicesHistory = - '$baseUrl/client/billing/invoices/history'; + static String get clientBillingInvoicesHistory => + ClientEndpoints.billingInvoicesHistory.path; /// Current bill. - static const String clientBillingCurrentBill = - '$baseUrl/client/billing/current-bill'; + static String get clientBillingCurrentBill => + ClientEndpoints.billingCurrentBill.path; /// Savings data. - static const String clientBillingSavings = '$baseUrl/client/billing/savings'; + static String get clientBillingSavings => + ClientEndpoints.billingSavings.path; /// Spend breakdown. - static const String clientBillingSpendBreakdown = - '$baseUrl/client/billing/spend-breakdown'; + static String get clientBillingSpendBreakdown => + ClientEndpoints.billingSpendBreakdown.path; /// Coverage overview. - static const String clientCoverage = '$baseUrl/client/coverage'; + static String get clientCoverage => ClientEndpoints.coverage.path; /// Coverage stats. - static const String clientCoverageStats = '$baseUrl/client/coverage/stats'; + static String get clientCoverageStats => + ClientEndpoints.coverageStats.path; /// Core team. - static const String clientCoverageCoreTeam = - '$baseUrl/client/coverage/core-team'; + static String get clientCoverageCoreTeam => + ClientEndpoints.coverageCoreTeam.path; /// Hubs list. - static const String clientHubs = '$baseUrl/client/hubs'; + static String get clientHubs => ClientEndpoints.hubs.path; /// Cost centers. - static const String clientCostCenters = '$baseUrl/client/cost-centers'; + static String get clientCostCenters => ClientEndpoints.costCenters.path; /// Vendors. - static const String clientVendors = '$baseUrl/client/vendors'; + static String get clientVendors => ClientEndpoints.vendors.path; /// Vendor roles by ID. static String clientVendorRoles(String vendorId) => - '$baseUrl/client/vendors/$vendorId/roles'; + ClientEndpoints.vendorRoles(vendorId).path; /// Hub managers by ID. static String clientHubManagers(String hubId) => - '$baseUrl/client/hubs/$hubId/managers'; + ClientEndpoints.hubManagers(hubId).path; /// Team members. - static const String clientTeamMembers = '$baseUrl/client/team-members'; + static String get clientTeamMembers => ClientEndpoints.teamMembers.path; /// View orders. - static const String clientOrdersView = '$baseUrl/client/orders/view'; + static String get clientOrdersView => ClientEndpoints.ordersView.path; /// Order reorder preview. static String clientOrderReorderPreview(String orderId) => - '$baseUrl/client/orders/$orderId/reorder-preview'; + ClientEndpoints.orderReorderPreview(orderId).path; /// Reports summary. - static const String clientReportsSummary = '$baseUrl/client/reports/summary'; + static String get clientReportsSummary => + ClientEndpoints.reportsSummary.path; /// Daily ops report. - static const String clientReportsDailyOps = - '$baseUrl/client/reports/daily-ops'; + static String get clientReportsDailyOps => + ClientEndpoints.reportsDailyOps.path; /// Spend report. - static const String clientReportsSpend = '$baseUrl/client/reports/spend'; + static String get clientReportsSpend => ClientEndpoints.reportsSpend.path; /// Coverage report. - static const String clientReportsCoverage = - '$baseUrl/client/reports/coverage'; + static String get clientReportsCoverage => + ClientEndpoints.reportsCoverage.path; /// Forecast report. - static const String clientReportsForecast = - '$baseUrl/client/reports/forecast'; + static String get clientReportsForecast => + ClientEndpoints.reportsForecast.path; /// Performance report. - static const String clientReportsPerformance = - '$baseUrl/client/reports/performance'; + static String get clientReportsPerformance => + ClientEndpoints.reportsPerformance.path; /// No-show report. - static const String clientReportsNoShow = '$baseUrl/client/reports/no-show'; + static String get clientReportsNoShow => + ClientEndpoints.reportsNoShow.path; // ── Client Write ────────────────────────────────────────────────────── /// Create one-time order. - static const String clientOrdersOneTime = '$baseUrl/client/orders/one-time'; + static String get clientOrdersOneTime => + ClientEndpoints.ordersOneTime.path; /// Create recurring order. - static const String clientOrdersRecurring = - '$baseUrl/client/orders/recurring'; + static String get clientOrdersRecurring => + ClientEndpoints.ordersRecurring.path; /// Create permanent order. - static const String clientOrdersPermanent = - '$baseUrl/client/orders/permanent'; + static String get clientOrdersPermanent => + ClientEndpoints.ordersPermanent.path; /// Edit order by ID. static String clientOrderEdit(String orderId) => - '$baseUrl/client/orders/$orderId/edit'; + ClientEndpoints.orderEdit(orderId).path; /// Cancel order by ID. static String clientOrderCancel(String orderId) => - '$baseUrl/client/orders/$orderId/cancel'; + ClientEndpoints.orderCancel(orderId).path; /// Create hub. - static const String clientHubCreate = '$baseUrl/client/hubs'; + static String get clientHubCreate => ClientEndpoints.hubCreate.path; /// Update hub by ID. static String clientHubUpdate(String hubId) => - '$baseUrl/client/hubs/$hubId'; + ClientEndpoints.hubUpdate(hubId).path; /// Delete hub by ID. static String clientHubDelete(String hubId) => - '$baseUrl/client/hubs/$hubId'; + ClientEndpoints.hubDelete(hubId).path; /// Assign NFC to hub. static String clientHubAssignNfc(String hubId) => - '$baseUrl/client/hubs/$hubId/assign-nfc'; + ClientEndpoints.hubAssignNfc(hubId).path; /// Assign managers to hub. static String clientHubAssignManagers(String hubId) => - '$baseUrl/client/hubs/$hubId/managers'; + ClientEndpoints.hubAssignManagers(hubId).path; /// Approve invoice. static String clientInvoiceApprove(String invoiceId) => - '$baseUrl/client/billing/invoices/$invoiceId/approve'; + ClientEndpoints.invoiceApprove(invoiceId).path; /// Dispute invoice. static String clientInvoiceDispute(String invoiceId) => - '$baseUrl/client/billing/invoices/$invoiceId/dispute'; + ClientEndpoints.invoiceDispute(invoiceId).path; /// Submit coverage review. - static const String clientCoverageReviews = - '$baseUrl/client/coverage/reviews'; + static String get clientCoverageReviews => + ClientEndpoints.coverageReviews.path; /// Cancel late worker assignment. static String clientCoverageCancelLateWorker(String assignmentId) => - '$baseUrl/client/coverage/late-workers/$assignmentId/cancel'; + ClientEndpoints.coverageCancelLateWorker(assignmentId).path; } diff --git a/apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart b/apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart index f869e260..da598388 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart @@ -1,14 +1,19 @@ import 'package:dio/dio.dart'; +import 'package:krow_core/src/config/app_config.dart'; import 'package:krow_core/src/services/api_service/inspectors/auth_interceptor.dart'; import 'package:krow_core/src/services/api_service/inspectors/idempotency_interceptor.dart'; /// A custom Dio client for the KROW project that includes basic configuration, /// [AuthInterceptor], and [IdempotencyInterceptor]. +/// +/// Sets [AppConfig.v2ApiBaseUrl] as the base URL so that endpoint paths only +/// need to be relative (e.g. '/staff/dashboard'). class DioClient extends DioMixin implements Dio { DioClient([BaseOptions? baseOptions]) { options = baseOptions ?? BaseOptions( + baseUrl: AppConfig.v2ApiBaseUrl, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), ); diff --git a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/auth_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/auth_endpoints.dart new file mode 100644 index 00000000..3dd0e56c --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/auth_endpoints.dart @@ -0,0 +1,34 @@ +import 'package:krow_core/src/services/api_service/api_endpoint.dart'; + +/// Authentication endpoints for both staff and client apps. +abstract final class AuthEndpoints { + /// Client email/password sign-in. + static const ApiEndpoint clientSignIn = + ApiEndpoint('/auth/client/sign-in'); + + /// Client business registration. + static const ApiEndpoint clientSignUp = + ApiEndpoint('/auth/client/sign-up'); + + /// Client sign-out. + static const ApiEndpoint clientSignOut = + ApiEndpoint('/auth/client/sign-out'); + + /// Start staff phone verification (SMS). + static const ApiEndpoint staffPhoneStart = + ApiEndpoint('/auth/staff/phone/start'); + + /// Complete staff phone verification. + static const ApiEndpoint staffPhoneVerify = + ApiEndpoint('/auth/staff/phone/verify'); + + /// Generic sign-out. + static const ApiEndpoint signOut = ApiEndpoint('/auth/sign-out'); + + /// Staff-specific sign-out. + static const ApiEndpoint staffSignOut = + ApiEndpoint('/auth/staff/sign-out'); + + /// Get current session data. + static const ApiEndpoint session = ApiEndpoint('/auth/session'); +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart new file mode 100644 index 00000000..b72cd7b1 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart @@ -0,0 +1,165 @@ +import 'package:krow_core/src/services/api_service/api_endpoint.dart'; + +/// Client-specific API endpoints (read and write). +abstract final class ClientEndpoints { + // ── Read ────────────────────────────────────────────────────────────── + + /// Client session data. + static const ApiEndpoint session = ApiEndpoint('/client/session'); + + /// Client dashboard. + static const ApiEndpoint dashboard = ApiEndpoint('/client/dashboard'); + + /// Client reorders. + static const ApiEndpoint reorders = ApiEndpoint('/client/reorders'); + + /// Billing accounts. + static const ApiEndpoint billingAccounts = + ApiEndpoint('/client/billing/accounts'); + + /// Pending invoices. + static const ApiEndpoint billingInvoicesPending = + ApiEndpoint('/client/billing/invoices/pending'); + + /// Invoice history. + static const ApiEndpoint billingInvoicesHistory = + ApiEndpoint('/client/billing/invoices/history'); + + /// Current bill. + static const ApiEndpoint billingCurrentBill = + ApiEndpoint('/client/billing/current-bill'); + + /// Savings data. + static const ApiEndpoint billingSavings = + ApiEndpoint('/client/billing/savings'); + + /// Spend breakdown. + static const ApiEndpoint billingSpendBreakdown = + ApiEndpoint('/client/billing/spend-breakdown'); + + /// Coverage overview. + static const ApiEndpoint coverage = ApiEndpoint('/client/coverage'); + + /// Coverage stats. + static const ApiEndpoint coverageStats = + ApiEndpoint('/client/coverage/stats'); + + /// Core team. + static const ApiEndpoint coverageCoreTeam = + ApiEndpoint('/client/coverage/core-team'); + + /// Hubs list. + static const ApiEndpoint hubs = ApiEndpoint('/client/hubs'); + + /// Cost centers. + static const ApiEndpoint costCenters = + ApiEndpoint('/client/cost-centers'); + + /// Vendors. + static const ApiEndpoint vendors = ApiEndpoint('/client/vendors'); + + /// Vendor roles by ID. + static ApiEndpoint vendorRoles(String vendorId) => + ApiEndpoint('/client/vendors/$vendorId/roles'); + + /// Hub managers by ID. + static ApiEndpoint hubManagers(String hubId) => + ApiEndpoint('/client/hubs/$hubId/managers'); + + /// Team members. + static const ApiEndpoint teamMembers = + ApiEndpoint('/client/team-members'); + + /// View orders. + static const ApiEndpoint ordersView = + ApiEndpoint('/client/orders/view'); + + /// Order reorder preview. + static ApiEndpoint orderReorderPreview(String orderId) => + ApiEndpoint('/client/orders/$orderId/reorder-preview'); + + /// Reports summary. + static const ApiEndpoint reportsSummary = + ApiEndpoint('/client/reports/summary'); + + /// Daily ops report. + static const ApiEndpoint reportsDailyOps = + ApiEndpoint('/client/reports/daily-ops'); + + /// Spend report. + static const ApiEndpoint reportsSpend = + ApiEndpoint('/client/reports/spend'); + + /// Coverage report. + static const ApiEndpoint reportsCoverage = + ApiEndpoint('/client/reports/coverage'); + + /// Forecast report. + static const ApiEndpoint reportsForecast = + ApiEndpoint('/client/reports/forecast'); + + /// Performance report. + static const ApiEndpoint reportsPerformance = + ApiEndpoint('/client/reports/performance'); + + /// No-show report. + static const ApiEndpoint reportsNoShow = + ApiEndpoint('/client/reports/no-show'); + + // ── Write ───────────────────────────────────────────────────────────── + + /// Create one-time order. + static const ApiEndpoint ordersOneTime = + ApiEndpoint('/client/orders/one-time'); + + /// Create recurring order. + static const ApiEndpoint ordersRecurring = + ApiEndpoint('/client/orders/recurring'); + + /// Create permanent order. + static const ApiEndpoint ordersPermanent = + ApiEndpoint('/client/orders/permanent'); + + /// Edit order by ID. + static ApiEndpoint orderEdit(String orderId) => + ApiEndpoint('/client/orders/$orderId/edit'); + + /// Cancel order by ID. + static ApiEndpoint orderCancel(String orderId) => + ApiEndpoint('/client/orders/$orderId/cancel'); + + /// Create hub (same path as list hubs). + static const ApiEndpoint hubCreate = ApiEndpoint('/client/hubs'); + + /// Update hub by ID. + static ApiEndpoint hubUpdate(String hubId) => + ApiEndpoint('/client/hubs/$hubId'); + + /// Delete hub by ID. + static ApiEndpoint hubDelete(String hubId) => + ApiEndpoint('/client/hubs/$hubId'); + + /// Assign NFC to hub. + static ApiEndpoint hubAssignNfc(String hubId) => + ApiEndpoint('/client/hubs/$hubId/assign-nfc'); + + /// Assign managers to hub. + static ApiEndpoint hubAssignManagers(String hubId) => + ApiEndpoint('/client/hubs/$hubId/managers'); + + /// Approve invoice. + static ApiEndpoint invoiceApprove(String invoiceId) => + ApiEndpoint('/client/billing/invoices/$invoiceId/approve'); + + /// Dispute invoice. + static ApiEndpoint invoiceDispute(String invoiceId) => + ApiEndpoint('/client/billing/invoices/$invoiceId/dispute'); + + /// Submit coverage review. + static const ApiEndpoint coverageReviews = + ApiEndpoint('/client/coverage/reviews'); + + /// Cancel late worker assignment. + static ApiEndpoint coverageCancelLateWorker(String assignmentId) => + ApiEndpoint('/client/coverage/late-workers/$assignmentId/cancel'); +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/core_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/core_endpoints.dart new file mode 100644 index 00000000..22b59769 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/core_endpoints.dart @@ -0,0 +1,40 @@ +import 'package:krow_core/src/services/api_service/api_endpoint.dart'; + +/// Core infrastructure endpoints (upload, signed URLs, LLM, verifications, +/// rapid orders). +abstract final class CoreEndpoints { + /// Upload a file. + static const ApiEndpoint uploadFile = + ApiEndpoint('/core/upload-file'); + + /// Create a signed URL for a file. + static const ApiEndpoint createSignedUrl = + ApiEndpoint('/core/create-signed-url'); + + /// Invoke a Large Language Model. + static const ApiEndpoint invokeLlm = ApiEndpoint('/core/invoke-llm'); + + /// Root for verification operations. + static const ApiEndpoint verifications = + ApiEndpoint('/core/verifications'); + + /// Get status of a verification job. + static ApiEndpoint verificationStatus(String id) => + ApiEndpoint('/core/verifications/$id'); + + /// Review a verification decision. + static ApiEndpoint verificationReview(String id) => + ApiEndpoint('/core/verifications/$id/review'); + + /// Retry a verification job. + static ApiEndpoint verificationRetry(String id) => + ApiEndpoint('/core/verifications/$id/retry'); + + /// Transcribe audio to text for rapid orders. + static const ApiEndpoint transcribeRapidOrder = + ApiEndpoint('/core/rapid-orders/transcribe'); + + /// Parse text to structured rapid order. + static const ApiEndpoint parseRapidOrder = + ApiEndpoint('/core/rapid-orders/parse'); +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart new file mode 100644 index 00000000..b12db827 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart @@ -0,0 +1,176 @@ +import 'package:krow_core/src/services/api_service/api_endpoint.dart'; + +/// Staff-specific API endpoints (read and write). +abstract final class StaffEndpoints { + // ── Read ────────────────────────────────────────────────────────────── + + /// Staff session data. + static const ApiEndpoint session = ApiEndpoint('/staff/session'); + + /// Staff dashboard overview. + static const ApiEndpoint dashboard = ApiEndpoint('/staff/dashboard'); + + /// Staff profile completion status. + static const ApiEndpoint profileCompletion = + ApiEndpoint('/staff/profile-completion'); + + /// Staff availability schedule. + static const ApiEndpoint availability = ApiEndpoint('/staff/availability'); + + /// Today's shifts for clock-in. + static const ApiEndpoint clockInShiftsToday = + ApiEndpoint('/staff/clock-in/shifts/today'); + + /// Current clock-in status. + static const ApiEndpoint clockInStatus = + ApiEndpoint('/staff/clock-in/status'); + + /// Payments summary. + static const ApiEndpoint paymentsSummary = + ApiEndpoint('/staff/payments/summary'); + + /// Payments history. + static const ApiEndpoint paymentsHistory = + ApiEndpoint('/staff/payments/history'); + + /// Payments chart data. + static const ApiEndpoint paymentsChart = + ApiEndpoint('/staff/payments/chart'); + + /// Assigned shifts. + static const ApiEndpoint shiftsAssigned = + ApiEndpoint('/staff/shifts/assigned'); + + /// Open shifts available to apply. + static const ApiEndpoint shiftsOpen = ApiEndpoint('/staff/shifts/open'); + + /// Pending shift assignments. + static const ApiEndpoint shiftsPending = + ApiEndpoint('/staff/shifts/pending'); + + /// Cancelled shifts. + static const ApiEndpoint shiftsCancelled = + ApiEndpoint('/staff/shifts/cancelled'); + + /// Completed shifts. + static const ApiEndpoint shiftsCompleted = + ApiEndpoint('/staff/shifts/completed'); + + /// Shift details by ID. + static ApiEndpoint shiftDetails(String shiftId) => + ApiEndpoint('/staff/shifts/$shiftId'); + + /// Staff profile sections overview. + static const ApiEndpoint profileSections = + ApiEndpoint('/staff/profile/sections'); + + /// Personal info. + static const ApiEndpoint personalInfo = + ApiEndpoint('/staff/profile/personal-info'); + + /// Industries/experience. + static const ApiEndpoint industries = + ApiEndpoint('/staff/profile/industries'); + + /// Skills. + static const ApiEndpoint skills = ApiEndpoint('/staff/profile/skills'); + + /// Documents. + static const ApiEndpoint documents = + ApiEndpoint('/staff/profile/documents'); + + /// Attire items. + static const ApiEndpoint attire = ApiEndpoint('/staff/profile/attire'); + + /// Tax forms. + static const ApiEndpoint taxForms = + ApiEndpoint('/staff/profile/tax-forms'); + + /// Emergency contacts. + static const ApiEndpoint emergencyContacts = + ApiEndpoint('/staff/profile/emergency-contacts'); + + /// Certificates. + static const ApiEndpoint certificates = + ApiEndpoint('/staff/profile/certificates'); + + /// Bank accounts. + static const ApiEndpoint bankAccounts = + ApiEndpoint('/staff/profile/bank-accounts'); + + /// Benefits. + static const ApiEndpoint benefits = ApiEndpoint('/staff/profile/benefits'); + + /// Time card. + static const ApiEndpoint timeCard = + ApiEndpoint('/staff/profile/time-card'); + + /// Privacy settings. + static const ApiEndpoint privacy = ApiEndpoint('/staff/profile/privacy'); + + /// FAQs. + static const ApiEndpoint faqs = ApiEndpoint('/staff/faqs'); + + /// FAQs search. + static const ApiEndpoint faqsSearch = ApiEndpoint('/staff/faqs/search'); + + // ── Write ───────────────────────────────────────────────────────────── + + /// Staff profile setup. + static const ApiEndpoint profileSetup = + ApiEndpoint('/staff/profile/setup'); + + /// Clock in. + static const ApiEndpoint clockIn = ApiEndpoint('/staff/clock-in'); + + /// Clock out. + static const ApiEndpoint clockOut = ApiEndpoint('/staff/clock-out'); + + /// Quick-set availability. + static const ApiEndpoint availabilityQuickSet = + ApiEndpoint('/staff/availability/quick-set'); + + /// Apply for a shift. + static ApiEndpoint shiftApply(String shiftId) => + ApiEndpoint('/staff/shifts/$shiftId/apply'); + + /// Accept a shift. + static ApiEndpoint shiftAccept(String shiftId) => + ApiEndpoint('/staff/shifts/$shiftId/accept'); + + /// Decline a shift. + static ApiEndpoint shiftDecline(String shiftId) => + ApiEndpoint('/staff/shifts/$shiftId/decline'); + + /// Request a shift swap. + static ApiEndpoint shiftRequestSwap(String shiftId) => + ApiEndpoint('/staff/shifts/$shiftId/request-swap'); + + /// Update emergency contact by ID. + static ApiEndpoint emergencyContactUpdate(String contactId) => + ApiEndpoint('/staff/profile/emergency-contacts/$contactId'); + + /// Update tax form by type. + static ApiEndpoint taxFormUpdate(String formType) => + ApiEndpoint('/staff/profile/tax-forms/$formType'); + + /// Submit tax form by type. + static ApiEndpoint taxFormSubmit(String formType) => + ApiEndpoint('/staff/profile/tax-forms/$formType/submit'); + + /// Upload staff profile photo. + static const ApiEndpoint profilePhoto = + ApiEndpoint('/staff/profile/photo'); + + /// Upload document by ID. + static ApiEndpoint documentUpload(String documentId) => + ApiEndpoint('/staff/profile/documents/$documentId/upload'); + + /// Upload attire by ID. + static ApiEndpoint attireUpload(String documentId) => + ApiEndpoint('/staff/profile/attire/$documentId/upload'); + + /// Delete certificate by ID. + static ApiEndpoint certificateDelete(String certificateId) => + ApiEndpoint('/staff/profile/certificates/$certificateId'); +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart b/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart index 5cbe0d1c..2d094d7c 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; import 'package:firebase_auth/firebase_auth.dart'; +import 'package:krow_core/src/services/api_service/endpoints/auth_endpoints.dart'; /// An interceptor that adds the Firebase Auth ID token to the Authorization /// header and retries once on 401 with a force-refreshed token. @@ -9,10 +10,10 @@ import 'package:firebase_auth/firebase_auth.dart'; /// endpoints DO require the token. class AuthInterceptor extends Interceptor { /// Auth paths that must NOT receive a Bearer token (no session exists yet). - static const List _unauthenticatedPaths = [ - '/auth/client/sign-in', - '/auth/client/sign-up', - '/auth/staff/phone/start', + static final List _unauthenticatedPaths = [ + AuthEndpoints.clientSignIn.path, + AuthEndpoints.clientSignUp.path, + AuthEndpoints.staffPhoneStart.path, ]; /// Tracks whether a 401 retry is in progress to prevent infinite loops. From 57bba8ab4ebac5ffc051247c50a97079879509f4 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 11:40:15 -0400 Subject: [PATCH 09/25] Refactor API endpoint usage across multiple repositories to use ClientEndpoints and StaffEndpoints - Updated ClientOrderQueryRepositoryImpl to replace V2ApiEndpoints with ClientEndpoints for vendor, role, hub, and manager retrieval methods. - Modified ViewOrdersRepositoryImpl to utilize ClientEndpoints for order viewing, editing, and vendor retrieval. - Refactored ReportsRepositoryImpl to switch from V2ApiEndpoints to ClientEndpoints for various report fetching methods. - Changed SettingsRepositoryImpl to use AuthEndpoints for sign-out functionality. - Adjusted AuthRepositoryImpl to replace V2ApiEndpoints with AuthEndpoints for phone authentication and sign-out processes. - Updated ProfileSetupRepositoryImpl to utilize StaffEndpoints for profile setup. - Refactored AvailabilityRepositoryImpl to switch from V2ApiEndpoints to StaffEndpoints for availability management. - Changed ClockInRepositoryImpl to use StaffEndpoints for clock-in and clock-out functionalities. - Updated HomeRepositoryImpl to replace V2ApiEndpoints with StaffEndpoints for dashboard and profile completion retrieval. - Refactored PaymentsRepositoryImpl to utilize StaffEndpoints for payment summaries and history. - Changed ProfileRepositoryImpl to switch from V2ApiEndpoints to StaffEndpoints for staff profile and section status retrieval. - Updated CertificatesRepositoryImpl to use StaffEndpoints for certificate management. - Refactored DocumentsRepositoryImpl to switch from V2ApiEndpoints to StaffEndpoints for document management. - Changed TaxFormsRepositoryImpl to utilize StaffEndpoints for tax form management. - Updated BankAccountRepositoryImpl to switch from V2ApiEndpoints to StaffEndpoints for bank account management. - Refactored TimeCardRepositoryImpl to use StaffEndpoints for time card retrieval. - Changed AttireRepositoryImpl to utilize StaffEndpoints for attire management. - Updated EmergencyContactRepositoryImpl to switch from V2ApiEndpoints to StaffEndpoints for emergency contact management. - Refactored ExperienceRepositoryImpl to use StaffEndpoints for industry and skill retrieval. - Changed PersonalInfoRepositoryImpl to switch from V2ApiEndpoints to StaffEndpoints for personal information management. - Updated FaqsRepositoryImpl to utilize StaffEndpoints for FAQs retrieval. - Refactored PrivacySettingsRepositoryImpl to switch from V2ApiEndpoints to StaffEndpoints for privacy settings management. - Changed ShiftsRepositoryImpl to use StaffEndpoints for shift management and retrieval. - Updated StaffMainRepositoryImpl to switch from V2ApiEndpoints to StaffEndpoints for profile completion checks. --- apps/mobile/packages/core/lib/core.dart | 3 - .../core_api_services/core_api_endpoints.dart | 41 -- .../file_upload/file_upload_service.dart | 4 +- .../core_api_services/llm/llm_service.dart | 4 +- .../rapid_order/rapid_order_service.dart | 6 +- .../signed_url/signed_url_service.dart | 4 +- .../core_api_services/v2_api_endpoints.dart | 358 ------------------ .../verification/verification_service.dart | 10 +- .../services/session/v2_session_service.dart | 6 +- .../auth_repository_impl.dart | 6 +- .../billing_repository_impl.dart | 18 +- .../completion_review_worker_card.dart | 1 - .../coverage_repository_impl.dart | 10 +- .../home_repository_impl.dart | 4 +- .../hub_repository_impl.dart | 18 +- .../widgets/hub_address_autocomplete.dart | 2 - .../client_create_order_repository_impl.dart | 8 +- .../client_order_query_repository_impl.dart | 10 +- .../view_orders_repository_impl.dart | 14 +- .../reports_repository_impl.dart | 16 +- .../settings_repository_impl.dart | 2 +- .../client_settings_page/settings_logout.dart | 2 +- .../auth_repository_impl.dart | 6 +- .../profile_setup_repository_impl.dart | 2 +- .../availability_repository_impl.dart | 6 +- .../clock_in_repository_impl.dart | 10 +- .../clock_in_page_skeleton.dart | 8 +- .../geofence_override_modal.dart | 2 - .../repositories/home_repository_impl.dart | 4 +- .../payments_repository_impl.dart | 6 +- .../repositories/profile_repository_impl.dart | 6 +- .../certificates_repository_impl.dart | 6 +- .../documents_repository_impl.dart | 4 +- .../tax_forms_repository_impl.dart | 6 +- .../bank_account_repository_impl.dart | 4 +- .../time_card_repository_impl.dart | 2 +- .../attire_repository_impl.dart | 4 +- .../emergency_contact_repository_impl.dart | 4 +- .../experience_repository_impl.dart | 6 +- .../personal_info_repository_impl.dart | 6 +- .../faqs_repository_impl.dart | 4 +- .../privacy_settings_repository_impl.dart | 4 +- .../shifts_repository_impl.dart | 24 +- .../shifts_repository_interface.dart | 2 +- .../widgets/tabs/find_shifts_tab.dart | 3 - .../staff_main_repository_impl.dart | 2 +- 46 files changed, 134 insertions(+), 544 deletions(-) delete mode 100644 apps/mobile/packages/core/lib/src/services/api_service/core_api_services/core_api_endpoints.dart delete mode 100644 apps/mobile/packages/core/lib/src/services/api_service/core_api_services/v2_api_endpoints.dart diff --git a/apps/mobile/packages/core/lib/core.dart b/apps/mobile/packages/core/lib/core.dart index 61f255d1..129afe0a 100644 --- a/apps/mobile/packages/core/lib/core.dart +++ b/apps/mobile/packages/core/lib/core.dart @@ -26,9 +26,6 @@ export 'src/services/api_service/endpoints/auth_endpoints.dart'; export 'src/services/api_service/endpoints/client_endpoints.dart'; export 'src/services/api_service/endpoints/core_endpoints.dart'; export 'src/services/api_service/endpoints/staff_endpoints.dart'; -// Backward-compatible facades (deprecated) -export 'src/services/api_service/core_api_services/core_api_endpoints.dart'; -export 'src/services/api_service/core_api_services/v2_api_endpoints.dart'; export 'src/services/api_service/core_api_services/file_upload/file_upload_service.dart'; export 'src/services/api_service/core_api_services/file_upload/file_upload_response.dart'; export 'src/services/api_service/core_api_services/signed_url/signed_url_service.dart'; diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/core_api_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/core_api_endpoints.dart deleted file mode 100644 index a5b53e05..00000000 --- a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/core_api_endpoints.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:krow_core/src/services/api_service/endpoints/core_endpoints.dart'; - -/// Backward-compatible facade that re-exports all Core API endpoints as -/// [String] paths. -/// -/// New code should use [CoreEndpoints] directly. -@Deprecated('Use CoreEndpoints directly') -class CoreApiEndpoints { - CoreApiEndpoints._(); - - /// Upload a file. - static String get uploadFile => CoreEndpoints.uploadFile.path; - - /// Create a signed URL for a file. - static String get createSignedUrl => CoreEndpoints.createSignedUrl.path; - - /// Invoke a Large Language Model. - static String get invokeLlm => CoreEndpoints.invokeLlm.path; - - /// Root for verification operations. - static String get verifications => CoreEndpoints.verifications.path; - - /// Get status of a verification job. - static String verificationStatus(String id) => - CoreEndpoints.verificationStatus(id).path; - - /// Review a verification decision. - static String verificationReview(String id) => - CoreEndpoints.verificationReview(id).path; - - /// Retry a verification job. - static String verificationRetry(String id) => - CoreEndpoints.verificationRetry(id).path; - - /// Transcribe audio to text for rapid orders. - static String get transcribeRapidOrder => - CoreEndpoints.transcribeRapidOrder.path; - - /// Parse text to structured rapid order. - static String get parseRapidOrder => CoreEndpoints.parseRapidOrder.path; -} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_service.dart index 09dc2854..84ffd05f 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_service.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_service.dart @@ -1,6 +1,6 @@ import 'package:dio/dio.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../core_api_endpoints.dart'; +import '../../endpoints/core_endpoints.dart'; import 'file_upload_response.dart'; /// Service for uploading files to the Core API. @@ -26,7 +26,7 @@ class FileUploadService extends BaseCoreService { if (category != null) 'category': category, }); - return api.post(CoreApiEndpoints.uploadFile, data: formData); + return api.post(CoreEndpoints.uploadFile.path, data: formData); }); if (res.code.startsWith('2')) { diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_service.dart index 5bf6208d..db5ab3a7 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_service.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_service.dart @@ -1,5 +1,5 @@ import 'package:krow_domain/krow_domain.dart'; -import '../core_api_endpoints.dart'; +import '../../endpoints/core_endpoints.dart'; import 'llm_response.dart'; /// Service for invoking Large Language Models (LLM). @@ -19,7 +19,7 @@ class LlmService extends BaseCoreService { }) async { final ApiResponse res = await action(() async { return api.post( - CoreApiEndpoints.invokeLlm, + CoreEndpoints.invokeLlm.path, data: { 'prompt': prompt, if (responseJsonSchema != null) diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/rapid_order/rapid_order_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/rapid_order/rapid_order_service.dart index 5715d9ec..92e8d249 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/rapid_order/rapid_order_service.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/rapid_order/rapid_order_service.dart @@ -1,5 +1,5 @@ import 'package:krow_domain/krow_domain.dart'; -import '../core_api_endpoints.dart'; +import '../../endpoints/core_endpoints.dart'; import 'rapid_order_response.dart'; /// Service for handling RAPID order operations (Transcription and Parsing). @@ -19,7 +19,7 @@ class RapidOrderService extends BaseCoreService { }) async { final ApiResponse res = await action(() async { return api.post( - CoreApiEndpoints.transcribeRapidOrder, + CoreEndpoints.transcribeRapidOrder.path, data: { 'audioFileUri': audioFileUri, 'locale': locale, @@ -51,7 +51,7 @@ class RapidOrderService extends BaseCoreService { }) async { final ApiResponse res = await action(() async { return api.post( - CoreApiEndpoints.parseRapidOrder, + CoreEndpoints.parseRapidOrder.path, data: { 'text': text, 'locale': locale, diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_service.dart index f25fea52..24af5336 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_service.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_service.dart @@ -1,5 +1,5 @@ import 'package:krow_domain/krow_domain.dart'; -import '../core_api_endpoints.dart'; +import '../../endpoints/core_endpoints.dart'; import 'signed_url_response.dart'; /// Service for creating signed URLs for Cloud Storage objects. @@ -17,7 +17,7 @@ class SignedUrlService extends BaseCoreService { }) async { final ApiResponse res = await action(() async { return api.post( - CoreApiEndpoints.createSignedUrl, + CoreEndpoints.createSignedUrl.path, data: { 'fileUri': fileUri, 'expiresInSeconds': expiresInSeconds, diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/v2_api_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/v2_api_endpoints.dart deleted file mode 100644 index 930ea8aa..00000000 --- a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/v2_api_endpoints.dart +++ /dev/null @@ -1,358 +0,0 @@ -import 'package:krow_core/src/services/api_service/endpoints/auth_endpoints.dart'; -import 'package:krow_core/src/services/api_service/endpoints/client_endpoints.dart'; -import 'package:krow_core/src/services/api_service/endpoints/staff_endpoints.dart'; - -/// Backward-compatible facade that re-exports all V2 endpoints as [String] -/// paths. -/// -/// New code should use [AuthEndpoints], [StaffEndpoints], or -/// [ClientEndpoints] directly. -@Deprecated( - 'Use AuthEndpoints, StaffEndpoints, or ClientEndpoints directly', -) -class V2ApiEndpoints { - V2ApiEndpoints._(); - - // ── Auth ────────────────────────────────────────────────────────────── - - /// Client email/password sign-in. - static String get clientSignIn => AuthEndpoints.clientSignIn.path; - - /// Client business registration. - static String get clientSignUp => AuthEndpoints.clientSignUp.path; - - /// Client sign-out. - static String get clientSignOut => AuthEndpoints.clientSignOut.path; - - /// Start staff phone verification (SMS). - static String get staffPhoneStart => AuthEndpoints.staffPhoneStart.path; - - /// Complete staff phone verification. - static String get staffPhoneVerify => AuthEndpoints.staffPhoneVerify.path; - - /// Generic sign-out. - static String get signOut => AuthEndpoints.signOut.path; - - /// Staff-specific sign-out. - static String get staffSignOut => AuthEndpoints.staffSignOut.path; - - /// Get current session data. - static String get session => AuthEndpoints.session.path; - - // ── Staff Read ──────────────────────────────────────────────────────── - - /// Staff session data. - static String get staffSession => StaffEndpoints.session.path; - - /// Staff dashboard overview. - static String get staffDashboard => StaffEndpoints.dashboard.path; - - /// Staff profile completion status. - static String get staffProfileCompletion => - StaffEndpoints.profileCompletion.path; - - /// Staff availability schedule. - static String get staffAvailability => StaffEndpoints.availability.path; - - /// Today's shifts for clock-in. - static String get staffClockInShiftsToday => - StaffEndpoints.clockInShiftsToday.path; - - /// Current clock-in status. - static String get staffClockInStatus => StaffEndpoints.clockInStatus.path; - - /// Payments summary. - static String get staffPaymentsSummary => - StaffEndpoints.paymentsSummary.path; - - /// Payments history. - static String get staffPaymentsHistory => - StaffEndpoints.paymentsHistory.path; - - /// Payments chart data. - static String get staffPaymentsChart => StaffEndpoints.paymentsChart.path; - - /// Assigned shifts. - static String get staffShiftsAssigned => - StaffEndpoints.shiftsAssigned.path; - - /// Open shifts available to apply. - static String get staffShiftsOpen => StaffEndpoints.shiftsOpen.path; - - /// Pending shift assignments. - static String get staffShiftsPending => StaffEndpoints.shiftsPending.path; - - /// Cancelled shifts. - static String get staffShiftsCancelled => - StaffEndpoints.shiftsCancelled.path; - - /// Completed shifts. - static String get staffShiftsCompleted => - StaffEndpoints.shiftsCompleted.path; - - /// Shift details by ID. - static String staffShiftDetails(String shiftId) => - StaffEndpoints.shiftDetails(shiftId).path; - - /// Staff profile sections overview. - static String get staffProfileSections => - StaffEndpoints.profileSections.path; - - /// Personal info. - static String get staffPersonalInfo => StaffEndpoints.personalInfo.path; - - /// Industries/experience. - static String get staffIndustries => StaffEndpoints.industries.path; - - /// Skills. - static String get staffSkills => StaffEndpoints.skills.path; - - /// Documents. - static String get staffDocuments => StaffEndpoints.documents.path; - - /// Attire items. - static String get staffAttire => StaffEndpoints.attire.path; - - /// Tax forms. - static String get staffTaxForms => StaffEndpoints.taxForms.path; - - /// Emergency contacts. - static String get staffEmergencyContacts => - StaffEndpoints.emergencyContacts.path; - - /// Certificates. - static String get staffCertificates => StaffEndpoints.certificates.path; - - /// Bank accounts. - static String get staffBankAccounts => StaffEndpoints.bankAccounts.path; - - /// Benefits. - static String get staffBenefits => StaffEndpoints.benefits.path; - - /// Time card. - static String get staffTimeCard => StaffEndpoints.timeCard.path; - - /// Privacy settings. - static String get staffPrivacy => StaffEndpoints.privacy.path; - - /// FAQs. - static String get staffFaqs => StaffEndpoints.faqs.path; - - /// FAQs search. - static String get staffFaqsSearch => StaffEndpoints.faqsSearch.path; - - // ── Staff Write ─────────────────────────────────────────────────────── - - /// Staff profile setup. - static String get staffProfileSetup => StaffEndpoints.profileSetup.path; - - /// Clock in. - static String get staffClockIn => StaffEndpoints.clockIn.path; - - /// Clock out. - static String get staffClockOut => StaffEndpoints.clockOut.path; - - /// Quick-set availability. - static String get staffAvailabilityQuickSet => - StaffEndpoints.availabilityQuickSet.path; - - /// Apply for a shift. - static String staffShiftApply(String shiftId) => - StaffEndpoints.shiftApply(shiftId).path; - - /// Accept a shift. - static String staffShiftAccept(String shiftId) => - StaffEndpoints.shiftAccept(shiftId).path; - - /// Decline a shift. - static String staffShiftDecline(String shiftId) => - StaffEndpoints.shiftDecline(shiftId).path; - - /// Request a shift swap. - static String staffShiftRequestSwap(String shiftId) => - StaffEndpoints.shiftRequestSwap(shiftId).path; - - /// Update emergency contact by ID. - static String staffEmergencyContactUpdate(String contactId) => - StaffEndpoints.emergencyContactUpdate(contactId).path; - - /// Update tax form by type. - static String staffTaxFormUpdate(String formType) => - StaffEndpoints.taxFormUpdate(formType).path; - - /// Submit tax form by type. - static String staffTaxFormSubmit(String formType) => - StaffEndpoints.taxFormSubmit(formType).path; - - /// Upload staff profile photo. - static String get staffProfilePhoto => StaffEndpoints.profilePhoto.path; - - /// Upload document by ID. - static String staffDocumentUpload(String documentId) => - StaffEndpoints.documentUpload(documentId).path; - - /// Upload attire by ID. - static String staffAttireUpload(String documentId) => - StaffEndpoints.attireUpload(documentId).path; - - /// Delete certificate by ID. - static String staffCertificateDelete(String certificateId) => - StaffEndpoints.certificateDelete(certificateId).path; - - // ── Client Read ─────────────────────────────────────────────────────── - - /// Client session data. - static String get clientSession => ClientEndpoints.session.path; - - /// Client dashboard. - static String get clientDashboard => ClientEndpoints.dashboard.path; - - /// Client reorders. - static String get clientReorders => ClientEndpoints.reorders.path; - - /// Billing accounts. - static String get clientBillingAccounts => - ClientEndpoints.billingAccounts.path; - - /// Pending invoices. - static String get clientBillingInvoicesPending => - ClientEndpoints.billingInvoicesPending.path; - - /// Invoice history. - static String get clientBillingInvoicesHistory => - ClientEndpoints.billingInvoicesHistory.path; - - /// Current bill. - static String get clientBillingCurrentBill => - ClientEndpoints.billingCurrentBill.path; - - /// Savings data. - static String get clientBillingSavings => - ClientEndpoints.billingSavings.path; - - /// Spend breakdown. - static String get clientBillingSpendBreakdown => - ClientEndpoints.billingSpendBreakdown.path; - - /// Coverage overview. - static String get clientCoverage => ClientEndpoints.coverage.path; - - /// Coverage stats. - static String get clientCoverageStats => - ClientEndpoints.coverageStats.path; - - /// Core team. - static String get clientCoverageCoreTeam => - ClientEndpoints.coverageCoreTeam.path; - - /// Hubs list. - static String get clientHubs => ClientEndpoints.hubs.path; - - /// Cost centers. - static String get clientCostCenters => ClientEndpoints.costCenters.path; - - /// Vendors. - static String get clientVendors => ClientEndpoints.vendors.path; - - /// Vendor roles by ID. - static String clientVendorRoles(String vendorId) => - ClientEndpoints.vendorRoles(vendorId).path; - - /// Hub managers by ID. - static String clientHubManagers(String hubId) => - ClientEndpoints.hubManagers(hubId).path; - - /// Team members. - static String get clientTeamMembers => ClientEndpoints.teamMembers.path; - - /// View orders. - static String get clientOrdersView => ClientEndpoints.ordersView.path; - - /// Order reorder preview. - static String clientOrderReorderPreview(String orderId) => - ClientEndpoints.orderReorderPreview(orderId).path; - - /// Reports summary. - static String get clientReportsSummary => - ClientEndpoints.reportsSummary.path; - - /// Daily ops report. - static String get clientReportsDailyOps => - ClientEndpoints.reportsDailyOps.path; - - /// Spend report. - static String get clientReportsSpend => ClientEndpoints.reportsSpend.path; - - /// Coverage report. - static String get clientReportsCoverage => - ClientEndpoints.reportsCoverage.path; - - /// Forecast report. - static String get clientReportsForecast => - ClientEndpoints.reportsForecast.path; - - /// Performance report. - static String get clientReportsPerformance => - ClientEndpoints.reportsPerformance.path; - - /// No-show report. - static String get clientReportsNoShow => - ClientEndpoints.reportsNoShow.path; - - // ── Client Write ────────────────────────────────────────────────────── - - /// Create one-time order. - static String get clientOrdersOneTime => - ClientEndpoints.ordersOneTime.path; - - /// Create recurring order. - static String get clientOrdersRecurring => - ClientEndpoints.ordersRecurring.path; - - /// Create permanent order. - static String get clientOrdersPermanent => - ClientEndpoints.ordersPermanent.path; - - /// Edit order by ID. - static String clientOrderEdit(String orderId) => - ClientEndpoints.orderEdit(orderId).path; - - /// Cancel order by ID. - static String clientOrderCancel(String orderId) => - ClientEndpoints.orderCancel(orderId).path; - - /// Create hub. - static String get clientHubCreate => ClientEndpoints.hubCreate.path; - - /// Update hub by ID. - static String clientHubUpdate(String hubId) => - ClientEndpoints.hubUpdate(hubId).path; - - /// Delete hub by ID. - static String clientHubDelete(String hubId) => - ClientEndpoints.hubDelete(hubId).path; - - /// Assign NFC to hub. - static String clientHubAssignNfc(String hubId) => - ClientEndpoints.hubAssignNfc(hubId).path; - - /// Assign managers to hub. - static String clientHubAssignManagers(String hubId) => - ClientEndpoints.hubAssignManagers(hubId).path; - - /// Approve invoice. - static String clientInvoiceApprove(String invoiceId) => - ClientEndpoints.invoiceApprove(invoiceId).path; - - /// Dispute invoice. - static String clientInvoiceDispute(String invoiceId) => - ClientEndpoints.invoiceDispute(invoiceId).path; - - /// Submit coverage review. - static String get clientCoverageReviews => - ClientEndpoints.coverageReviews.path; - - /// Cancel late worker assignment. - static String clientCoverageCancelLateWorker(String assignmentId) => - ClientEndpoints.coverageCancelLateWorker(assignmentId).path; -} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart index 3dd72b79..db35f5dc 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart @@ -1,5 +1,5 @@ import 'package:krow_domain/krow_domain.dart'; -import '../core_api_endpoints.dart'; +import '../../endpoints/core_endpoints.dart'; import 'verification_response.dart'; /// Service for handling async verification jobs. @@ -22,7 +22,7 @@ class VerificationService extends BaseCoreService { }) async { final ApiResponse res = await action(() async { return api.post( - CoreApiEndpoints.verifications, + CoreEndpoints.verifications.path, data: { 'type': type, 'subjectType': subjectType, @@ -44,7 +44,7 @@ class VerificationService extends BaseCoreService { /// Polls the status of a specific verification. Future getStatus(String verificationId) async { final ApiResponse res = await action(() async { - return api.get(CoreApiEndpoints.verificationStatus(verificationId)); + return api.get(CoreEndpoints.verificationStatus(verificationId).path); }); if (res.code.startsWith('2')) { @@ -65,7 +65,7 @@ class VerificationService extends BaseCoreService { }) async { final ApiResponse res = await action(() async { return api.post( - CoreApiEndpoints.verificationReview(verificationId), + CoreEndpoints.verificationReview(verificationId).path, data: { 'decision': decision, if (note != null) 'note': note, @@ -84,7 +84,7 @@ class VerificationService extends BaseCoreService { /// Retries a verification job that failed or needs re-processing. Future retryVerification(String verificationId) async { final ApiResponse res = await action(() async { - return api.post(CoreApiEndpoints.verificationRetry(verificationId)); + return api.post(CoreEndpoints.verificationRetry(verificationId).path); }); if (res.code.startsWith('2')) { 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 index 0e55469a..28881cf8 100644 --- 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 @@ -3,7 +3,7 @@ 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/endpoints/auth_endpoints.dart'; import '../api_service/mixins/session_handler_mixin.dart'; import 'client_session_store.dart'; import 'staff_session_store.dart'; @@ -51,7 +51,7 @@ class V2SessionService with SessionHandlerMixin { return null; } - final ApiResponse response = await api.get(V2ApiEndpoints.session); + final ApiResponse response = await api.get(AuthEndpoints.session.path); if (response.data is Map) { final Map data = @@ -99,7 +99,7 @@ class V2SessionService with SessionHandlerMixin { final BaseApiService? api = _apiService; if (api != null) { try { - await api.post(V2ApiEndpoints.signOut); + await api.post(AuthEndpoints.signOut.path); } catch (e) { debugPrint('[V2SessionService] Server sign-out failed: $e'); } 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 4f84d295..b1f85552 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 @@ -45,7 +45,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // 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, + AuthEndpoints.clientSignIn.path, data: { 'email': email, 'password': password, @@ -107,7 +107,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // - Creates user, tenant, business, memberships in one transaction // - Returns full auth envelope with session tokens final ApiResponse response = await _apiService.post( - V2ApiEndpoints.clientSignUp, + AuthEndpoints.clientSignUp.path, data: { 'companyName': companyName, 'email': email, @@ -172,7 +172,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { Future signOut() async { try { // Step 1: Call V2 sign-out endpoint for server-side token revocation. - await _apiService.post(V2ApiEndpoints.clientSignOut); + await _apiService.post(AuthEndpoints.clientSignOut.path); } catch (e) { developer.log( 'V2 sign-out request failed: $e', 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 c8e8eea3..ac5b928b 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 @@ -5,7 +5,7 @@ import 'package:billing/src/domain/repositories/billing_repository.dart'; /// Implementation of [BillingRepository] using the V2 REST API. /// -/// All backend calls go through [BaseApiService] with [V2ApiEndpoints]. +/// All backend calls go through [BaseApiService] with [ClientEndpoints]. class BillingRepositoryImpl implements BillingRepository { /// Creates a [BillingRepositoryImpl]. BillingRepositoryImpl({required BaseApiService apiService}) @@ -17,7 +17,7 @@ class BillingRepositoryImpl implements BillingRepository { @override Future> getBankAccounts() async { final ApiResponse response = - await _apiService.get(V2ApiEndpoints.clientBillingAccounts); + await _apiService.get(ClientEndpoints.billingAccounts.path); final List items = (response.data as Map)['items'] as List; return items @@ -29,7 +29,7 @@ class BillingRepositoryImpl implements BillingRepository { @override Future> getPendingInvoices() async { final ApiResponse response = - await _apiService.get(V2ApiEndpoints.clientBillingInvoicesPending); + await _apiService.get(ClientEndpoints.billingInvoicesPending.path); final List items = (response.data as Map)['items'] as List; return items @@ -41,7 +41,7 @@ class BillingRepositoryImpl implements BillingRepository { @override Future> getInvoiceHistory() async { final ApiResponse response = - await _apiService.get(V2ApiEndpoints.clientBillingInvoicesHistory); + await _apiService.get(ClientEndpoints.billingInvoicesHistory.path); final List items = (response.data as Map)['items'] as List; return items @@ -53,7 +53,7 @@ class BillingRepositoryImpl implements BillingRepository { @override Future getCurrentBillCents() async { final ApiResponse response = - await _apiService.get(V2ApiEndpoints.clientBillingCurrentBill); + await _apiService.get(ClientEndpoints.billingCurrentBill.path); final Map data = response.data as Map; return (data['currentBillCents'] as num).toInt(); @@ -62,7 +62,7 @@ class BillingRepositoryImpl implements BillingRepository { @override Future getSavingsCents() async { final ApiResponse response = - await _apiService.get(V2ApiEndpoints.clientBillingSavings); + await _apiService.get(ClientEndpoints.billingSavings.path); final Map data = response.data as Map; return (data['savingsCents'] as num).toInt(); @@ -74,7 +74,7 @@ class BillingRepositoryImpl implements BillingRepository { required String endDate, }) async { final ApiResponse response = await _apiService.get( - V2ApiEndpoints.clientBillingSpendBreakdown, + ClientEndpoints.billingSpendBreakdown.path, params: { 'startDate': startDate, 'endDate': endDate, @@ -90,13 +90,13 @@ class BillingRepositoryImpl implements BillingRepository { @override Future approveInvoice(String id) async { - await _apiService.post(V2ApiEndpoints.clientInvoiceApprove(id)); + await _apiService.post(ClientEndpoints.invoiceApprove(id).path); } @override Future disputeInvoice(String id, String reason) async { await _apiService.post( - V2ApiEndpoints.clientInvoiceDispute(id), + ClientEndpoints.invoiceDispute(id).path, data: {'reason': reason}, ); } 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 8c146aea..39204a24 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,4 +1,3 @@ -import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; /// Card showing a single worker's details in the completion review. 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 c6fa62fd..c331e9c4 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 @@ -5,7 +5,7 @@ import 'package:client_coverage/src/domain/repositories/coverage_repository.dart /// V2 API implementation of [CoverageRepository]. /// -/// Uses [BaseApiService] with [V2ApiEndpoints] for all backend access. +/// Uses [BaseApiService] with [ClientEndpoints] for all backend access. class CoverageRepositoryImpl implements CoverageRepository { /// Creates a [CoverageRepositoryImpl]. CoverageRepositoryImpl({required BaseApiService apiService}) @@ -20,7 +20,7 @@ class CoverageRepositoryImpl implements CoverageRepository { 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, + ClientEndpoints.coverage.path, params: {'date': dateStr}, ); final List items = response.data['items'] as List; @@ -35,7 +35,7 @@ class CoverageRepositoryImpl implements CoverageRepository { 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, + ClientEndpoints.coverageStats.path, params: {'date': dateStr}, ); return CoverageStats.fromJson(response.data as Map); @@ -67,7 +67,7 @@ class CoverageRepositoryImpl implements CoverageRepository { body['markAsFavorite'] = markAsFavorite; } await _apiService.post( - V2ApiEndpoints.clientCoverageReviews, + ClientEndpoints.coverageReviews.path, data: body, ); } @@ -82,7 +82,7 @@ class CoverageRepositoryImpl implements CoverageRepository { body['reason'] = reason; } await _apiService.post( - V2ApiEndpoints.clientCoverageCancelLateWorker(assignmentId), + ClientEndpoints.coverageCancelLateWorker(assignmentId).path, data: body, ); } 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 dfaf734e..e6b9d1ed 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 @@ -18,7 +18,7 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { @override Future getDashboard() async { final ApiResponse response = - await _apiService.get(V2ApiEndpoints.clientDashboard); + await _apiService.get(ClientEndpoints.dashboard.path); final Map data = response.data as Map; return ClientDashboard.fromJson(data); } @@ -26,7 +26,7 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { @override Future> getRecentReorders() async { final ApiResponse response = - await _apiService.get(V2ApiEndpoints.clientReorders); + await _apiService.get(ClientEndpoints.reorders.path); final Map body = response.data as Map; final List items = body['items'] as List; return items 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 8ab96984..ecd06f76 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 @@ -5,7 +5,7 @@ import 'package:client_hubs/src/domain/repositories/hub_repository_interface.dar /// Implementation of [HubRepositoryInterface] using the V2 REST API. /// -/// All backend calls go through [BaseApiService] with [V2ApiEndpoints]. +/// All backend calls go through [BaseApiService] with [ClientEndpoints]. class HubRepositoryImpl implements HubRepositoryInterface { /// Creates a [HubRepositoryImpl]. HubRepositoryImpl({required BaseApiService apiService}) @@ -17,7 +17,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { @override Future> getHubs() async { final ApiResponse response = - await _apiService.get(V2ApiEndpoints.clientHubs); + await _apiService.get(ClientEndpoints.hubs.path); final List items = (response.data as Map)['items'] as List; return items @@ -28,7 +28,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { @override Future> getCostCenters() async { final ApiResponse response = - await _apiService.get(V2ApiEndpoints.clientCostCenters); + await _apiService.get(ClientEndpoints.costCenters.path); final List items = (response.data as Map)['items'] as List; return items @@ -52,7 +52,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { String? costCenterId, }) async { final ApiResponse response = await _apiService.post( - V2ApiEndpoints.clientHubCreate, + ClientEndpoints.hubCreate.path, data: { 'name': name, 'fullAddress': fullAddress, @@ -88,7 +88,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { String? costCenterId, }) async { final ApiResponse response = await _apiService.put( - V2ApiEndpoints.clientHubUpdate(hubId), + ClientEndpoints.hubUpdate(hubId).path, data: { 'hubId': hubId, if (name != null) 'name': name, @@ -111,7 +111,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { @override Future deleteHub(String hubId) async { - await _apiService.delete(V2ApiEndpoints.clientHubDelete(hubId)); + await _apiService.delete(ClientEndpoints.hubDelete(hubId).path); } @override @@ -120,7 +120,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { required String nfcTagId, }) async { await _apiService.post( - V2ApiEndpoints.clientHubAssignNfc(hubId), + ClientEndpoints.hubAssignNfc(hubId).path, data: {'nfcTagId': nfcTagId}, ); } @@ -128,7 +128,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { @override Future> getManagers(String hubId) async { final ApiResponse response = - await _apiService.get(V2ApiEndpoints.clientHubManagers(hubId)); + await _apiService.get(ClientEndpoints.hubManagers(hubId).path); final List items = (response.data as Map)['items'] as List; return items @@ -143,7 +143,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { required List businessMembershipIds, }) async { await _apiService.post( - V2ApiEndpoints.clientHubAssignManagers(hubId), + ClientEndpoints.hubAssignManagers(hubId).path, data: { 'businessMembershipIds': businessMembershipIds, }, 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 7bad9647..cb129673 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,8 +4,6 @@ import 'package:google_places_flutter/google_places_flutter.dart'; import 'package:google_places_flutter/model/prediction.dart'; import 'package:krow_core/core.dart'; -import 'package:client_hubs/src/util/hubs_constants.dart'; - class HubAddressAutocomplete extends StatelessWidget { const HubAddressAutocomplete({ required this.controller, 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 040cf7d7..6efc9179 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 @@ -24,17 +24,17 @@ class ClientCreateOrderRepositoryImpl @override Future createOneTimeOrder(Map payload) async { - await _api.post(V2ApiEndpoints.clientOrdersOneTime, data: payload); + await _api.post(ClientEndpoints.ordersOneTime.path, data: payload); } @override Future createRecurringOrder(Map payload) async { - await _api.post(V2ApiEndpoints.clientOrdersRecurring, data: payload); + await _api.post(ClientEndpoints.ordersRecurring.path, data: payload); } @override Future createPermanentOrder(Map payload) async { - await _api.post(V2ApiEndpoints.clientOrdersPermanent, data: payload); + await _api.post(ClientEndpoints.ordersPermanent.path, data: payload); } @override @@ -82,7 +82,7 @@ class ClientCreateOrderRepositoryImpl @override Future getOrderDetailsForReorder(String orderId) async { final ApiResponse response = await _api.get( - V2ApiEndpoints.clientOrderReorderPreview(orderId), + ClientEndpoints.orderReorderPreview(orderId).path, ); 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 311e3a62..b1d62cb8 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 @@ -8,7 +8,7 @@ import '../../domain/repositories/client_order_query_repository_interface.dart'; /// V2 API implementation of [ClientOrderQueryRepositoryInterface]. /// -/// Delegates all backend calls to [BaseApiService] with [V2ApiEndpoints]. +/// Delegates all backend calls to [BaseApiService] with [ClientEndpoints]. class ClientOrderQueryRepositoryImpl implements ClientOrderQueryRepositoryInterface { /// Creates an instance backed by the given [apiService]. @@ -19,7 +19,7 @@ class ClientOrderQueryRepositoryImpl @override Future> getVendors() async { - final ApiResponse response = await _api.get(V2ApiEndpoints.clientVendors); + final ApiResponse response = await _api.get(ClientEndpoints.vendors.path); final Map data = response.data as Map; final List items = data['items'] as List; return items @@ -30,7 +30,7 @@ class ClientOrderQueryRepositoryImpl @override Future> getRolesByVendor(String vendorId) async { final ApiResponse response = - await _api.get(V2ApiEndpoints.clientVendorRoles(vendorId)); + await _api.get(ClientEndpoints.vendorRoles(vendorId).path); final Map data = response.data as Map; final List items = data['items'] as List; return items.map((dynamic json) { @@ -46,7 +46,7 @@ class ClientOrderQueryRepositoryImpl @override Future> getHubs() async { - final ApiResponse response = await _api.get(V2ApiEndpoints.clientHubs); + final ApiResponse response = await _api.get(ClientEndpoints.hubs.path); final Map data = response.data as Map; final List items = data['items'] as List; return items.map((dynamic json) { @@ -71,7 +71,7 @@ class ClientOrderQueryRepositoryImpl @override Future> getManagersByHub(String hubId) async { final ApiResponse response = - await _api.get(V2ApiEndpoints.clientHubManagers(hubId)); + await _api.get(ClientEndpoints.hubManagers(hubId).path); final Map data = response.data as Map; final List items = data['items'] as List; return items.map((dynamic json) { 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 1173cd20..a12fce25 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 @@ -20,7 +20,7 @@ class ViewOrdersRepositoryImpl implements IViewOrdersRepository { required DateTime end, }) async { final ApiResponse response = await _api.get( - V2ApiEndpoints.clientOrdersView, + ClientEndpoints.ordersView.path, params: { 'startDate': start.toIso8601String(), 'endDate': end.toIso8601String(), @@ -40,7 +40,7 @@ class ViewOrdersRepositoryImpl implements IViewOrdersRepository { required Map payload, }) async { final ApiResponse response = await _api.post( - V2ApiEndpoints.clientOrderEdit(orderId), + ClientEndpoints.orderEdit(orderId).path, data: payload, ); final Map data = response.data as Map; @@ -53,7 +53,7 @@ class ViewOrdersRepositoryImpl implements IViewOrdersRepository { String? reason, }) async { await _api.post( - V2ApiEndpoints.clientOrderCancel(orderId), + ClientEndpoints.orderCancel(orderId).path, data: { if (reason != null) 'reason': reason, }, @@ -62,7 +62,7 @@ class ViewOrdersRepositoryImpl implements IViewOrdersRepository { @override Future> getVendors() async { - final ApiResponse response = await _api.get(V2ApiEndpoints.clientVendors); + final ApiResponse response = await _api.get(ClientEndpoints.vendors.path); final Map data = response.data as Map; final List items = data['items'] as List; return items @@ -73,7 +73,7 @@ class ViewOrdersRepositoryImpl implements IViewOrdersRepository { @override Future>> getRolesByVendor(String vendorId) async { final ApiResponse response = - await _api.get(V2ApiEndpoints.clientVendorRoles(vendorId)); + await _api.get(ClientEndpoints.vendorRoles(vendorId).path); final Map data = response.data as Map; final List items = data['items'] as List; return items.cast>(); @@ -81,7 +81,7 @@ class ViewOrdersRepositoryImpl implements IViewOrdersRepository { @override Future>> getHubs() async { - final ApiResponse response = await _api.get(V2ApiEndpoints.clientHubs); + final ApiResponse response = await _api.get(ClientEndpoints.hubs.path); final Map data = response.data as Map; final List items = data['items'] as List; return items.cast>(); @@ -90,7 +90,7 @@ class ViewOrdersRepositoryImpl implements IViewOrdersRepository { @override Future>> getManagersByHub(String hubId) async { final ApiResponse response = - await _api.get(V2ApiEndpoints.clientHubManagers(hubId)); + await _api.get(ClientEndpoints.hubManagers(hubId).path); 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/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 13cf5a2d..f067d464 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 @@ -5,7 +5,7 @@ import 'package:client_reports/src/domain/repositories/reports_repository.dart'; /// V2 API implementation of [ReportsRepository]. /// -/// Each method hits its corresponding `V2ApiEndpoints.clientReports*` endpoint, +/// Each method hits its corresponding `ClientEndpoints.reports*` endpoint, /// passing date-range query parameters, and deserialises the JSON response /// into the relevant domain entity. class ReportsRepositoryImpl implements ReportsRepository { @@ -32,7 +32,7 @@ class ReportsRepositoryImpl implements ReportsRepository { required DateTime date, }) async { final ApiResponse response = await _apiService.get( - V2ApiEndpoints.clientReportsDailyOps, + ClientEndpoints.reportsDailyOps.path, params: {'date': _iso(date)}, ); final Map data = response.data as Map; @@ -45,7 +45,7 @@ class ReportsRepositoryImpl implements ReportsRepository { required DateTime endDate, }) async { final ApiResponse response = await _apiService.get( - V2ApiEndpoints.clientReportsSpend, + ClientEndpoints.reportsSpend.path, params: _rangeParams(startDate, endDate), ); final Map data = response.data as Map; @@ -58,7 +58,7 @@ class ReportsRepositoryImpl implements ReportsRepository { required DateTime endDate, }) async { final ApiResponse response = await _apiService.get( - V2ApiEndpoints.clientReportsCoverage, + ClientEndpoints.reportsCoverage.path, params: _rangeParams(startDate, endDate), ); final Map data = response.data as Map; @@ -71,7 +71,7 @@ class ReportsRepositoryImpl implements ReportsRepository { required DateTime endDate, }) async { final ApiResponse response = await _apiService.get( - V2ApiEndpoints.clientReportsForecast, + ClientEndpoints.reportsForecast.path, params: _rangeParams(startDate, endDate), ); final Map data = response.data as Map; @@ -84,7 +84,7 @@ class ReportsRepositoryImpl implements ReportsRepository { required DateTime endDate, }) async { final ApiResponse response = await _apiService.get( - V2ApiEndpoints.clientReportsPerformance, + ClientEndpoints.reportsPerformance.path, params: _rangeParams(startDate, endDate), ); final Map data = response.data as Map; @@ -97,7 +97,7 @@ class ReportsRepositoryImpl implements ReportsRepository { required DateTime endDate, }) async { final ApiResponse response = await _apiService.get( - V2ApiEndpoints.clientReportsNoShow, + ClientEndpoints.reportsNoShow.path, params: _rangeParams(startDate, endDate), ); final Map data = response.data as Map; @@ -110,7 +110,7 @@ class ReportsRepositoryImpl implements ReportsRepository { required DateTime endDate, }) async { final ApiResponse response = await _apiService.get( - V2ApiEndpoints.clientReportsSummary, + ClientEndpoints.reportsSummary.path, params: _rangeParams(startDate, endDate), ); final Map data = response.data as Map; 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 e620bf94..ce114987 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 @@ -22,7 +22,7 @@ class SettingsRepositoryImpl implements SettingsRepositoryInterface { Future signOut() async { try { // Step 1: Call V2 sign-out endpoint for server-side token revocation. - await _apiService.post(V2ApiEndpoints.clientSignOut); + await _apiService.post(AuthEndpoints.clientSignOut.path); } catch (e) { developer.log( 'V2 sign-out request failed: $e', diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart index ea359254..648a1acc 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.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:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; + import '../../blocs/client_settings_bloc.dart'; /// A widget that displays the log out button. 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 69ba37ea..6db9cd94 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 @@ -63,7 +63,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { try { final domain.ApiResponse startResponse = await _apiService.post( - V2ApiEndpoints.staffPhoneStart, + AuthEndpoints.staffPhoneStart.path, data: { 'phoneNumber': phoneNumber, }, @@ -182,7 +182,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // 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, + AuthEndpoints.staffPhoneVerify.path, data: { 'idToken': idToken, 'mode': v2Mode, @@ -233,7 +233,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { @override Future signOut() async { try { - await _apiService.post(V2ApiEndpoints.staffSignOut); + await _apiService.post(AuthEndpoints.staffSignOut.path); } catch (_) { // Sign-out should not fail even if the API call fails. // The local sign-out below will clear the session regardless. 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 5b27ec68..ecb52730 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 @@ -27,7 +27,7 @@ class ProfileSetupRepositoryImpl implements ProfileSetupRepository { required List skills, }) async { final ApiResponse response = await _apiService.post( - V2ApiEndpoints.staffProfileSetup, + StaffEndpoints.profileSetup.path, data: { 'fullName': fullName, if (bio != null && bio.isNotEmpty) 'bio': bio, 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 6857561b..fc0b6669 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 @@ -25,7 +25,7 @@ class AvailabilityRepositoryImpl implements AvailabilityRepository { final String endDate = _toIsoDate(end); final ApiResponse response = await _apiService.get( - V2ApiEndpoints.staffAvailability, + StaffEndpoints.availability.path, params: { 'startDate': startDate, 'endDate': endDate, @@ -48,7 +48,7 @@ class AvailabilityRepositoryImpl implements AvailabilityRepository { required List slots, }) async { final ApiResponse response = await _apiService.put( - V2ApiEndpoints.staffAvailability, + StaffEndpoints.availability.path, data: { 'dayOfWeek': dayOfWeek, 'availabilityStatus': status.toJson(), @@ -86,7 +86,7 @@ class AvailabilityRepositoryImpl implements AvailabilityRepository { } await _apiService.post( - V2ApiEndpoints.staffAvailabilityQuickSet, + StaffEndpoints.availabilityQuickSet.path, data: data, ); } 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 c0cfe0c2..efc2599b 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 @@ -5,7 +5,7 @@ import 'package:staff_clock_in/src/domain/repositories/clock_in_repository_inter /// Implementation of [ClockInRepositoryInterface] using the V2 REST API. /// -/// All backend calls go through [BaseApiService] with [V2ApiEndpoints]. +/// All backend calls go through [BaseApiService] with [StaffEndpoints]. /// The old Data Connect implementation has been removed. class ClockInRepositoryImpl implements ClockInRepositoryInterface { /// Creates a [ClockInRepositoryImpl] backed by the V2 API. @@ -17,7 +17,7 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface { @override Future> getTodaysShifts() async { final ApiResponse response = await _apiService.get( - V2ApiEndpoints.staffClockInShiftsToday, + StaffEndpoints.clockInShiftsToday.path, ); final List items = response.data['items'] as List; // TODO: Ask BE to add latitude, longitude, hourlyRate, and clientName @@ -33,7 +33,7 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface { @override Future getAttendanceStatus() async { final ApiResponse response = await _apiService.get( - V2ApiEndpoints.staffClockInStatus, + StaffEndpoints.clockInStatus.path, ); return AttendanceStatus.fromJson(response.data as Map); } @@ -44,7 +44,7 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface { String? notes, }) async { await _apiService.post( - V2ApiEndpoints.staffClockIn, + StaffEndpoints.clockIn.path, data: { 'shiftId': shiftId, 'sourceType': 'GEO', @@ -62,7 +62,7 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface { String? shiftId, }) async { await _apiService.post( - V2ApiEndpoints.staffClockOut, + StaffEndpoints.clockOut.path, data: { if (shiftId != null) 'shiftId': shiftId, 'sourceType': 'GEO', diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/clock_in_page_skeleton.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/clock_in_page_skeleton.dart index b4c0aade..e64b461f 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/clock_in_page_skeleton.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/clock_in_page_skeleton.dart @@ -16,19 +16,19 @@ class ClockInPageSkeleton extends StatelessWidget { @override Widget build(BuildContext context) { - return UiShimmer( + return const UiShimmer( child: SingleChildScrollView( - padding: const EdgeInsets.only( + padding: EdgeInsets.only( bottom: UiConstants.space24, top: UiConstants.space6, ), child: Padding( - padding: const EdgeInsets.symmetric( + padding: EdgeInsets.symmetric( horizontal: UiConstants.space5, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: const [ + children: [ // Date selector row DateSelectorSkeleton(), SizedBox(height: UiConstants.space5), diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart index 56072000..58ba8f24 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/geofence_status_banner/geofence_override_modal.dart @@ -2,8 +2,6 @@ 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:krow_core/core.dart'; import '../../bloc/geofence/geofence_bloc.dart'; import '../../bloc/geofence/geofence_event.dart'; 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 b24461cf..9da08cc3 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 @@ -17,7 +17,7 @@ class HomeRepositoryImpl implements HomeRepository { @override Future getDashboard() async { final ApiResponse response = - await _apiService.get(V2ApiEndpoints.staffDashboard); + await _apiService.get(StaffEndpoints.dashboard.path); final Map data = response.data as Map; return StaffDashboard.fromJson(data); } @@ -25,7 +25,7 @@ class HomeRepositoryImpl implements HomeRepository { @override Future getProfileCompletion() async { final ApiResponse response = - await _apiService.get(V2ApiEndpoints.staffProfileCompletion); + await _apiService.get(StaffEndpoints.profileCompletion.path); final Map data = response.data as Map; final ProfileCompletion completion = ProfileCompletion.fromJson(data); return completion.completed; 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 3530ef62..c20155e7 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 @@ -24,7 +24,7 @@ class PaymentsRepositoryImpl implements PaymentsRepository { if (endDate != null) 'endDate': endDate, }; final ApiResponse response = await _apiService.get( - V2ApiEndpoints.staffPaymentsSummary, + StaffEndpoints.paymentsSummary.path, params: params.isEmpty ? null : params, ); return PaymentSummary.fromJson(response.data as Map); @@ -40,7 +40,7 @@ class PaymentsRepositoryImpl implements PaymentsRepository { if (endDate != null) 'endDate': endDate, }; final ApiResponse response = await _apiService.get( - V2ApiEndpoints.staffPaymentsHistory, + StaffEndpoints.paymentsHistory.path, params: params.isEmpty ? null : params, ); final Map body = response.data as Map; @@ -63,7 +63,7 @@ class PaymentsRepositoryImpl implements PaymentsRepository { if (endDate != null) 'endDate': endDate, }; final ApiResponse response = await _apiService.get( - V2ApiEndpoints.staffPaymentsChart, + StaffEndpoints.paymentsChart.path, params: params, ); final Map body = response.data as Map; 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 index 076db252..49b8012f 100644 --- 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 @@ -14,7 +14,7 @@ class ProfileRepositoryImpl { /// Fetches the staff profile from the V2 session endpoint. Future getStaffProfile() async { final ApiResponse response = - await _api.get(V2ApiEndpoints.staffSession); + await _api.get(StaffEndpoints.session.path); final Map json = response.data['staff'] as Map; return Staff.fromJson(json); @@ -23,7 +23,7 @@ class ProfileRepositoryImpl { /// Fetches the profile section completion statuses. Future getProfileSections() async { final ApiResponse response = - await _api.get(V2ApiEndpoints.staffProfileSections); + await _api.get(StaffEndpoints.profileSections.path); final Map json = response.data as Map; return ProfileSectionStatus.fromJson(json); @@ -31,6 +31,6 @@ class ProfileRepositoryImpl { /// Signs out the current user. Future signOut() async { - await _api.post(V2ApiEndpoints.signOut); + await _api.post(AuthEndpoints.signOut.path); } } 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 b1af3e9e..22ab7e00 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 @@ -27,7 +27,7 @@ class CertificatesRepositoryImpl implements CertificatesRepository { @override Future> getCertificates() async { final ApiResponse response = - await _api.get(V2ApiEndpoints.staffCertificates); + await _api.get(StaffEndpoints.certificates.path); final List items = response.data['certificates'] as List; return items @@ -73,7 +73,7 @@ class CertificatesRepositoryImpl implements CertificatesRepository { // 4. Save certificate via V2 API await _api.post( - V2ApiEndpoints.staffCertificates, + StaffEndpoints.certificates.path, data: { 'certificateType': certificateType, 'name': name, @@ -95,7 +95,7 @@ class CertificatesRepositoryImpl implements CertificatesRepository { @override Future deleteCertificate({required String certificateId}) async { await _api.delete( - V2ApiEndpoints.staffCertificateDelete(certificateId), + StaffEndpoints.certificateDelete(certificateId).path, ); } } 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 5c8ea9d2..76c7023b 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 @@ -27,7 +27,7 @@ class DocumentsRepositoryImpl implements DocumentsRepository { @override Future> getDocuments() async { final ApiResponse response = - await _api.get(V2ApiEndpoints.staffDocuments); + await _api.get(StaffEndpoints.documents.path); final List items = response.data['documents'] as List; return items .map((dynamic json) => @@ -64,7 +64,7 @@ class DocumentsRepositoryImpl implements DocumentsRepository { // 4. Submit upload result to V2 API await _api.put( - V2ApiEndpoints.staffDocumentUpload(documentId), + StaffEndpoints.documentUpload(documentId).path, data: { 'fileUri': signedUrlRes.signedUrl, 'verificationId': verificationRes.verificationId, 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 ca1649a3..5961f3d8 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 @@ -18,7 +18,7 @@ class TaxFormsRepositoryImpl implements TaxFormsRepository { @override Future> getTaxForms() async { final ApiResponse response = - await _api.get(V2ApiEndpoints.staffTaxForms); + await _api.get(StaffEndpoints.taxForms.path); final List items = response.data['taxForms'] as List; return items .map((dynamic json) => @@ -29,7 +29,7 @@ class TaxFormsRepositoryImpl implements TaxFormsRepository { @override Future updateTaxForm(TaxForm form) async { await _api.put( - V2ApiEndpoints.staffTaxFormUpdate(form.formType), + StaffEndpoints.taxFormUpdate(form.formType).path, data: form.toJson(), ); } @@ -37,7 +37,7 @@ class TaxFormsRepositoryImpl implements TaxFormsRepository { @override Future submitTaxForm(TaxForm form) async { await _api.post( - V2ApiEndpoints.staffTaxFormSubmit(form.formType), + StaffEndpoints.taxFormSubmit(form.formType).path, data: form.toJson(), ); } 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 03aa491a..46ac8b8f 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 @@ -16,7 +16,7 @@ class BankAccountRepositoryImpl implements BankAccountRepository { @override Future> getAccounts() async { final ApiResponse response = - await _api.get(V2ApiEndpoints.staffBankAccounts); + await _api.get(StaffEndpoints.bankAccounts.path); final List items = response.data['accounts'] as List; return items .map((dynamic json) => @@ -27,7 +27,7 @@ class BankAccountRepositoryImpl implements BankAccountRepository { @override Future addAccount(BankAccount account) async { await _api.post( - V2ApiEndpoints.staffBankAccounts, + StaffEndpoints.bankAccounts.path, data: account.toJson(), ); } 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 5640aea7..82261445 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 @@ -16,7 +16,7 @@ class TimeCardRepositoryImpl implements TimeCardRepository { @override Future> getTimeCards(DateTime month) async { final ApiResponse response = await _api.get( - V2ApiEndpoints.staffTimeCard, + StaffEndpoints.timeCard.path, params: { 'year': month.year, 'month': month.month, 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 2846c6bd..ba00457c 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 @@ -27,7 +27,7 @@ class AttireRepositoryImpl implements AttireRepository { @override Future> getAttireOptions() async { - final ApiResponse response = await _api.get(V2ApiEndpoints.staffAttire); + final ApiResponse response = await _api.get(StaffEndpoints.attire.path); final List items = response.data['items'] as List; return items .map((dynamic json) => @@ -100,7 +100,7 @@ class AttireRepositoryImpl implements AttireRepository { // 5. Update attire item via V2 API await _api.put( - V2ApiEndpoints.staffAttireUpload(itemId), + StaffEndpoints.attireUpload(itemId).path, data: { 'photoUrl': photoUrl, 'verificationId': verifyRes.verificationId, 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 a0b90d67..ba8f01a0 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 @@ -17,7 +17,7 @@ class EmergencyContactRepositoryImpl @override Future> getContacts() async { final ApiResponse response = - await _api.get(V2ApiEndpoints.staffEmergencyContacts); + await _api.get(StaffEndpoints.emergencyContacts.path); final List items = response.data['contacts'] as List; return items .map((dynamic json) => @@ -28,7 +28,7 @@ class EmergencyContactRepositoryImpl @override Future saveContacts(List contacts) async { await _api.put( - V2ApiEndpoints.staffEmergencyContacts, + StaffEndpoints.emergencyContacts.path, data: { 'contacts': contacts.map((EmergencyContact c) => c.toJson()).toList(), 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 f7cc838e..5e25f550 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 @@ -16,14 +16,14 @@ class ExperienceRepositoryImpl implements ExperienceRepositoryInterface { @override Future> getIndustries() async { final ApiResponse response = - await _api.get(V2ApiEndpoints.staffIndustries); + await _api.get(StaffEndpoints.industries.path); final List items = response.data['industries'] as List; return items.map((dynamic e) => e.toString()).toList(); } @override Future> getSkills() async { - final ApiResponse response = await _api.get(V2ApiEndpoints.staffSkills); + final ApiResponse response = await _api.get(StaffEndpoints.skills.path); final List items = response.data['skills'] as List; return items.map((dynamic e) => e.toString()).toList(); } @@ -34,7 +34,7 @@ class ExperienceRepositoryImpl implements ExperienceRepositoryInterface { List skills, ) async { await _api.put( - V2ApiEndpoints.staffPersonalInfo, + StaffEndpoints.personalInfo.path, data: { 'industries': industries, 'skills': skills, 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 af633d67..ade14cba 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 @@ -28,7 +28,7 @@ class PersonalInfoRepositoryImpl implements PersonalInfoRepositoryInterface { @override Future getStaffProfile() async { final ApiResponse response = - await _api.get(V2ApiEndpoints.staffPersonalInfo); + await _api.get(StaffEndpoints.personalInfo.path); final Map json = response.data as Map; return StaffPersonalInfo.fromJson(json); @@ -40,7 +40,7 @@ class PersonalInfoRepositoryImpl implements PersonalInfoRepositoryInterface { required Map data, }) async { final ApiResponse response = await _api.put( - V2ApiEndpoints.staffPersonalInfo, + StaffEndpoints.personalInfo.path, data: data, ); final Map json = @@ -65,7 +65,7 @@ class PersonalInfoRepositoryImpl implements PersonalInfoRepositoryInterface { // 3. Submit the photo URL to the V2 API. await _api.post( - V2ApiEndpoints.staffProfilePhoto, + StaffEndpoints.profilePhoto.path, data: { 'fileUri': uploadRes.fileUri, 'photoUrl': photoUrl, 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 ec200d89..c8f26470 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 @@ -18,7 +18,7 @@ class FaqsRepositoryImpl implements FaqsRepositoryInterface { Future> getFaqs() async { try { final ApiResponse response = - await _apiService.get(V2ApiEndpoints.staffFaqs); + await _apiService.get(StaffEndpoints.faqs.path); return _parseCategories(response); } catch (_) { return []; @@ -29,7 +29,7 @@ class FaqsRepositoryImpl implements FaqsRepositoryInterface { Future> searchFaqs(String query) async { try { final ApiResponse response = await _apiService.get( - V2ApiEndpoints.staffFaqsSearch, + StaffEndpoints.faqsSearch.path, params: {'q': query}, ); return _parseCategories(response); 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 8001306c..24b49a99 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 @@ -19,7 +19,7 @@ class PrivacySettingsRepositoryImpl @override Future getProfileVisibility() async { final ApiResponse response = - await _api.get(V2ApiEndpoints.staffPrivacy); + await _api.get(StaffEndpoints.privacy.path); final Map json = response.data as Map; final PrivacySettings settings = PrivacySettings.fromJson(json); @@ -29,7 +29,7 @@ class PrivacySettingsRepositoryImpl @override Future updateProfileVisibility(bool isVisible) async { await _api.put( - V2ApiEndpoints.staffPrivacy, + StaffEndpoints.privacy.path, data: {'profileVisible': isVisible}, ); return isVisible; 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 f891e208..4057a0af 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 @@ -5,7 +5,7 @@ import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface /// V2 API implementation of [ShiftsRepositoryInterface]. /// -/// Uses [BaseApiService] with [V2ApiEndpoints] for all network access. +/// Uses [BaseApiService] with [StaffEndpoints] for all network access. class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { /// Creates a [ShiftsRepositoryImpl]. ShiftsRepositoryImpl({required BaseApiService apiService}) @@ -34,7 +34,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { required DateTime end, }) async { final ApiResponse response = await _apiService.get( - V2ApiEndpoints.staffShiftsAssigned, + StaffEndpoints.shiftsAssigned.path, params: { 'startDate': start.toIso8601String(), 'endDate': end.toIso8601String(), @@ -59,7 +59,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { params['search'] = search; } final ApiResponse response = await _apiService.get( - V2ApiEndpoints.staffShiftsOpen, + StaffEndpoints.shiftsOpen.path, params: params, ); final List items = _extractItems(response.data); @@ -72,7 +72,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { @override Future> getPendingAssignments() async { final ApiResponse response = - await _apiService.get(V2ApiEndpoints.staffShiftsPending); + await _apiService.get(StaffEndpoints.shiftsPending.path); final List items = _extractItems(response.data); return items .map((dynamic json) => @@ -83,7 +83,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { @override Future> getCancelledShifts() async { final ApiResponse response = - await _apiService.get(V2ApiEndpoints.staffShiftsCancelled); + await _apiService.get(StaffEndpoints.shiftsCancelled.path); final List items = _extractItems(response.data); return items .map((dynamic json) => @@ -94,7 +94,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { @override Future> getCompletedShifts() async { final ApiResponse response = - await _apiService.get(V2ApiEndpoints.staffShiftsCompleted); + await _apiService.get(StaffEndpoints.shiftsCompleted.path); final List items = _extractItems(response.data); return items .map((dynamic json) => @@ -105,7 +105,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { @override Future getShiftDetail(String shiftId) async { final ApiResponse response = - await _apiService.get(V2ApiEndpoints.staffShiftDetails(shiftId)); + await _apiService.get(StaffEndpoints.shiftDetails(shiftId).path); if (response.data == null) { return null; } @@ -119,7 +119,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { bool instantBook = false, }) async { await _apiService.post( - V2ApiEndpoints.staffShiftApply(shiftId), + StaffEndpoints.shiftApply(shiftId).path, data: { if (roleId != null) 'roleId': roleId, 'instantBook': instantBook, @@ -129,18 +129,18 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { @override Future acceptShift(String shiftId) async { - await _apiService.post(V2ApiEndpoints.staffShiftAccept(shiftId)); + await _apiService.post(StaffEndpoints.shiftAccept(shiftId).path); } @override Future declineShift(String shiftId) async { - await _apiService.post(V2ApiEndpoints.staffShiftDecline(shiftId)); + await _apiService.post(StaffEndpoints.shiftDecline(shiftId).path); } @override Future requestSwap(String shiftId, {String? reason}) async { await _apiService.post( - V2ApiEndpoints.staffShiftRequestSwap(shiftId), + StaffEndpoints.shiftRequestSwap(shiftId).path, data: { if (reason != null) 'reason': reason, }, @@ -150,7 +150,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { @override Future getProfileCompletion() async { final ApiResponse response = - await _apiService.get(V2ApiEndpoints.staffProfileCompletion); + await _apiService.get(StaffEndpoints.profileCompletion.path); 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/repositories/shifts_repository_interface.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart index 8fdaa4b7..abd6a9ea 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 @@ -3,7 +3,7 @@ import 'package:krow_domain/krow_domain.dart'; /// Contract for accessing shift-related data from the V2 API. /// /// Implementations reside in the data layer and use [BaseApiService] -/// with V2ApiEndpoints. +/// with [StaffEndpoints]. abstract interface class ShiftsRepositoryInterface { /// Retrieves assigned shifts for the current staff within a date range. Future> getAssignedShifts({ 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 60f0b0d2..134fe35b 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 @@ -1,13 +1,10 @@ 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_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. 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 index 0cd9e379..24232d75 100644 --- 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 @@ -23,7 +23,7 @@ class StaffMainRepositoryImpl implements StaffMainRepositoryInterface { Future getProfileCompletion() async { try { final ApiResponse response = await _apiService.get( - V2ApiEndpoints.staffProfileCompletion, + StaffEndpoints.profileCompletion.path, ); if (response.data is Map) { From 376b4e4431b050f0073166dd966f91b979e7052c Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 12:01:06 -0400 Subject: [PATCH 10/25] feat: Update API endpoint usage in repositories to remove redundant path property - Refactored multiple repository implementations across client and staff features to directly use endpoint objects without accessing the `path` property. - Introduced a new `FeatureGate` class for client-side feature gating based on user scopes, allowing for better access control to API endpoints. - Added `ApiEndpoint` class to represent API endpoints with their paths and required scopes for future feature gating. --- apps/mobile/packages/core/lib/core.dart | 4 +- .../src/services/api_service/api_service.dart | 34 +++++---- .../file_upload/file_upload_service.dart | 2 +- .../core_api_services/llm/llm_service.dart | 2 +- .../rapid_order/rapid_order_service.dart | 4 +- .../signed_url/signed_url_service.dart | 2 +- .../verification/verification_service.dart | 8 +-- .../api_service/endpoints/auth_endpoints.dart | 2 +- .../endpoints/client_endpoints.dart | 2 +- .../api_service/endpoints/core_endpoints.dart | 2 +- .../endpoints/staff_endpoints.dart | 2 +- .../services/api_service/feature_gate.dart | 69 +++++++++++++++++++ .../services/session/v2_session_service.dart | 4 +- .../packages/domain/lib/krow_domain.dart | 1 + .../services/api_services}/api_endpoint.dart | 0 .../api_services/base_api_service.dart | 14 ++-- .../lib/src/exceptions/app_exception.dart | 19 +++++ .../auth_repository_impl.dart | 6 +- .../billing_repository_impl.dart | 16 ++--- .../coverage_repository_impl.dart | 8 +-- .../home_repository_impl.dart | 4 +- .../hub_repository_impl.dart | 16 ++--- .../client_create_order_repository_impl.dart | 8 +-- .../client_order_query_repository_impl.dart | 8 +-- .../view_orders_repository_impl.dart | 14 ++-- .../reports_repository_impl.dart | 14 ++-- .../settings_repository_impl.dart | 2 +- .../auth_repository_impl.dart | 6 +- .../profile_setup_repository_impl.dart | 2 +- .../availability_repository_impl.dart | 6 +- .../clock_in_repository_impl.dart | 8 +-- .../repositories/home_repository_impl.dart | 4 +- .../payments_repository_impl.dart | 6 +- .../repositories/profile_repository_impl.dart | 6 +- .../certificates_repository_impl.dart | 6 +- .../documents_repository_impl.dart | 4 +- .../tax_forms_repository_impl.dart | 6 +- .../bank_account_repository_impl.dart | 4 +- .../time_card_repository_impl.dart | 2 +- .../attire_repository_impl.dart | 4 +- .../emergency_contact_repository_impl.dart | 4 +- .../experience_repository_impl.dart | 6 +- .../personal_info_repository_impl.dart | 6 +- .../faqs_repository_impl.dart | 4 +- .../privacy_settings_repository_impl.dart | 4 +- .../shifts_repository_impl.dart | 22 +++--- .../staff_main_repository_impl.dart | 2 +- 47 files changed, 240 insertions(+), 139 deletions(-) create mode 100644 apps/mobile/packages/core/lib/src/services/api_service/feature_gate.dart rename apps/mobile/packages/{core/lib/src/services/api_service => domain/lib/src/core/services/api_services}/api_endpoint.dart (100%) diff --git a/apps/mobile/packages/core/lib/core.dart b/apps/mobile/packages/core/lib/core.dart index 129afe0a..f9143b60 100644 --- a/apps/mobile/packages/core/lib/core.dart +++ b/apps/mobile/packages/core/lib/core.dart @@ -20,8 +20,8 @@ export 'src/services/api_service/dio_client.dart'; export 'src/services/api_service/mixins/api_error_handler.dart'; export 'src/services/api_service/mixins/session_handler_mixin.dart'; -// API Endpoint classes -export 'src/services/api_service/api_endpoint.dart'; +// Feature Gate & Endpoint classes +export 'src/services/api_service/feature_gate.dart'; export 'src/services/api_service/endpoints/auth_endpoints.dart'; export 'src/services/api_service/endpoints/client_endpoints.dart'; export 'src/services/api_service/endpoints/core_endpoints.dart'; diff --git a/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart index 00c58020..80e7a86b 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart @@ -1,10 +1,11 @@ import 'package:dio/dio.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'feature_gate.dart'; + /// A service that handles HTTP communication using the [Dio] client. /// -/// This class provides a wrapper around [Dio]'s methods to handle -/// response parsing and error handling in a consistent way. +/// Integrates [FeatureGate] to validate endpoint scopes before each request. class ApiService implements BaseApiService { /// Creates an [ApiService] with the given [Dio] instance. ApiService(this._dio); @@ -15,12 +16,13 @@ class ApiService implements BaseApiService { /// Performs a GET request to the specified [endpoint]. @override Future get( - String endpoint, { + ApiEndpoint endpoint, { Map? params, }) async { + FeatureGate.instance.validateAccess(endpoint); try { final Response response = await _dio.get( - endpoint, + endpoint.path, queryParameters: params, ); return _handleResponse(response); @@ -32,13 +34,14 @@ class ApiService implements BaseApiService { /// Performs a POST request to the specified [endpoint]. @override Future post( - String endpoint, { + ApiEndpoint endpoint, { dynamic data, Map? params, }) async { + FeatureGate.instance.validateAccess(endpoint); try { final Response response = await _dio.post( - endpoint, + endpoint.path, data: data, queryParameters: params, ); @@ -51,13 +54,14 @@ class ApiService implements BaseApiService { /// Performs a PUT request to the specified [endpoint]. @override Future put( - String endpoint, { + ApiEndpoint endpoint, { dynamic data, Map? params, }) async { + FeatureGate.instance.validateAccess(endpoint); try { final Response response = await _dio.put( - endpoint, + endpoint.path, data: data, queryParameters: params, ); @@ -70,13 +74,14 @@ class ApiService implements BaseApiService { /// Performs a PATCH request to the specified [endpoint]. @override Future patch( - String endpoint, { + ApiEndpoint endpoint, { dynamic data, Map? params, }) async { + FeatureGate.instance.validateAccess(endpoint); try { final Response response = await _dio.patch( - endpoint, + endpoint.path, data: data, queryParameters: params, ); @@ -89,13 +94,14 @@ class ApiService implements BaseApiService { /// Performs a DELETE request to the specified [endpoint]. @override Future delete( - String endpoint, { + ApiEndpoint endpoint, { dynamic data, Map? params, }) async { + FeatureGate.instance.validateAccess(endpoint); try { final Response response = await _dio.delete( - endpoint, + endpoint.path, data: data, queryParameters: params, ); @@ -105,6 +111,10 @@ class ApiService implements BaseApiService { } } + // --------------------------------------------------------------------------- + // Response handling + // --------------------------------------------------------------------------- + /// Extracts [ApiResponse] from a successful [Response]. ApiResponse _handleResponse(Response response) { return ApiResponse( diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_service.dart index 84ffd05f..b4231174 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_service.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_service.dart @@ -26,7 +26,7 @@ class FileUploadService extends BaseCoreService { if (category != null) 'category': category, }); - return api.post(CoreEndpoints.uploadFile.path, data: formData); + return api.post(CoreEndpoints.uploadFile, data: formData); }); if (res.code.startsWith('2')) { diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_service.dart index db5ab3a7..e1670dfc 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_service.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_service.dart @@ -19,7 +19,7 @@ class LlmService extends BaseCoreService { }) async { final ApiResponse res = await action(() async { return api.post( - CoreEndpoints.invokeLlm.path, + CoreEndpoints.invokeLlm, data: { 'prompt': prompt, if (responseJsonSchema != null) diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/rapid_order/rapid_order_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/rapid_order/rapid_order_service.dart index 92e8d249..ba37a20d 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/rapid_order/rapid_order_service.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/rapid_order/rapid_order_service.dart @@ -19,7 +19,7 @@ class RapidOrderService extends BaseCoreService { }) async { final ApiResponse res = await action(() async { return api.post( - CoreEndpoints.transcribeRapidOrder.path, + CoreEndpoints.transcribeRapidOrder, data: { 'audioFileUri': audioFileUri, 'locale': locale, @@ -51,7 +51,7 @@ class RapidOrderService extends BaseCoreService { }) async { final ApiResponse res = await action(() async { return api.post( - CoreEndpoints.parseRapidOrder.path, + CoreEndpoints.parseRapidOrder, data: { 'text': text, 'locale': locale, diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_service.dart index 24af5336..a7a5a17d 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_service.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_service.dart @@ -17,7 +17,7 @@ class SignedUrlService extends BaseCoreService { }) async { final ApiResponse res = await action(() async { return api.post( - CoreEndpoints.createSignedUrl.path, + CoreEndpoints.createSignedUrl, data: { 'fileUri': fileUri, 'expiresInSeconds': expiresInSeconds, diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart index db35f5dc..bd28e27f 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart @@ -22,7 +22,7 @@ class VerificationService extends BaseCoreService { }) async { final ApiResponse res = await action(() async { return api.post( - CoreEndpoints.verifications.path, + CoreEndpoints.verifications, data: { 'type': type, 'subjectType': subjectType, @@ -44,7 +44,7 @@ class VerificationService extends BaseCoreService { /// Polls the status of a specific verification. Future getStatus(String verificationId) async { final ApiResponse res = await action(() async { - return api.get(CoreEndpoints.verificationStatus(verificationId).path); + return api.get(CoreEndpoints.verificationStatus(verificationId)); }); if (res.code.startsWith('2')) { @@ -65,7 +65,7 @@ class VerificationService extends BaseCoreService { }) async { final ApiResponse res = await action(() async { return api.post( - CoreEndpoints.verificationReview(verificationId).path, + CoreEndpoints.verificationReview(verificationId), data: { 'decision': decision, if (note != null) 'note': note, @@ -84,7 +84,7 @@ class VerificationService extends BaseCoreService { /// Retries a verification job that failed or needs re-processing. Future retryVerification(String verificationId) async { final ApiResponse res = await action(() async { - return api.post(CoreEndpoints.verificationRetry(verificationId).path); + return api.post(CoreEndpoints.verificationRetry(verificationId)); }); if (res.code.startsWith('2')) { diff --git a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/auth_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/auth_endpoints.dart index 3dd0e56c..ed63b510 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/auth_endpoints.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/auth_endpoints.dart @@ -1,4 +1,4 @@ -import 'package:krow_core/src/services/api_service/api_endpoint.dart'; +import 'package:krow_domain/krow_domain.dart' show ApiEndpoint; /// Authentication endpoints for both staff and client apps. abstract final class AuthEndpoints { diff --git a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart index b72cd7b1..714172bb 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart @@ -1,4 +1,4 @@ -import 'package:krow_core/src/services/api_service/api_endpoint.dart'; +import 'package:krow_domain/krow_domain.dart' show ApiEndpoint; /// Client-specific API endpoints (read and write). abstract final class ClientEndpoints { diff --git a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/core_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/core_endpoints.dart index 22b59769..8c18a244 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/core_endpoints.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/core_endpoints.dart @@ -1,4 +1,4 @@ -import 'package:krow_core/src/services/api_service/api_endpoint.dart'; +import 'package:krow_domain/krow_domain.dart' show ApiEndpoint; /// Core infrastructure endpoints (upload, signed URLs, LLM, verifications, /// rapid orders). diff --git a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart index b12db827..878c3708 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart @@ -1,4 +1,4 @@ -import 'package:krow_core/src/services/api_service/api_endpoint.dart'; +import 'package:krow_domain/krow_domain.dart' show ApiEndpoint; /// Staff-specific API endpoints (read and write). abstract final class StaffEndpoints { diff --git a/apps/mobile/packages/core/lib/src/services/api_service/feature_gate.dart b/apps/mobile/packages/core/lib/src/services/api_service/feature_gate.dart new file mode 100644 index 00000000..340577a8 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/feature_gate.dart @@ -0,0 +1,69 @@ +import 'package:flutter/foundation.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Client-side feature gate that checks user scopes against endpoint +/// requirements before allowing an API call. +/// +/// Usage: +/// ```dart +/// FeatureGate.instance.validateAccess(StaffEndpoints.dashboard); +/// ``` +/// +/// When an endpoint's [ApiEndpoint.requiredScopes] is empty, access is always +/// granted. When scopes are defined, the gate verifies that the user has ALL +/// required scopes. Throws [InsufficientScopeException] if any are missing. +class FeatureGate { + FeatureGate._(); + + /// The global singleton instance. + static final FeatureGate instance = FeatureGate._(); + + /// The scopes the current user has. + List _userScopes = const []; + + /// Updates the user's scopes (call after sign-in or session hydration). + void setUserScopes(List scopes) { + _userScopes = List.unmodifiable(scopes); + debugPrint('[FeatureGate] User scopes updated: $_userScopes'); + } + + /// Clears the user's scopes (call on sign-out). + void clearScopes() { + _userScopes = const []; + debugPrint('[FeatureGate] User scopes cleared'); + } + + /// The current user's scopes (read-only). + List get userScopes => _userScopes; + + /// Returns `true` if the user has all scopes required by [endpoint]. + bool hasAccess(ApiEndpoint endpoint) { + if (endpoint.requiredScopes.isEmpty) return true; + return endpoint.requiredScopes.every( + (String scope) => _userScopes.contains(scope), + ); + } + + /// Validates that the user can access [endpoint]. + /// + /// No-op when the endpoint has no required scopes (ungated). + /// Throws [InsufficientScopeException] when scopes are missing. + void validateAccess(ApiEndpoint endpoint) { + if (endpoint.requiredScopes.isEmpty) return; + + final List missingScopes = endpoint.requiredScopes + .where((String scope) => !_userScopes.contains(scope)) + .toList(); + + if (missingScopes.isNotEmpty) { + throw InsufficientScopeException( + requiredScopes: endpoint.requiredScopes, + userScopes: _userScopes, + technicalMessage: + 'Endpoint "${endpoint.path}" requires scopes ' + '${endpoint.requiredScopes} but user has $_userScopes. ' + 'Missing: $missingScopes', + ); + } + } +} 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 index 28881cf8..30b7ce94 100644 --- 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 @@ -51,7 +51,7 @@ class V2SessionService with SessionHandlerMixin { return null; } - final ApiResponse response = await api.get(AuthEndpoints.session.path); + final ApiResponse response = await api.get(AuthEndpoints.session); if (response.data is Map) { final Map data = @@ -99,7 +99,7 @@ class V2SessionService with SessionHandlerMixin { final BaseApiService? api = _apiService; if (api != null) { try { - await api.post(AuthEndpoints.signOut.path); + await api.post(AuthEndpoints.signOut); } catch (e) { debugPrint('[V2SessionService] Server sign-out failed: $e'); } diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index efe8328e..d848a73b 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -23,6 +23,7 @@ export 'src/entities/enums/staff_status.dart'; export 'src/entities/enums/user_role.dart'; // Core +export 'src/core/services/api_services/api_endpoint.dart'; export 'src/core/services/api_services/api_response.dart'; export 'src/core/services/api_services/base_api_service.dart'; export 'src/core/services/api_services/base_core_service.dart'; diff --git a/apps/mobile/packages/core/lib/src/services/api_service/api_endpoint.dart b/apps/mobile/packages/domain/lib/src/core/services/api_services/api_endpoint.dart similarity index 100% rename from apps/mobile/packages/core/lib/src/services/api_service/api_endpoint.dart rename to apps/mobile/packages/domain/lib/src/core/services/api_services/api_endpoint.dart diff --git a/apps/mobile/packages/domain/lib/src/core/services/api_services/base_api_service.dart b/apps/mobile/packages/domain/lib/src/core/services/api_services/base_api_service.dart index a3dabfb0..ab572023 100644 --- a/apps/mobile/packages/domain/lib/src/core/services/api_services/base_api_service.dart +++ b/apps/mobile/packages/domain/lib/src/core/services/api_services/base_api_service.dart @@ -1,36 +1,38 @@ +import 'api_endpoint.dart'; import 'api_response.dart'; /// Abstract base class for API services. /// -/// This defines the contract for making HTTP requests. +/// Methods accept [ApiEndpoint] which carries the path and required scopes. +/// Implementations should validate scopes via [FeatureGate] before executing. abstract class BaseApiService { /// Performs a GET request to the specified [endpoint]. - Future get(String endpoint, {Map? params}); + Future get(ApiEndpoint endpoint, {Map? params}); /// Performs a POST request to the specified [endpoint]. Future post( - String endpoint, { + ApiEndpoint endpoint, { dynamic data, Map? params, }); /// Performs a PUT request to the specified [endpoint]. Future put( - String endpoint, { + ApiEndpoint endpoint, { dynamic data, Map? params, }); /// Performs a PATCH request to the specified [endpoint]. Future patch( - String endpoint, { + ApiEndpoint endpoint, { dynamic data, Map? params, }); /// Performs a DELETE request to the specified [endpoint]. Future delete( - String endpoint, { + ApiEndpoint endpoint, { dynamic data, Map? params, }); diff --git a/apps/mobile/packages/domain/lib/src/exceptions/app_exception.dart b/apps/mobile/packages/domain/lib/src/exceptions/app_exception.dart index a70e2bf6..f8abd5c0 100644 --- a/apps/mobile/packages/domain/lib/src/exceptions/app_exception.dart +++ b/apps/mobile/packages/domain/lib/src/exceptions/app_exception.dart @@ -330,3 +330,22 @@ class NotAuthenticatedException extends AppException { @override String get messageKey => 'errors.auth.not_authenticated'; } + +/// Thrown when the user lacks the required scopes to access an endpoint. +class InsufficientScopeException extends AppException { + /// Creates an [InsufficientScopeException]. + const InsufficientScopeException({ + required this.requiredScopes, + required this.userScopes, + super.technicalMessage, + }) : super(code: 'SCOPE_001'); + + /// The scopes required by the endpoint. + final List requiredScopes; + + /// The scopes the user currently has. + final List userScopes; + + @override + String get messageKey => 'errors.generic.insufficient_scope'; +} 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 b1f85552..08185e59 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 @@ -45,7 +45,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // 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( - AuthEndpoints.clientSignIn.path, + AuthEndpoints.clientSignIn, data: { 'email': email, 'password': password, @@ -107,7 +107,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // - Creates user, tenant, business, memberships in one transaction // - Returns full auth envelope with session tokens final ApiResponse response = await _apiService.post( - AuthEndpoints.clientSignUp.path, + AuthEndpoints.clientSignUp, data: { 'companyName': companyName, 'email': email, @@ -172,7 +172,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { Future signOut() async { try { // Step 1: Call V2 sign-out endpoint for server-side token revocation. - await _apiService.post(AuthEndpoints.clientSignOut.path); + await _apiService.post(AuthEndpoints.clientSignOut); } catch (e) { developer.log( 'V2 sign-out request failed: $e', 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 ac5b928b..64027bd7 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 @@ -17,7 +17,7 @@ class BillingRepositoryImpl implements BillingRepository { @override Future> getBankAccounts() async { final ApiResponse response = - await _apiService.get(ClientEndpoints.billingAccounts.path); + await _apiService.get(ClientEndpoints.billingAccounts); final List items = (response.data as Map)['items'] as List; return items @@ -29,7 +29,7 @@ class BillingRepositoryImpl implements BillingRepository { @override Future> getPendingInvoices() async { final ApiResponse response = - await _apiService.get(ClientEndpoints.billingInvoicesPending.path); + await _apiService.get(ClientEndpoints.billingInvoicesPending); final List items = (response.data as Map)['items'] as List; return items @@ -41,7 +41,7 @@ class BillingRepositoryImpl implements BillingRepository { @override Future> getInvoiceHistory() async { final ApiResponse response = - await _apiService.get(ClientEndpoints.billingInvoicesHistory.path); + await _apiService.get(ClientEndpoints.billingInvoicesHistory); final List items = (response.data as Map)['items'] as List; return items @@ -53,7 +53,7 @@ class BillingRepositoryImpl implements BillingRepository { @override Future getCurrentBillCents() async { final ApiResponse response = - await _apiService.get(ClientEndpoints.billingCurrentBill.path); + await _apiService.get(ClientEndpoints.billingCurrentBill); final Map data = response.data as Map; return (data['currentBillCents'] as num).toInt(); @@ -62,7 +62,7 @@ class BillingRepositoryImpl implements BillingRepository { @override Future getSavingsCents() async { final ApiResponse response = - await _apiService.get(ClientEndpoints.billingSavings.path); + await _apiService.get(ClientEndpoints.billingSavings); final Map data = response.data as Map; return (data['savingsCents'] as num).toInt(); @@ -74,7 +74,7 @@ class BillingRepositoryImpl implements BillingRepository { required String endDate, }) async { final ApiResponse response = await _apiService.get( - ClientEndpoints.billingSpendBreakdown.path, + ClientEndpoints.billingSpendBreakdown, params: { 'startDate': startDate, 'endDate': endDate, @@ -90,13 +90,13 @@ class BillingRepositoryImpl implements BillingRepository { @override Future approveInvoice(String id) async { - await _apiService.post(ClientEndpoints.invoiceApprove(id).path); + await _apiService.post(ClientEndpoints.invoiceApprove(id)); } @override Future disputeInvoice(String id, String reason) async { await _apiService.post( - ClientEndpoints.invoiceDispute(id).path, + ClientEndpoints.invoiceDispute(id), data: {'reason': reason}, ); } 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 c331e9c4..2010cec5 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 @@ -20,7 +20,7 @@ class CoverageRepositoryImpl implements CoverageRepository { final String dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; final ApiResponse response = await _apiService.get( - ClientEndpoints.coverage.path, + ClientEndpoints.coverage, params: {'date': dateStr}, ); final List items = response.data['items'] as List; @@ -35,7 +35,7 @@ class CoverageRepositoryImpl implements CoverageRepository { final String dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; final ApiResponse response = await _apiService.get( - ClientEndpoints.coverageStats.path, + ClientEndpoints.coverageStats, params: {'date': dateStr}, ); return CoverageStats.fromJson(response.data as Map); @@ -67,7 +67,7 @@ class CoverageRepositoryImpl implements CoverageRepository { body['markAsFavorite'] = markAsFavorite; } await _apiService.post( - ClientEndpoints.coverageReviews.path, + ClientEndpoints.coverageReviews, data: body, ); } @@ -82,7 +82,7 @@ class CoverageRepositoryImpl implements CoverageRepository { body['reason'] = reason; } await _apiService.post( - ClientEndpoints.coverageCancelLateWorker(assignmentId).path, + ClientEndpoints.coverageCancelLateWorker(assignmentId), data: body, ); } 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 e6b9d1ed..ea756255 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 @@ -18,7 +18,7 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { @override Future getDashboard() async { final ApiResponse response = - await _apiService.get(ClientEndpoints.dashboard.path); + await _apiService.get(ClientEndpoints.dashboard); final Map data = response.data as Map; return ClientDashboard.fromJson(data); } @@ -26,7 +26,7 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { @override Future> getRecentReorders() async { final ApiResponse response = - await _apiService.get(ClientEndpoints.reorders.path); + await _apiService.get(ClientEndpoints.reorders); final Map body = response.data as Map; final List items = body['items'] as List; return items 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 ecd06f76..22b066d4 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 @@ -17,7 +17,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { @override Future> getHubs() async { final ApiResponse response = - await _apiService.get(ClientEndpoints.hubs.path); + await _apiService.get(ClientEndpoints.hubs); final List items = (response.data as Map)['items'] as List; return items @@ -28,7 +28,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { @override Future> getCostCenters() async { final ApiResponse response = - await _apiService.get(ClientEndpoints.costCenters.path); + await _apiService.get(ClientEndpoints.costCenters); final List items = (response.data as Map)['items'] as List; return items @@ -52,7 +52,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { String? costCenterId, }) async { final ApiResponse response = await _apiService.post( - ClientEndpoints.hubCreate.path, + ClientEndpoints.hubCreate, data: { 'name': name, 'fullAddress': fullAddress, @@ -88,7 +88,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { String? costCenterId, }) async { final ApiResponse response = await _apiService.put( - ClientEndpoints.hubUpdate(hubId).path, + ClientEndpoints.hubUpdate(hubId), data: { 'hubId': hubId, if (name != null) 'name': name, @@ -111,7 +111,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { @override Future deleteHub(String hubId) async { - await _apiService.delete(ClientEndpoints.hubDelete(hubId).path); + await _apiService.delete(ClientEndpoints.hubDelete(hubId)); } @override @@ -120,7 +120,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { required String nfcTagId, }) async { await _apiService.post( - ClientEndpoints.hubAssignNfc(hubId).path, + ClientEndpoints.hubAssignNfc(hubId), data: {'nfcTagId': nfcTagId}, ); } @@ -128,7 +128,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { @override Future> getManagers(String hubId) async { final ApiResponse response = - await _apiService.get(ClientEndpoints.hubManagers(hubId).path); + await _apiService.get(ClientEndpoints.hubManagers(hubId)); final List items = (response.data as Map)['items'] as List; return items @@ -143,7 +143,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { required List businessMembershipIds, }) async { await _apiService.post( - ClientEndpoints.hubAssignManagers(hubId).path, + ClientEndpoints.hubAssignManagers(hubId), data: { 'businessMembershipIds': businessMembershipIds, }, 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 6efc9179..7739e41e 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 @@ -24,17 +24,17 @@ class ClientCreateOrderRepositoryImpl @override Future createOneTimeOrder(Map payload) async { - await _api.post(ClientEndpoints.ordersOneTime.path, data: payload); + await _api.post(ClientEndpoints.ordersOneTime, data: payload); } @override Future createRecurringOrder(Map payload) async { - await _api.post(ClientEndpoints.ordersRecurring.path, data: payload); + await _api.post(ClientEndpoints.ordersRecurring, data: payload); } @override Future createPermanentOrder(Map payload) async { - await _api.post(ClientEndpoints.ordersPermanent.path, data: payload); + await _api.post(ClientEndpoints.ordersPermanent, data: payload); } @override @@ -82,7 +82,7 @@ class ClientCreateOrderRepositoryImpl @override Future getOrderDetailsForReorder(String orderId) async { final ApiResponse response = await _api.get( - ClientEndpoints.orderReorderPreview(orderId).path, + ClientEndpoints.orderReorderPreview(orderId), ); 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 b1d62cb8..967da1b6 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 @@ -19,7 +19,7 @@ class ClientOrderQueryRepositoryImpl @override Future> getVendors() async { - final ApiResponse response = await _api.get(ClientEndpoints.vendors.path); + final ApiResponse response = await _api.get(ClientEndpoints.vendors); final Map data = response.data as Map; final List items = data['items'] as List; return items @@ -30,7 +30,7 @@ class ClientOrderQueryRepositoryImpl @override Future> getRolesByVendor(String vendorId) async { final ApiResponse response = - await _api.get(ClientEndpoints.vendorRoles(vendorId).path); + await _api.get(ClientEndpoints.vendorRoles(vendorId)); final Map data = response.data as Map; final List items = data['items'] as List; return items.map((dynamic json) { @@ -46,7 +46,7 @@ class ClientOrderQueryRepositoryImpl @override Future> getHubs() async { - final ApiResponse response = await _api.get(ClientEndpoints.hubs.path); + final ApiResponse response = await _api.get(ClientEndpoints.hubs); final Map data = response.data as Map; final List items = data['items'] as List; return items.map((dynamic json) { @@ -71,7 +71,7 @@ class ClientOrderQueryRepositoryImpl @override Future> getManagersByHub(String hubId) async { final ApiResponse response = - await _api.get(ClientEndpoints.hubManagers(hubId).path); + await _api.get(ClientEndpoints.hubManagers(hubId)); final Map data = response.data as Map; final List items = data['items'] as List; return items.map((dynamic json) { 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 a12fce25..2f656e39 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 @@ -20,7 +20,7 @@ class ViewOrdersRepositoryImpl implements IViewOrdersRepository { required DateTime end, }) async { final ApiResponse response = await _api.get( - ClientEndpoints.ordersView.path, + ClientEndpoints.ordersView, params: { 'startDate': start.toIso8601String(), 'endDate': end.toIso8601String(), @@ -40,7 +40,7 @@ class ViewOrdersRepositoryImpl implements IViewOrdersRepository { required Map payload, }) async { final ApiResponse response = await _api.post( - ClientEndpoints.orderEdit(orderId).path, + ClientEndpoints.orderEdit(orderId), data: payload, ); final Map data = response.data as Map; @@ -53,7 +53,7 @@ class ViewOrdersRepositoryImpl implements IViewOrdersRepository { String? reason, }) async { await _api.post( - ClientEndpoints.orderCancel(orderId).path, + ClientEndpoints.orderCancel(orderId), data: { if (reason != null) 'reason': reason, }, @@ -62,7 +62,7 @@ class ViewOrdersRepositoryImpl implements IViewOrdersRepository { @override Future> getVendors() async { - final ApiResponse response = await _api.get(ClientEndpoints.vendors.path); + final ApiResponse response = await _api.get(ClientEndpoints.vendors); final Map data = response.data as Map; final List items = data['items'] as List; return items @@ -73,7 +73,7 @@ class ViewOrdersRepositoryImpl implements IViewOrdersRepository { @override Future>> getRolesByVendor(String vendorId) async { final ApiResponse response = - await _api.get(ClientEndpoints.vendorRoles(vendorId).path); + await _api.get(ClientEndpoints.vendorRoles(vendorId)); final Map data = response.data as Map; final List items = data['items'] as List; return items.cast>(); @@ -81,7 +81,7 @@ class ViewOrdersRepositoryImpl implements IViewOrdersRepository { @override Future>> getHubs() async { - final ApiResponse response = await _api.get(ClientEndpoints.hubs.path); + final ApiResponse response = await _api.get(ClientEndpoints.hubs); final Map data = response.data as Map; final List items = data['items'] as List; return items.cast>(); @@ -90,7 +90,7 @@ class ViewOrdersRepositoryImpl implements IViewOrdersRepository { @override Future>> getManagersByHub(String hubId) async { final ApiResponse response = - await _api.get(ClientEndpoints.hubManagers(hubId).path); + await _api.get(ClientEndpoints.hubManagers(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/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 f067d464..40853550 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 @@ -32,7 +32,7 @@ class ReportsRepositoryImpl implements ReportsRepository { required DateTime date, }) async { final ApiResponse response = await _apiService.get( - ClientEndpoints.reportsDailyOps.path, + ClientEndpoints.reportsDailyOps, params: {'date': _iso(date)}, ); final Map data = response.data as Map; @@ -45,7 +45,7 @@ class ReportsRepositoryImpl implements ReportsRepository { required DateTime endDate, }) async { final ApiResponse response = await _apiService.get( - ClientEndpoints.reportsSpend.path, + ClientEndpoints.reportsSpend, params: _rangeParams(startDate, endDate), ); final Map data = response.data as Map; @@ -58,7 +58,7 @@ class ReportsRepositoryImpl implements ReportsRepository { required DateTime endDate, }) async { final ApiResponse response = await _apiService.get( - ClientEndpoints.reportsCoverage.path, + ClientEndpoints.reportsCoverage, params: _rangeParams(startDate, endDate), ); final Map data = response.data as Map; @@ -71,7 +71,7 @@ class ReportsRepositoryImpl implements ReportsRepository { required DateTime endDate, }) async { final ApiResponse response = await _apiService.get( - ClientEndpoints.reportsForecast.path, + ClientEndpoints.reportsForecast, params: _rangeParams(startDate, endDate), ); final Map data = response.data as Map; @@ -84,7 +84,7 @@ class ReportsRepositoryImpl implements ReportsRepository { required DateTime endDate, }) async { final ApiResponse response = await _apiService.get( - ClientEndpoints.reportsPerformance.path, + ClientEndpoints.reportsPerformance, params: _rangeParams(startDate, endDate), ); final Map data = response.data as Map; @@ -97,7 +97,7 @@ class ReportsRepositoryImpl implements ReportsRepository { required DateTime endDate, }) async { final ApiResponse response = await _apiService.get( - ClientEndpoints.reportsNoShow.path, + ClientEndpoints.reportsNoShow, params: _rangeParams(startDate, endDate), ); final Map data = response.data as Map; @@ -110,7 +110,7 @@ class ReportsRepositoryImpl implements ReportsRepository { required DateTime endDate, }) async { final ApiResponse response = await _apiService.get( - ClientEndpoints.reportsSummary.path, + ClientEndpoints.reportsSummary, params: _rangeParams(startDate, endDate), ); final Map data = response.data as Map; 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 ce114987..15ff9337 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 @@ -22,7 +22,7 @@ class SettingsRepositoryImpl implements SettingsRepositoryInterface { Future signOut() async { try { // Step 1: Call V2 sign-out endpoint for server-side token revocation. - await _apiService.post(AuthEndpoints.clientSignOut.path); + await _apiService.post(AuthEndpoints.clientSignOut); } catch (e) { developer.log( 'V2 sign-out request failed: $e', 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 6db9cd94..b3c92f14 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 @@ -63,7 +63,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { try { final domain.ApiResponse startResponse = await _apiService.post( - AuthEndpoints.staffPhoneStart.path, + AuthEndpoints.staffPhoneStart, data: { 'phoneNumber': phoneNumber, }, @@ -182,7 +182,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // 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( - AuthEndpoints.staffPhoneVerify.path, + AuthEndpoints.staffPhoneVerify, data: { 'idToken': idToken, 'mode': v2Mode, @@ -233,7 +233,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { @override Future signOut() async { try { - await _apiService.post(AuthEndpoints.staffSignOut.path); + await _apiService.post(AuthEndpoints.staffSignOut); } catch (_) { // Sign-out should not fail even if the API call fails. // The local sign-out below will clear the session regardless. 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 ecb52730..2ca01e07 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 @@ -27,7 +27,7 @@ class ProfileSetupRepositoryImpl implements ProfileSetupRepository { required List skills, }) async { final ApiResponse response = await _apiService.post( - StaffEndpoints.profileSetup.path, + StaffEndpoints.profileSetup, data: { 'fullName': fullName, if (bio != null && bio.isNotEmpty) 'bio': bio, 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 fc0b6669..a402b6ee 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 @@ -25,7 +25,7 @@ class AvailabilityRepositoryImpl implements AvailabilityRepository { final String endDate = _toIsoDate(end); final ApiResponse response = await _apiService.get( - StaffEndpoints.availability.path, + StaffEndpoints.availability, params: { 'startDate': startDate, 'endDate': endDate, @@ -48,7 +48,7 @@ class AvailabilityRepositoryImpl implements AvailabilityRepository { required List slots, }) async { final ApiResponse response = await _apiService.put( - StaffEndpoints.availability.path, + StaffEndpoints.availability, data: { 'dayOfWeek': dayOfWeek, 'availabilityStatus': status.toJson(), @@ -86,7 +86,7 @@ class AvailabilityRepositoryImpl implements AvailabilityRepository { } await _apiService.post( - StaffEndpoints.availabilityQuickSet.path, + StaffEndpoints.availabilityQuickSet, data: data, ); } 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 efc2599b..ee44c6a2 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 @@ -17,7 +17,7 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface { @override Future> getTodaysShifts() async { final ApiResponse response = await _apiService.get( - StaffEndpoints.clockInShiftsToday.path, + StaffEndpoints.clockInShiftsToday, ); final List items = response.data['items'] as List; // TODO: Ask BE to add latitude, longitude, hourlyRate, and clientName @@ -33,7 +33,7 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface { @override Future getAttendanceStatus() async { final ApiResponse response = await _apiService.get( - StaffEndpoints.clockInStatus.path, + StaffEndpoints.clockInStatus, ); return AttendanceStatus.fromJson(response.data as Map); } @@ -44,7 +44,7 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface { String? notes, }) async { await _apiService.post( - StaffEndpoints.clockIn.path, + StaffEndpoints.clockIn, data: { 'shiftId': shiftId, 'sourceType': 'GEO', @@ -62,7 +62,7 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface { String? shiftId, }) async { await _apiService.post( - StaffEndpoints.clockOut.path, + StaffEndpoints.clockOut, data: { if (shiftId != null) 'shiftId': shiftId, 'sourceType': 'GEO', 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 9da08cc3..bc69b23c 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 @@ -17,7 +17,7 @@ class HomeRepositoryImpl implements HomeRepository { @override Future getDashboard() async { final ApiResponse response = - await _apiService.get(StaffEndpoints.dashboard.path); + await _apiService.get(StaffEndpoints.dashboard); final Map data = response.data as Map; return StaffDashboard.fromJson(data); } @@ -25,7 +25,7 @@ class HomeRepositoryImpl implements HomeRepository { @override Future getProfileCompletion() async { final ApiResponse response = - await _apiService.get(StaffEndpoints.profileCompletion.path); + await _apiService.get(StaffEndpoints.profileCompletion); final Map data = response.data as Map; final ProfileCompletion completion = ProfileCompletion.fromJson(data); return completion.completed; 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 c20155e7..aa0ea027 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 @@ -24,7 +24,7 @@ class PaymentsRepositoryImpl implements PaymentsRepository { if (endDate != null) 'endDate': endDate, }; final ApiResponse response = await _apiService.get( - StaffEndpoints.paymentsSummary.path, + StaffEndpoints.paymentsSummary, params: params.isEmpty ? null : params, ); return PaymentSummary.fromJson(response.data as Map); @@ -40,7 +40,7 @@ class PaymentsRepositoryImpl implements PaymentsRepository { if (endDate != null) 'endDate': endDate, }; final ApiResponse response = await _apiService.get( - StaffEndpoints.paymentsHistory.path, + StaffEndpoints.paymentsHistory, params: params.isEmpty ? null : params, ); final Map body = response.data as Map; @@ -63,7 +63,7 @@ class PaymentsRepositoryImpl implements PaymentsRepository { if (endDate != null) 'endDate': endDate, }; final ApiResponse response = await _apiService.get( - StaffEndpoints.paymentsChart.path, + StaffEndpoints.paymentsChart, params: params, ); final Map body = response.data as Map; 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 index 49b8012f..606d08f0 100644 --- 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 @@ -14,7 +14,7 @@ class ProfileRepositoryImpl { /// Fetches the staff profile from the V2 session endpoint. Future getStaffProfile() async { final ApiResponse response = - await _api.get(StaffEndpoints.session.path); + await _api.get(StaffEndpoints.session); final Map json = response.data['staff'] as Map; return Staff.fromJson(json); @@ -23,7 +23,7 @@ class ProfileRepositoryImpl { /// Fetches the profile section completion statuses. Future getProfileSections() async { final ApiResponse response = - await _api.get(StaffEndpoints.profileSections.path); + await _api.get(StaffEndpoints.profileSections); final Map json = response.data as Map; return ProfileSectionStatus.fromJson(json); @@ -31,6 +31,6 @@ class ProfileRepositoryImpl { /// Signs out the current user. Future signOut() async { - await _api.post(AuthEndpoints.signOut.path); + await _api.post(AuthEndpoints.signOut); } } 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 22ab7e00..b1762a77 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 @@ -27,7 +27,7 @@ class CertificatesRepositoryImpl implements CertificatesRepository { @override Future> getCertificates() async { final ApiResponse response = - await _api.get(StaffEndpoints.certificates.path); + await _api.get(StaffEndpoints.certificates); final List items = response.data['certificates'] as List; return items @@ -73,7 +73,7 @@ class CertificatesRepositoryImpl implements CertificatesRepository { // 4. Save certificate via V2 API await _api.post( - StaffEndpoints.certificates.path, + StaffEndpoints.certificates, data: { 'certificateType': certificateType, 'name': name, @@ -95,7 +95,7 @@ class CertificatesRepositoryImpl implements CertificatesRepository { @override Future deleteCertificate({required String certificateId}) async { await _api.delete( - StaffEndpoints.certificateDelete(certificateId).path, + StaffEndpoints.certificateDelete(certificateId), ); } } 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 76c7023b..01505378 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 @@ -27,7 +27,7 @@ class DocumentsRepositoryImpl implements DocumentsRepository { @override Future> getDocuments() async { final ApiResponse response = - await _api.get(StaffEndpoints.documents.path); + await _api.get(StaffEndpoints.documents); final List items = response.data['documents'] as List; return items .map((dynamic json) => @@ -64,7 +64,7 @@ class DocumentsRepositoryImpl implements DocumentsRepository { // 4. Submit upload result to V2 API await _api.put( - StaffEndpoints.documentUpload(documentId).path, + StaffEndpoints.documentUpload(documentId), data: { 'fileUri': signedUrlRes.signedUrl, 'verificationId': verificationRes.verificationId, 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 5961f3d8..d3d36e39 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 @@ -18,7 +18,7 @@ class TaxFormsRepositoryImpl implements TaxFormsRepository { @override Future> getTaxForms() async { final ApiResponse response = - await _api.get(StaffEndpoints.taxForms.path); + await _api.get(StaffEndpoints.taxForms); final List items = response.data['taxForms'] as List; return items .map((dynamic json) => @@ -29,7 +29,7 @@ class TaxFormsRepositoryImpl implements TaxFormsRepository { @override Future updateTaxForm(TaxForm form) async { await _api.put( - StaffEndpoints.taxFormUpdate(form.formType).path, + StaffEndpoints.taxFormUpdate(form.formType), data: form.toJson(), ); } @@ -37,7 +37,7 @@ class TaxFormsRepositoryImpl implements TaxFormsRepository { @override Future submitTaxForm(TaxForm form) async { await _api.post( - StaffEndpoints.taxFormSubmit(form.formType).path, + StaffEndpoints.taxFormSubmit(form.formType), data: form.toJson(), ); } 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 46ac8b8f..48058367 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 @@ -16,7 +16,7 @@ class BankAccountRepositoryImpl implements BankAccountRepository { @override Future> getAccounts() async { final ApiResponse response = - await _api.get(StaffEndpoints.bankAccounts.path); + await _api.get(StaffEndpoints.bankAccounts); final List items = response.data['accounts'] as List; return items .map((dynamic json) => @@ -27,7 +27,7 @@ class BankAccountRepositoryImpl implements BankAccountRepository { @override Future addAccount(BankAccount account) async { await _api.post( - StaffEndpoints.bankAccounts.path, + StaffEndpoints.bankAccounts, data: account.toJson(), ); } 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 82261445..f72d0803 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 @@ -16,7 +16,7 @@ class TimeCardRepositoryImpl implements TimeCardRepository { @override Future> getTimeCards(DateTime month) async { final ApiResponse response = await _api.get( - StaffEndpoints.timeCard.path, + StaffEndpoints.timeCard, params: { 'year': month.year, 'month': month.month, 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 ba00457c..5c9b9369 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 @@ -27,7 +27,7 @@ class AttireRepositoryImpl implements AttireRepository { @override Future> getAttireOptions() async { - final ApiResponse response = await _api.get(StaffEndpoints.attire.path); + final ApiResponse response = await _api.get(StaffEndpoints.attire); final List items = response.data['items'] as List; return items .map((dynamic json) => @@ -100,7 +100,7 @@ class AttireRepositoryImpl implements AttireRepository { // 5. Update attire item via V2 API await _api.put( - StaffEndpoints.attireUpload(itemId).path, + StaffEndpoints.attireUpload(itemId), data: { 'photoUrl': photoUrl, 'verificationId': verifyRes.verificationId, 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 ba8f01a0..52be33d2 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 @@ -17,7 +17,7 @@ class EmergencyContactRepositoryImpl @override Future> getContacts() async { final ApiResponse response = - await _api.get(StaffEndpoints.emergencyContacts.path); + await _api.get(StaffEndpoints.emergencyContacts); final List items = response.data['contacts'] as List; return items .map((dynamic json) => @@ -28,7 +28,7 @@ class EmergencyContactRepositoryImpl @override Future saveContacts(List contacts) async { await _api.put( - StaffEndpoints.emergencyContacts.path, + StaffEndpoints.emergencyContacts, data: { 'contacts': contacts.map((EmergencyContact c) => c.toJson()).toList(), 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 5e25f550..28ae1150 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 @@ -16,14 +16,14 @@ class ExperienceRepositoryImpl implements ExperienceRepositoryInterface { @override Future> getIndustries() async { final ApiResponse response = - await _api.get(StaffEndpoints.industries.path); + await _api.get(StaffEndpoints.industries); final List items = response.data['industries'] as List; return items.map((dynamic e) => e.toString()).toList(); } @override Future> getSkills() async { - final ApiResponse response = await _api.get(StaffEndpoints.skills.path); + final ApiResponse response = await _api.get(StaffEndpoints.skills); final List items = response.data['skills'] as List; return items.map((dynamic e) => e.toString()).toList(); } @@ -34,7 +34,7 @@ class ExperienceRepositoryImpl implements ExperienceRepositoryInterface { List skills, ) async { await _api.put( - StaffEndpoints.personalInfo.path, + StaffEndpoints.personalInfo, data: { 'industries': industries, 'skills': skills, 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 ade14cba..75fc0a01 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 @@ -28,7 +28,7 @@ class PersonalInfoRepositoryImpl implements PersonalInfoRepositoryInterface { @override Future getStaffProfile() async { final ApiResponse response = - await _api.get(StaffEndpoints.personalInfo.path); + await _api.get(StaffEndpoints.personalInfo); final Map json = response.data as Map; return StaffPersonalInfo.fromJson(json); @@ -40,7 +40,7 @@ class PersonalInfoRepositoryImpl implements PersonalInfoRepositoryInterface { required Map data, }) async { final ApiResponse response = await _api.put( - StaffEndpoints.personalInfo.path, + StaffEndpoints.personalInfo, data: data, ); final Map json = @@ -65,7 +65,7 @@ class PersonalInfoRepositoryImpl implements PersonalInfoRepositoryInterface { // 3. Submit the photo URL to the V2 API. await _api.post( - StaffEndpoints.profilePhoto.path, + StaffEndpoints.profilePhoto, data: { 'fileUri': uploadRes.fileUri, 'photoUrl': photoUrl, 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 c8f26470..7e6fea1a 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 @@ -18,7 +18,7 @@ class FaqsRepositoryImpl implements FaqsRepositoryInterface { Future> getFaqs() async { try { final ApiResponse response = - await _apiService.get(StaffEndpoints.faqs.path); + await _apiService.get(StaffEndpoints.faqs); return _parseCategories(response); } catch (_) { return []; @@ -29,7 +29,7 @@ class FaqsRepositoryImpl implements FaqsRepositoryInterface { Future> searchFaqs(String query) async { try { final ApiResponse response = await _apiService.get( - StaffEndpoints.faqsSearch.path, + StaffEndpoints.faqsSearch, params: {'q': query}, ); return _parseCategories(response); 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 24b49a99..fe50221b 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 @@ -19,7 +19,7 @@ class PrivacySettingsRepositoryImpl @override Future getProfileVisibility() async { final ApiResponse response = - await _api.get(StaffEndpoints.privacy.path); + await _api.get(StaffEndpoints.privacy); final Map json = response.data as Map; final PrivacySettings settings = PrivacySettings.fromJson(json); @@ -29,7 +29,7 @@ class PrivacySettingsRepositoryImpl @override Future updateProfileVisibility(bool isVisible) async { await _api.put( - StaffEndpoints.privacy.path, + StaffEndpoints.privacy, data: {'profileVisible': isVisible}, ); return isVisible; 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 4057a0af..8835d825 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 @@ -34,7 +34,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { required DateTime end, }) async { final ApiResponse response = await _apiService.get( - StaffEndpoints.shiftsAssigned.path, + StaffEndpoints.shiftsAssigned, params: { 'startDate': start.toIso8601String(), 'endDate': end.toIso8601String(), @@ -59,7 +59,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { params['search'] = search; } final ApiResponse response = await _apiService.get( - StaffEndpoints.shiftsOpen.path, + StaffEndpoints.shiftsOpen, params: params, ); final List items = _extractItems(response.data); @@ -72,7 +72,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { @override Future> getPendingAssignments() async { final ApiResponse response = - await _apiService.get(StaffEndpoints.shiftsPending.path); + await _apiService.get(StaffEndpoints.shiftsPending); final List items = _extractItems(response.data); return items .map((dynamic json) => @@ -83,7 +83,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { @override Future> getCancelledShifts() async { final ApiResponse response = - await _apiService.get(StaffEndpoints.shiftsCancelled.path); + await _apiService.get(StaffEndpoints.shiftsCancelled); final List items = _extractItems(response.data); return items .map((dynamic json) => @@ -94,7 +94,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { @override Future> getCompletedShifts() async { final ApiResponse response = - await _apiService.get(StaffEndpoints.shiftsCompleted.path); + await _apiService.get(StaffEndpoints.shiftsCompleted); final List items = _extractItems(response.data); return items .map((dynamic json) => @@ -105,7 +105,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { @override Future getShiftDetail(String shiftId) async { final ApiResponse response = - await _apiService.get(StaffEndpoints.shiftDetails(shiftId).path); + await _apiService.get(StaffEndpoints.shiftDetails(shiftId)); if (response.data == null) { return null; } @@ -119,7 +119,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { bool instantBook = false, }) async { await _apiService.post( - StaffEndpoints.shiftApply(shiftId).path, + StaffEndpoints.shiftApply(shiftId), data: { if (roleId != null) 'roleId': roleId, 'instantBook': instantBook, @@ -129,18 +129,18 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { @override Future acceptShift(String shiftId) async { - await _apiService.post(StaffEndpoints.shiftAccept(shiftId).path); + await _apiService.post(StaffEndpoints.shiftAccept(shiftId)); } @override Future declineShift(String shiftId) async { - await _apiService.post(StaffEndpoints.shiftDecline(shiftId).path); + await _apiService.post(StaffEndpoints.shiftDecline(shiftId)); } @override Future requestSwap(String shiftId, {String? reason}) async { await _apiService.post( - StaffEndpoints.shiftRequestSwap(shiftId).path, + StaffEndpoints.shiftRequestSwap(shiftId), data: { if (reason != null) 'reason': reason, }, @@ -150,7 +150,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { @override Future getProfileCompletion() async { final ApiResponse response = - await _apiService.get(StaffEndpoints.profileCompletion.path); + await _apiService.get(StaffEndpoints.profileCompletion); final Map data = response.data as Map; final ProfileCompletion completion = ProfileCompletion.fromJson(data); return completion.completed; 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 index 24232d75..01f76599 100644 --- 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 @@ -23,7 +23,7 @@ class StaffMainRepositoryImpl implements StaffMainRepositoryInterface { Future getProfileCompletion() async { try { final ApiResponse response = await _apiService.get( - StaffEndpoints.profileCompletion.path, + StaffEndpoints.profileCompletion, ); if (response.data is Map) { From d12b45a37d7958d3eb0569d63fb3cfdf6ab8df56 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 12:14:14 -0400 Subject: [PATCH 11/25] Expose parseDouble; fix shift mapping and UI Rename and expose numeric parser (from _parseDouble to parseDouble) with a doc comment so other modules can reuse it. Update ClockIn repository mapping to populate orderId and prefer clientName for the shift title, and map latitude/longitude using Shift.parseDouble. Remove outdated TODOs and remove the selected/today badge text from the shift card UI. These changes consolidate parsing logic, improve today-shift mapping fidelity, and simplify the shift card display. --- .../domain/lib/src/entities/shifts/shift.dart | 7 ++++--- .../clock_in_repository_impl.dart | 16 ++++------------ .../lib/src/presentation/widgets/shift_card.dart | 7 ------- 3 files changed, 8 insertions(+), 22 deletions(-) diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart index 4d6f11cd..03d21a7b 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart @@ -39,8 +39,8 @@ class Shift extends Equatable { timezone: json['timezone'] as String? ?? 'UTC', locationName: json['locationName'] as String?, locationAddress: json['locationAddress'] as String?, - latitude: _parseDouble(json['latitude']), - longitude: _parseDouble(json['longitude']), + latitude: parseDouble(json['latitude']), + longitude: parseDouble(json['longitude']), geofenceRadiusMeters: json['geofenceRadiusMeters'] as int?, requiredWorkers: json['requiredWorkers'] as int? ?? 1, assignedWorkers: json['assignedWorkers'] as int? ?? 0, @@ -114,7 +114,8 @@ class Shift extends Equatable { }; } - static double? _parseDouble(dynamic value) { + /// Safely parses a numeric value to double. + static double? parseDouble(dynamic value) { if (value == null) return null; if (value is double) return value; if (value is int) return value.toDouble(); 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 ee44c6a2..96992d9b 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 @@ -20,8 +20,6 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface { StaffEndpoints.clockInShiftsToday, ); 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) => @@ -75,23 +73,17 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface { } /// 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? ?? '', + orderId: json['orderId'] as String? ?? '', + title: json['clientName'] as String? ?? 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?, + latitude: Shift.parseDouble(json['latitude']), + longitude: Shift.parseDouble(json['longitude']), requiredWorkers: 0, assignedWorkers: 0, ); 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 f140b243..5681604c 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 @@ -79,13 +79,6 @@ class _ShiftDetails extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - isSelected ? i18n.selected_shift_badge : i18n.today_shift_badge, - style: UiTypography.titleUppercase4b.copyWith( - color: isSelected ? UiColors.primary : UiColors.textSecondary, - ), - ), - 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. From 730b298d49efd7226ea1c912f28a6019019ad9bd Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 12:17:31 -0400 Subject: [PATCH 12/25] fix: correct time comparison logic in clock-in validation --- .../src/domain/validators/validators/time_window_validator.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/time_window_validator.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/time_window_validator.dart index a38edd62..3710c228 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/time_window_validator.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/time_window_validator.dart @@ -39,7 +39,7 @@ class TimeWindowValidator implements ClockInValidator { const Duration(minutes: _earlyWindowMinutes), ); - if (DateTime.now().isBefore(windowStart)) { + if (windowStart.isBefore(DateTime.now())) { return const ClockInValidationResult.invalid('too_early_clock_in'); } From 020c785b6f3f06d189dc4add64ab087ee46aaff6 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 12:22:09 -0400 Subject: [PATCH 13/25] Cancel background tasks and clear feature scopes Ensure sign-out cleans up background work and feature state: import BackgroundTaskService and FeatureGate, invoke BackgroundTaskService.cancelAll() inside a try/catch in the sign-out finally block, and call FeatureGate.instance.clearScopes(). Existing client/staff session clearing and handleSignOut() are preserved. --- .../lib/src/services/session/v2_session_service.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 index 30b7ce94..639f6022 100644 --- 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 @@ -4,7 +4,9 @@ import 'package:krow_domain/krow_domain.dart'; import '../api_service/api_service.dart'; import '../api_service/endpoints/auth_endpoints.dart'; +import '../api_service/feature_gate.dart'; import '../api_service/mixins/session_handler_mixin.dart'; +import '../device/background_task/background_task_service.dart'; import 'client_session_store.dart'; import 'staff_session_store.dart'; @@ -110,8 +112,16 @@ class V2SessionService with SessionHandlerMixin { debugPrint('[V2SessionService] Error signing out: $e'); rethrow; } finally { + // Cancel all background tasks (geofence tracking, etc.). + try { + await const BackgroundTaskService().cancelAll(); + } catch (e) { + debugPrint('[V2SessionService] Failed to cancel background tasks: $e'); + } + StaffSessionStore.instance.clear(); ClientSessionStore.instance.clear(); + FeatureGate.instance.clearScopes(); handleSignOut(); } } From a0d5a18e6feae9e5713f08906e72bc866e820503 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 13:08:32 -0400 Subject: [PATCH 14/25] Refactor shift history and my shifts tabs to use a unified ShiftCard widget - Introduced ShiftCard widget to standardize the display of shift information across different states (assigned, completed, cancelled, pending). - Removed redundant card implementations (_CompletedShiftCard, MyShiftCard, ShiftAssignmentCard) and replaced them with ShiftCard. - Updated localization for empty states and shift titles in HistoryShiftsTab and MyShiftsTab. - Enhanced MyShiftsTab to track submitted shifts locally and show appropriate actions based on shift status. - Added meta package dependency for improved type annotations. --- .../src/entities/shifts/completed_shift.dart | 8 +- .../shifts_repository_impl.dart | 4 +- .../src/presentation/widgets/shift_card.dart | 775 ++++++++++++++++++ .../widgets/tabs/history_shifts_tab.dart | 104 +-- .../widgets/tabs/my_shifts_tab.dart | 359 ++++---- .../features/staff/shifts/pubspec.yaml | 1 + 6 files changed, 945 insertions(+), 306 deletions(-) create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card.dart diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart index 01b6f005..3d3e47e2 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart @@ -1,6 +1,5 @@ import 'package:equatable/equatable.dart'; - -import 'package:krow_domain/src/entities/enums/payment_status.dart'; +import 'package:krow_domain/krow_domain.dart'; /// A shift the staff member has completed. /// @@ -16,6 +15,7 @@ class CompletedShift extends Equatable { required this.date, required this.minutesWorked, required this.paymentStatus, + required this.status, }); /// Deserialises from the V2 API JSON response. @@ -28,6 +28,7 @@ class CompletedShift extends Equatable { date: DateTime.parse(json['date'] as String), minutesWorked: json['minutesWorked'] as int? ?? 0, paymentStatus: PaymentStatus.fromJson(json['paymentStatus'] as String?), + status: AssignmentStatus.completed, ); } @@ -52,6 +53,9 @@ class CompletedShift extends Equatable { /// Payment processing status. final PaymentStatus paymentStatus; + /// Assignment status (should always be `completed` for this class). + final AssignmentStatus status; + /// Serialises to JSON. Map toJson() { return { 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 8835d825..ee27ea03 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 @@ -96,10 +96,12 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { final ApiResponse response = await _apiService.get(StaffEndpoints.shiftsCompleted); final List items = _extractItems(response.data); - return items + var x = items .map((dynamic json) => CompletedShift.fromJson(json as Map)) .toList(); + + return x; } @override diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card.dart new file mode 100644 index 00000000..63b83e93 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card.dart @@ -0,0 +1,775 @@ +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'; + +/// Variant that controls the visual treatment of the [ShiftCard]. +/// +/// Each variant maps to a different colour scheme for the status badge and +/// optional footer action area. +enum ShiftCardVariant { + /// Confirmed / accepted assignment. + confirmed, + + /// Pending assignment awaiting acceptance. + pending, + + /// Cancelled assignment. + cancelled, + + /// Completed shift (history). + completed, + + /// Worker is currently checked in. + checkedIn, + + /// A swap has been requested. + swapRequested, +} + +/// Immutable data model that feeds the [ShiftCard]. +/// +/// Acts as an adapter between the various shift entity types +/// (`AssignedShift`, `CompletedShift`, `CancelledShift`, `PendingAssignment`) +/// and the unified card presentation. +class ShiftCardData { + /// Creates a [ShiftCardData]. + const ShiftCardData({ + required this.shiftId, + required this.title, + required this.location, + required this.date, + required this.variant, + this.subtitle, + this.startTime, + this.endTime, + this.hourlyRateCents, + this.orderType, + this.minutesWorked, + this.cancellationReason, + this.paymentStatus, + }); + + /// Constructs [ShiftCardData] from an [AssignedShift]. + factory ShiftCardData.fromAssigned(AssignedShift shift) { + return ShiftCardData( + shiftId: shift.shiftId, + title: shift.roleName, + subtitle: shift.location, + location: shift.location, + date: shift.date, + startTime: shift.startTime, + endTime: shift.endTime, + hourlyRateCents: shift.hourlyRateCents, + orderType: shift.orderType, + variant: _variantFromAssignmentStatus(shift.status), + ); + } + + /// Constructs [ShiftCardData] from a [CompletedShift]. + factory ShiftCardData.fromCompleted(CompletedShift shift) { + return ShiftCardData( + shiftId: shift.shiftId, + title: shift.title, + location: shift.location, + date: shift.date, + minutesWorked: shift.minutesWorked, + paymentStatus: shift.paymentStatus, + variant: ShiftCardVariant.completed, + ); + } + + /// Constructs [ShiftCardData] from a [CancelledShift]. + factory ShiftCardData.fromCancelled(CancelledShift shift) { + return ShiftCardData( + shiftId: shift.shiftId, + title: shift.title, + location: shift.location, + date: shift.date, + cancellationReason: shift.cancellationReason, + variant: ShiftCardVariant.cancelled, + ); + } + + /// Constructs [ShiftCardData] from a [PendingAssignment]. + factory ShiftCardData.fromPending(PendingAssignment assignment) { + return ShiftCardData( + shiftId: assignment.shiftId, + title: assignment.roleName, + subtitle: assignment.title.isNotEmpty ? assignment.title : null, + location: assignment.location, + date: assignment.startTime, + startTime: assignment.startTime, + endTime: assignment.endTime, + variant: ShiftCardVariant.pending, + ); + } + + /// The shift row id. + final String shiftId; + + /// Primary display title (role name or shift title). + final String title; + + /// Optional secondary text (e.g. location under the role name). + final String? subtitle; + + /// Human-readable location label. + final String location; + + /// The date of the shift. + final DateTime date; + + /// Scheduled start time (null for completed/cancelled). + final DateTime? startTime; + + /// Scheduled end time (null for completed/cancelled). + final DateTime? endTime; + + /// Hourly pay rate in cents (null when not applicable). + final int? hourlyRateCents; + + /// Order type (null for completed/cancelled). + final OrderType? orderType; + + /// Minutes worked (only for completed shifts). + final int? minutesWorked; + + /// Cancellation reason (only for cancelled shifts). + final String? cancellationReason; + + /// Payment processing status (only for completed shifts). + final PaymentStatus? paymentStatus; + + /// Visual variant for the card. + final ShiftCardVariant variant; + + static ShiftCardVariant _variantFromAssignmentStatus( + AssignmentStatus status, + ) { + switch (status) { + case AssignmentStatus.accepted: + return ShiftCardVariant.confirmed; + case AssignmentStatus.checkedIn: + return ShiftCardVariant.checkedIn; + case AssignmentStatus.swapRequested: + return ShiftCardVariant.swapRequested; + case AssignmentStatus.completed: + return ShiftCardVariant.completed; + case AssignmentStatus.cancelled: + return ShiftCardVariant.cancelled; + case AssignmentStatus.assigned: + return ShiftCardVariant.pending; + case AssignmentStatus.checkedOut: + case AssignmentStatus.noShow: + case AssignmentStatus.unknown: + return ShiftCardVariant.confirmed; + } + } +} + +/// Unified card widget for displaying shift information across all shift types. +/// +/// Replaces `MyShiftCard`, `ShiftAssignmentCard`, and the inline +/// `_CompletedShiftCard` / `_buildCancelledCard` from the tabs. Accepts a +/// [ShiftCardData] data model that adapts the various domain entities into a +/// common display shape. +class ShiftCard extends StatelessWidget { + /// Creates a [ShiftCard]. + const ShiftCard({ + super.key, + required this.data, + this.onTap, + this.onSubmitForApproval, + this.showApprovalAction = false, + this.isSubmitted = false, + this.onAccept, + this.onDecline, + this.isAccepting = false, + }); + + /// The shift data to display. + final ShiftCardData data; + + /// Callback when the card is tapped (typically navigates to shift details). + final VoidCallback? onTap; + + /// Callback when the "Submit for Approval" button is pressed. + final VoidCallback? onSubmitForApproval; + + /// Whether to show the submit-for-approval footer. + final bool showApprovalAction; + + /// Whether the timesheet has already been submitted. + final bool isSubmitted; + + /// Callback when the accept action is pressed (pending assignments only). + final VoidCallback? onAccept; + + /// Callback when the decline action is pressed (pending assignments only). + final VoidCallback? onDecline; + + /// Whether the accept action is in progress. + final bool isAccepting; + + /// Whether the accept/decline footer should be shown. + bool get _showPendingActions => onAccept != null || onDecline != null; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + boxShadow: _showPendingActions + ? [ + BoxShadow( + color: UiColors.black.withValues(alpha: 0.05), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ] + : null, + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _StatusBadge( + variant: data.variant, + orderType: data.orderType, + ), + const SizedBox(height: UiConstants.space2), + _CardBody(data: data), + if (showApprovalAction) ...[ + const SizedBox(height: UiConstants.space4), + const Divider(height: 1, color: UiColors.border), + const SizedBox(height: UiConstants.space2), + _ApprovalFooter( + isSubmitted: isSubmitted, + onSubmit: onSubmitForApproval, + ), + ], + ], + ), + ), + if (_showPendingActions) + _PendingActionsFooter( + onAccept: onAccept, + onDecline: onDecline, + isAccepting: isAccepting, + ), + ], + ), + ), + ); + } +} + +/// Displays the coloured status dot/icon and label, plus an optional order-type +/// chip. +class _StatusBadge extends StatelessWidget { + const _StatusBadge({required this.variant, this.orderType}); + + final ShiftCardVariant variant; + final OrderType? orderType; + + @override + Widget build(BuildContext context) { + final _StatusStyle style = _resolveStyle(context); + + return Row( + children: [ + if (style.icon != null) + Padding( + padding: const EdgeInsets.only(right: UiConstants.space2), + child: Icon( + style.icon, + size: UiConstants.iconXs, + color: style.foreground, + ), + ) + else + Container( + width: 8, + height: 8, + margin: const EdgeInsets.only(right: UiConstants.space2), + decoration: BoxDecoration( + color: style.dot, + shape: BoxShape.circle, + ), + ), + Text( + style.label, + style: UiTypography.footnote2b.copyWith( + color: style.foreground, + letterSpacing: 0.5, + ), + ), + if (orderType != null) ...[ + const SizedBox(width: UiConstants.space2), + _OrderTypeChip(orderType: orderType!), + ], + ], + ); + } + + _StatusStyle _resolveStyle(BuildContext context) { + switch (variant) { + case ShiftCardVariant.confirmed: + return _StatusStyle( + label: context.t.staff_shifts.status.confirmed, + foreground: UiColors.textLink, + dot: UiColors.primary, + ); + case ShiftCardVariant.pending: + return _StatusStyle( + label: context.t.staff_shifts.status.act_now, + foreground: UiColors.destructive, + dot: UiColors.destructive, + ); + case ShiftCardVariant.cancelled: + return _StatusStyle( + label: context.t.staff_shifts.my_shifts_tab.card.cancelled, + foreground: UiColors.destructive, + dot: UiColors.destructive, + ); + case ShiftCardVariant.completed: + return _StatusStyle( + label: context.t.staff_shifts.status.completed, + foreground: UiColors.textSuccess, + dot: UiColors.iconSuccess, + ); + case ShiftCardVariant.checkedIn: + return _StatusStyle( + label: context.t.staff_shifts.my_shift_card.checked_in, + foreground: UiColors.textSuccess, + dot: UiColors.iconSuccess, + ); + case ShiftCardVariant.swapRequested: + return _StatusStyle( + label: context.t.staff_shifts.status.swap_requested, + foreground: UiColors.textWarning, + dot: UiColors.textWarning, + icon: UiIcons.swap, + ); + } + } +} + +/// Internal helper grouping status badge presentation values. +class _StatusStyle { + const _StatusStyle({ + required this.label, + required this.foreground, + required this.dot, + this.icon, + }); + + final String label; + final Color foreground; + final Color dot; + final IconData? icon; +} + +/// Small chip showing the order type (One Day / Multi-Day / Long Term). +class _OrderTypeChip extends StatelessWidget { + const _OrderTypeChip({required this.orderType}); + + final OrderType orderType; + + @override + Widget build(BuildContext context) { + final String label = _label(context); + + return 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( + label, + style: UiTypography.footnote2m.copyWith(color: UiColors.textSecondary), + ), + ); + } + + String _label(BuildContext context) { + switch (orderType) { + case OrderType.permanent: + return context.t.staff_shifts.filter.long_term; + case OrderType.recurring: + return context.t.staff_shifts.filter.multi_day; + case OrderType.oneTime: + case OrderType.rapid: + case OrderType.unknown: + return context.t.staff_shifts.filter.one_day; + } + } +} + +/// The main body: icon, title/subtitle, metadata rows, and optional pay info. +class _CardBody extends StatelessWidget { + const _CardBody({required this.data}); + + final ShiftCardData data; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ShiftIcon(variant: data.variant), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _TitleRow(data: data), + if (data.subtitle != null) ...[ + Text( + data.subtitle!, + style: UiTypography.body3r.textSecondary, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: UiConstants.space2), + _MetadataRows(data: data), + if (data.cancellationReason != null && + data.cancellationReason!.isNotEmpty) ...[ + const SizedBox(height: UiConstants.space1), + Text( + data.cancellationReason!, + style: UiTypography.footnote2r.textSecondary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ); + } +} + +/// The 44x44 icon box with a gradient background. +class _ShiftIcon extends StatelessWidget { + const _ShiftIcon({required this.variant}); + + final ShiftCardVariant variant; + + @override + Widget build(BuildContext context) { + final bool isCancelled = variant == ShiftCardVariant.cancelled; + + return Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: isCancelled + ? null + : LinearGradient( + colors: [ + UiColors.primary.withValues(alpha: 0.09), + UiColors.primary.withValues(alpha: 0.03), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + color: isCancelled + ? UiColors.primary.withValues(alpha: 0.05) + : null, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: isCancelled + ? null + : Border.all( + color: UiColors.primary.withValues(alpha: 0.09), + ), + ), + child: const Center( + child: Icon( + UiIcons.briefcase, + color: UiColors.primary, + size: UiConstants.iconMd, + ), + ), + ); + } +} + +/// Title row with optional pay summary on the right. +class _TitleRow extends StatelessWidget { + const _TitleRow({required this.data}); + + final ShiftCardData data; + + @override + Widget build(BuildContext context) { + final bool hasPay = data.hourlyRateCents != null && + data.startTime != null && + data.endTime != null; + + if (!hasPay) { + return Text( + data.title, + style: UiTypography.body2m.textPrimary, + overflow: TextOverflow.ellipsis, + ); + } + + final double hourlyRate = data.hourlyRateCents! / 100; + final int durationMinutes = + data.endTime!.difference(data.startTime!).inMinutes; + double durationHours = durationMinutes / 60; + if (durationHours < 0) durationHours += 24; + durationHours = durationHours.roundToDouble(); + final double estimatedTotal = hourlyRate * durationHours; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + data.title, + style: UiTypography.body2m.textPrimary, + 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 ${durationHours.toInt()}h', + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), + ], + ); + } +} + +/// Date, time, location, and worked-hours rows. +class _MetadataRows extends StatelessWidget { + const _MetadataRows({required this.data}); + + final ShiftCardData data; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Date and time row + Row( + children: [ + const Icon( + UiIcons.calendar, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Text( + _formatDate(context, data.date), + style: UiTypography.footnote1r.textSecondary, + ), + if (data.startTime != null && data.endTime != null) ...[ + const SizedBox(width: UiConstants.space3), + const Icon( + UiIcons.clock, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Text( + '${_formatTime(data.startTime!)} - ${_formatTime(data.endTime!)}', + style: UiTypography.footnote1r.textSecondary, + ), + ], + if (data.minutesWorked != null) ...[ + const SizedBox(width: UiConstants.space3), + const Icon( + UiIcons.clock, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Text( + _formatWorkedDuration(data.minutesWorked!), + style: UiTypography.footnote1r.textSecondary, + ), + ], + ], + ), + const SizedBox(height: UiConstants.space1), + // Location row + Row( + children: [ + const Icon( + UiIcons.mapPin, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Expanded( + child: Text( + data.location, + style: UiTypography.footnote1r.textSecondary, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ); + } + + String _formatDate(BuildContext context, 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 context.t.staff_shifts.my_shifts_tab.date.today; + if (d == tomorrow) { + return context.t.staff_shifts.my_shifts_tab.date.tomorrow; + } + return DateFormat('EEE, MMM d').format(date); + } + + String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt); + + String _formatWorkedDuration(int totalMinutes) { + final int hours = totalMinutes ~/ 60; + final int mins = totalMinutes % 60; + return mins > 0 ? '${hours}h ${mins}m' : '${hours}h'; + } +} + +/// Footer showing the submit-for-approval action for completed shifts. +class _ApprovalFooter extends StatelessWidget { + const _ApprovalFooter({ + required this.isSubmitted, + this.onSubmit, + }); + + final bool isSubmitted; + final VoidCallback? onSubmit; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + isSubmitted + ? context.t.staff_shifts.my_shift_card.submitted + : context.t.staff_shifts.my_shift_card.ready_to_submit, + style: UiTypography.footnote2b.copyWith( + color: isSubmitted ? UiColors.textSuccess : UiColors.textSecondary, + ), + ), + if (!isSubmitted) + UiButton.secondary( + text: context.t.staff_shifts.my_shift_card.submit_for_approval, + size: UiButtonSize.small, + onPressed: onSubmit, + ) + else + const Icon( + UiIcons.success, + color: UiColors.iconSuccess, + size: 20, + ), + ], + ); + } +} + +/// Coloured footer with Decline / Accept buttons for pending assignments. +class _PendingActionsFooter extends StatelessWidget { + const _PendingActionsFooter({ + this.onAccept, + this.onDecline, + this.isAccepting = false, + }); + + final VoidCallback? onAccept; + final VoidCallback? onDecline; + final bool isAccepting; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space2), + decoration: const BoxDecoration( + color: UiColors.secondary, + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(UiConstants.radiusBase), + bottomRight: Radius.circular(UiConstants.radiusBase), + ), + ), + child: Row( + children: [ + Expanded( + child: TextButton( + onPressed: onDecline, + style: TextButton.styleFrom( + foregroundColor: UiColors.destructive, + ), + child: Text( + context.t.staff_shifts.action.decline, + style: UiTypography.body2m.textError, + ), + ), + ), + const SizedBox(width: UiConstants.space2), + Expanded( + child: ElevatedButton( + onPressed: isAccepting ? null : onAccept, + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.primary, + foregroundColor: UiColors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + UiConstants.radiusMdValue, + ), + ), + ), + child: isAccepting + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: UiColors.white, + ), + ) + : Text( + context.t.staff_shifts.action.confirm, + style: UiTypography.body2m.white, + ), + ), + ), + ], + ), + ); + } +} 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 639cc291..0ea3b6a6 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,11 +1,12 @@ +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:design_system/design_system.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_card.dart'; /// Tab displaying completed shift history. class HistoryShiftsTab extends StatelessWidget { @@ -18,10 +19,10 @@ class HistoryShiftsTab extends StatelessWidget { @override Widget build(BuildContext context) { if (historyShifts.isEmpty) { - return const EmptyStateView( + return EmptyStateView( icon: UiIcons.clock, - title: 'No shift history', - subtitle: 'Completed shifts appear here', + title: context.t.staff_shifts.list.no_shifts, + subtitle: context.t.staff_shifts.history_tab.subtitle, ); } @@ -33,9 +34,10 @@ class HistoryShiftsTab extends StatelessWidget { ...historyShifts.map( (CompletedShift shift) => Padding( padding: const EdgeInsets.only(bottom: UiConstants.space3), - child: GestureDetector( - onTap: () => Modular.to.toShiftDetailsById(shift.shiftId), - child: _CompletedShiftCard(shift: shift), + child: ShiftCard( + data: ShiftCardData.fromCompleted(shift), + onTap: () => + Modular.to.toShiftDetailsById(shift.shiftId), ), ), ), @@ -45,89 +47,3 @@ 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 3914292c..c4c52421 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 @@ -1,14 +1,15 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:intl/intl.dart'; -import 'package:design_system/design_system.dart'; 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' show ReadContext; +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_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'; +import 'package:staff_shifts/src/presentation/widgets/shift_card.dart'; /// Tab displaying the worker's assigned, pending, and cancelled shifts. class MyShiftsTab extends StatefulWidget { @@ -41,6 +42,9 @@ class _MyShiftsTabState extends State { DateTime _selectedDate = DateTime.now(); int _weekOffset = 0; + /// Tracks which completed-shift cards have been submitted locally. + final Set _submittedShiftIds = {}; + @override void initState() { super.initState(); @@ -64,19 +68,19 @@ class _MyShiftsTabState extends State { void _applyInitialDate(DateTime date) { _selectedDate = date; - final now = DateTime.now(); - int reactDayIndex = now.weekday == 7 ? 0 : now.weekday; - int daysSinceFriday = (reactDayIndex + 2) % 7; + final DateTime now = DateTime.now(); + final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday; + final int daysSinceFriday = (reactDayIndex + 2) % 7; // Base Friday - final baseStart = DateTime( + final DateTime baseStart = DateTime( now.year, now.month, now.day, ).subtract(Duration(days: daysSinceFriday)); - final target = DateTime(date.year, date.month, date.day); - final diff = target.difference(baseStart).inDays; + final DateTime target = DateTime(date.year, date.month, date.day); + final int diff = target.difference(baseStart).inDays; setState(() { _weekOffset = (diff / 7).floor(); @@ -87,19 +91,23 @@ class _MyShiftsTabState extends State { } List _getCalendarDays() { - final now = DateTime.now(); - int reactDayIndex = now.weekday == 7 ? 0 : now.weekday; - int daysSinceFriday = (reactDayIndex + 2) % 7; - final start = now + 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 startDate = DateTime(start.year, start.month, start.day); - return List.generate(7, (index) => startDate.add(Duration(days: index))); + final DateTime startDate = + DateTime(start.year, start.month, start.day); + return List.generate( + 7, + (int index) => startDate.add(Duration(days: index)), + ); } void _loadShiftsForCurrentWeek() { final List calendarDays = _getCalendarDays(); - context.read().add( + ReadContext(context).read().add( LoadShiftsForRangeEvent( start: calendarDays.first, end: calendarDays.last, @@ -114,10 +122,12 @@ class _MyShiftsTabState extends State { void _confirmShift(String id) { showDialog( context: context, - builder: (ctx) => AlertDialog( - title: Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.title), - content: Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.message), - actions: [ + builder: (BuildContext ctx) => AlertDialog( + title: + Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.title), + content: + Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.message), + actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(), child: Text(context.t.common.cancel), @@ -125,17 +135,19 @@ class _MyShiftsTabState extends State { TextButton( onPressed: () { Navigator.of(ctx).pop(); - context.read().add(AcceptShiftEvent(id)); + ReadContext(context).read().add(AcceptShiftEvent(id)); UiSnackbar.show( context, - message: context.t.staff_shifts.my_shifts_tab.confirm_dialog.success, + message: context + .t.staff_shifts.my_shifts_tab.confirm_dialog.success, type: UiSnackbarType.success, ); }, style: TextButton.styleFrom( foregroundColor: UiColors.success, ), - child: Text(context.t.staff_shifts.shift_details.accept_shift), + child: + Text(context.t.staff_shifts.shift_details.accept_shift), ), ], ), @@ -145,12 +157,13 @@ class _MyShiftsTabState extends State { void _declineShift(String id) { showDialog( context: context, - builder: (ctx) => AlertDialog( - title: Text(context.t.staff_shifts.my_shifts_tab.decline_dialog.title), + builder: (BuildContext ctx) => AlertDialog( + title: + Text(context.t.staff_shifts.my_shifts_tab.decline_dialog.title), content: Text( context.t.staff_shifts.my_shifts_tab.decline_dialog.message, ), - actions: [ + actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(), child: Text(context.t.common.cancel), @@ -158,10 +171,11 @@ class _MyShiftsTabState extends State { TextButton( onPressed: () { Navigator.of(ctx).pop(); - context.read().add(DeclineShiftEvent(id)); + ReadContext(context).read().add(DeclineShiftEvent(id)); UiSnackbar.show( context, - message: context.t.staff_shifts.my_shifts_tab.decline_dialog.success, + message: context + .t.staff_shifts.my_shifts_tab.decline_dialog.success, type: UiSnackbarType.error, ); }, @@ -175,27 +189,17 @@ class _MyShiftsTabState extends State { ); } - 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 Widget build(BuildContext context) { - final calendarDays = _getCalendarDays(); - final weekStartDate = calendarDays.first; - final weekEndDate = calendarDays.last; + final List calendarDays = _getCalendarDays(); + final DateTime weekStartDate = calendarDays.first; + final DateTime weekEndDate = calendarDays.last; - final List visibleMyShifts = widget.myShifts.where( - (AssignedShift s) => _isSameDay(s.date, _selectedDate), - ).toList(); + final List visibleMyShifts = widget.myShifts + .where( + (AssignedShift s) => _isSameDay(s.date, _selectedDate), + ) + .toList(); final List visibleCancelledShifts = widget.cancelledShifts.where((CancelledShift s) { @@ -205,7 +209,7 @@ class _MyShiftsTabState extends State { }).toList(); return Column( - children: [ + children: [ // Calendar Selector Container( color: UiColors.white, @@ -214,12 +218,12 @@ class _MyShiftsTabState extends State { horizontal: UiConstants.space4, ), child: Column( - children: [ + children: [ Padding( padding: const EdgeInsets.only(bottom: UiConstants.space3), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + children: [ IconButton( icon: const Icon( UiIcons.chevronLeft, @@ -258,10 +262,8 @@ class _MyShiftsTabState extends State { // Days Grid Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: calendarDays.map((date) { - final isSelected = _isSameDay(date, _selectedDate); - // ignore: unused_local_variable - final dateStr = DateFormat('yyyy-MM-dd').format(date); + children: calendarDays.map((DateTime date) { + final bool isSelected = _isSameDay(date, _selectedDate); final bool hasShifts = widget.myShifts.any( (AssignedShift s) => _isSameDay(s.date, date), ); @@ -269,7 +271,7 @@ class _MyShiftsTabState extends State { return GestureDetector( onTap: () => setState(() => _selectedDate = date), child: Column( - children: [ + children: [ Container( width: 44, height: 60, @@ -277,7 +279,9 @@ class _MyShiftsTabState extends State { color: isSelected ? UiColors.primary : UiColors.white, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), border: Border.all( color: isSelected ? UiColors.primary @@ -287,7 +291,7 @@ class _MyShiftsTabState extends State { ), child: Column( mainAxisAlignment: MainAxisAlignment.center, - children: [ + children: [ Text( date.day.toString().padLeft(2, '0'), style: isSelected @@ -297,14 +301,21 @@ class _MyShiftsTabState extends State { Text( DateFormat('E').format(date), style: (isSelected - ? UiTypography.footnote2m.white - : UiTypography.footnote2m.textSecondary).copyWith( - color: isSelected ? UiColors.white.withValues(alpha: 0.8) : null, + ? UiTypography.footnote2m.white + : UiTypography + .footnote2m.textSecondary) + .copyWith( + color: isSelected + ? UiColors.white + .withValues(alpha: 0.8) + : null, ), ), if (hasShifts && !isSelected) Container( - margin: const EdgeInsets.only(top: UiConstants.space1), + margin: const EdgeInsets.only( + top: UiConstants.space1, + ), width: 4, height: 4, decoration: const BoxDecoration( @@ -327,40 +338,52 @@ class _MyShiftsTabState extends State { Expanded( child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), child: Column( - children: [ + children: [ const SizedBox(height: UiConstants.space5), - if (widget.pendingAssignments.isNotEmpty) ...[ + if (widget.pendingAssignments.isNotEmpty) ...[ _buildSectionHeader( - context.t.staff_shifts.my_shifts_tab.sections.awaiting, + context + .t.staff_shifts.my_shifts_tab.sections.awaiting, UiColors.textWarning, ), ...widget.pendingAssignments.map( (PendingAssignment assignment) => Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space4), - child: ShiftAssignmentCard( - assignment: assignment, - onConfirm: () => _confirmShift(assignment.shiftId), - onDecline: () => _declineShift(assignment.shiftId), - isConfirming: true, + padding: const EdgeInsets.only( + bottom: UiConstants.space4, + ), + child: ShiftCard( + data: ShiftCardData.fromPending(assignment), + onTap: () => Modular.to + .toShiftDetailsById(assignment.shiftId), + onAccept: () => + _confirmShift(assignment.shiftId), + onDecline: () => + _declineShift(assignment.shiftId), ), ), ), const SizedBox(height: UiConstants.space3), ], - if (visibleCancelledShifts.isNotEmpty) ...[ - _buildSectionHeader(context.t.staff_shifts.my_shifts_tab.sections.cancelled, UiColors.textSecondary), + if (visibleCancelledShifts.isNotEmpty) ...[ + _buildSectionHeader( + context + .t.staff_shifts.my_shifts_tab.sections.cancelled, + UiColors.textSecondary, + ), ...visibleCancelledShifts.map( (CancelledShift cs) => Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space4), - child: _buildCancelledCard( - title: cs.title, - location: cs.location, - date: DateFormat('EEE, MMM d').format(cs.date), - reason: cs.cancellationReason, - onTap: () {}, + padding: const EdgeInsets.only( + bottom: UiConstants.space4, + ), + child: ShiftCard( + data: ShiftCardData.fromCancelled(cs), + onTap: () => + Modular.to.toShiftDetailsById(cs.shiftId), ), ), ), @@ -368,23 +391,43 @@ class _MyShiftsTabState extends State { ], // Confirmed Shifts - if (visibleMyShifts.isNotEmpty) ...[ - _buildSectionHeader(context.t.staff_shifts.my_shifts_tab.sections.confirmed, UiColors.textSecondary), + if (visibleMyShifts.isNotEmpty) ...[ + _buildSectionHeader( + context + .t.staff_shifts.my_shifts_tab.sections.confirmed, + UiColors.textSecondary, + ), ...visibleMyShifts.map( - (AssignedShift shift) => Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space3), - child: MyShiftCard( - shift: shift, - onDecline: () => _declineShift(shift.shiftId), - onRequestSwap: () { - UiSnackbar.show( - context, - message: context.t.staff_shifts.my_shifts_tab.swap_coming_soon, - type: UiSnackbarType.message, - ); - }, - ), - ), + (AssignedShift shift) { + final bool isCompleted = + shift.status == AssignmentStatus.completed; + final bool isSubmitted = + _submittedShiftIds.contains(shift.shiftId); + + return Padding( + padding: const EdgeInsets.only( + bottom: UiConstants.space3, + ), + child: ShiftCard( + data: ShiftCardData.fromAssigned(shift), + onTap: () => Modular.to + .toShiftDetailsById(shift.shiftId), + showApprovalAction: isCompleted, + isSubmitted: isSubmitted, + onSubmitForApproval: () { + setState(() { + _submittedShiftIds.add(shift.shiftId); + }); + UiSnackbar.show( + context, + message: context.t.staff_shifts + .my_shift_card.timesheet_submitted, + type: UiSnackbarType.success, + ); + }, + ), + ); + }, ), ], @@ -393,8 +436,10 @@ class _MyShiftsTabState extends State { widget.cancelledShifts.isEmpty) EmptyStateView( icon: UiIcons.calendar, - title: context.t.staff_shifts.my_shifts_tab.empty.title, - subtitle: context.t.staff_shifts.my_shifts_tab.empty.subtitle, + title: + context.t.staff_shifts.my_shifts_tab.empty.title, + subtitle: context + .t.staff_shifts.my_shifts_tab.empty.subtitle, ), const SizedBox(height: UiConstants.space32), @@ -410,11 +455,14 @@ class _MyShiftsTabState extends State { return Padding( padding: const EdgeInsets.only(bottom: UiConstants.space4), child: Row( - children: [ + children: [ Container( width: 8, height: 8, - decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle), + decoration: BoxDecoration( + color: dotColor, + shape: BoxShape.circle, + ), ), const SizedBox(width: UiConstants.space2), Text( @@ -427,111 +475,4 @@ class _MyShiftsTabState extends State { ), ); } - - Widget _buildCancelledCard({ - required String title, - required String location, - required String date, - String? reason, - required VoidCallback onTap, - }) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(UiConstants.radiusBase + 4), - border: Border.all(color: UiColors.border), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 6, - height: 6, - decoration: const BoxDecoration( - color: UiColors.destructive, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 6), - Text( - context.t.staff_shifts.my_shifts_tab.card.cancelled, - style: UiTypography.footnote2b.textError, - ), - ], - ), - const SizedBox(height: UiConstants.space3), - 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: 20, - ), - ), - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: UiTypography.body2b.textPrimary), - const SizedBox(height: UiConstants.space2), - Row( - children: [ - const Icon(UiIcons.calendar, - size: 12, color: UiColors.textSecondary), - const SizedBox(width: 4), - Text(date, - style: UiTypography.footnote1r.textSecondary), - ], - ), - const SizedBox(height: 4), - Row( - children: [ - const Icon(UiIcons.mapPin, - size: 12, color: UiColors.textSecondary), - const SizedBox(width: 4), - Expanded( - child: Text( - 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/pubspec.yaml b/apps/mobile/packages/features/staff/shifts/pubspec.yaml index 3d50a8ad..a05c568e 100644 --- a/apps/mobile/packages/features/staff/shifts/pubspec.yaml +++ b/apps/mobile/packages/features/staff/shifts/pubspec.yaml @@ -28,6 +28,7 @@ dependencies: intl: ^0.20.2 url_launcher: ^6.3.1 bloc: ^8.1.4 + meta: ^1.17.0 dev_dependencies: flutter_test: From e83b8fff1c09bc408d53f5b42d87b30ffa441775 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 13:20:04 -0400 Subject: [PATCH 15/25] refactor: update color definitions and improve UI component structure; handle null safety in repository data fetching --- .../design_system/lib/src/ui_colors.dart | 2 +- .../design_system/lib/src/ui_typography.dart | 1 - .../lib/src/widgets/ui_notice_banner.dart | 62 ++++++++++--------- .../certificates_repository_impl.dart | 2 +- .../documents_repository_impl.dart | 2 +- .../tax_forms_repository_impl.dart | 2 +- .../bank_account_repository_impl.dart | 2 +- .../time_card_repository_impl.dart | 2 +- .../attire_repository_impl.dart | 2 +- .../emergency_contact_repository_impl.dart | 2 +- .../experience_repository_impl.dart | 4 +- .../faqs_repository_impl.dart | 6 +- .../faqs/lib/src/staff_faqs_module.dart | 3 +- 13 files changed, 49 insertions(+), 43 deletions(-) diff --git a/apps/mobile/packages/design_system/lib/src/ui_colors.dart b/apps/mobile/packages/design_system/lib/src/ui_colors.dart index 1613e791..b5407ef3 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_colors.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_colors.dart @@ -245,7 +245,7 @@ class UiColors { static const Color buttonPrimaryStill = primary; /// Primary button hover (#082EB2) - static const Color buttonPrimaryHover = Color(0xFF082EB2); + static const Color buttonPrimaryHover = Color.fromARGB(255, 8, 46, 178); /// Primary button inactive (#F1F3F5) static const Color buttonPrimaryInactive = secondary; diff --git a/apps/mobile/packages/design_system/lib/src/ui_typography.dart b/apps/mobile/packages/design_system/lib/src/ui_typography.dart index 2293ecd8..37b7c0b9 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_typography.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_typography.dart @@ -368,7 +368,6 @@ class UiTypography { fontWeight: FontWeight.w400, fontSize: 12, height: 1.5, - letterSpacing: -0.1, color: UiColors.textPrimary, ); diff --git a/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart b/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart index 478f0c91..878b34ea 100644 --- a/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart +++ b/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart @@ -67,39 +67,45 @@ class UiNoticeBanner extends StatelessWidget { color: backgroundColor ?? UiColors.primary.withValues(alpha: 0.08), borderRadius: borderRadius ?? UiConstants.radiusLg, ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + child: Column( children: [ - if (leading != null) ...[ - leading!, - const SizedBox(width: UiConstants.space3), - ] else if (icon != null) ...[ - Icon(icon, color: iconColor ?? UiColors.primary, size: 24), - const SizedBox(width: UiConstants.space3), - ], - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (leading != null) ...[ + leading!, + const SizedBox(width: UiConstants.space3), + ] else if (icon != null) ...[ + Icon(icon, color: iconColor ?? UiColors.primary, size: 24), + const SizedBox(width: UiConstants.space3), Text( title, - style: UiTypography.body2b.copyWith(color: titleColor), - ), - if (description != null) ...[ - const SizedBox(height: 2), - Text( - description!, - style: UiTypography.body3r.copyWith( - color: descriptionColor, - ), + style: UiTypography.body2b.copyWith( + color: titleColor ?? UiColors.primary, ), - ], - if (action != null) ...[ - const SizedBox(height: UiConstants.space2), - action!, - ], + ), ], - ), + ], + ), + + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (description != null) ...[ + const SizedBox(height: UiConstants.space2), + Text( + description!, + style: UiTypography.body3r.copyWith( + //color: descriptionColor ?? UiColors.textSecondary, + color: descriptionColor ?? UiColors.primary, + ), + ), + ], + if (action != null) ...[ + const SizedBox(height: UiConstants.space2), + action!, + ], + ], ), ], ), 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 b1762a77..60b45251 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 @@ -29,7 +29,7 @@ class CertificatesRepositoryImpl implements CertificatesRepository { final ApiResponse response = await _api.get(StaffEndpoints.certificates); final List items = - response.data['certificates'] as List; + response.data['certificates'] as List? ?? []; return items .map((dynamic json) => StaffCertificate.fromJson(json as Map)) 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 01505378..56ade1eb 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 @@ -28,7 +28,7 @@ class DocumentsRepositoryImpl implements DocumentsRepository { Future> getDocuments() async { final ApiResponse response = await _api.get(StaffEndpoints.documents); - final List items = response.data['documents'] as List; + final List items = response.data['documents'] as List? ?? []; return items .map((dynamic json) => ProfileDocument.fromJson(json as Map)) 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 d3d36e39..6145e964 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 @@ -19,7 +19,7 @@ class TaxFormsRepositoryImpl implements TaxFormsRepository { Future> getTaxForms() async { final ApiResponse response = await _api.get(StaffEndpoints.taxForms); - final List items = response.data['taxForms'] as List; + final List items = response.data['taxForms'] as List? ?? []; return items .map((dynamic json) => TaxForm.fromJson(json as Map)) 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 48058367..bc387187 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 @@ -17,7 +17,7 @@ class BankAccountRepositoryImpl implements BankAccountRepository { Future> getAccounts() async { final ApiResponse response = await _api.get(StaffEndpoints.bankAccounts); - final List items = response.data['accounts'] as List; + final List items = response.data['accounts'] as List? ?? []; return items .map((dynamic json) => BankAccount.fromJson(json as Map)) 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 f72d0803..cd103430 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 @@ -22,7 +22,7 @@ class TimeCardRepositoryImpl implements TimeCardRepository { 'month': month.month, }, ); - final List items = response.data['entries'] as List; + final List items = response.data['entries'] as List? ?? []; return items .map((dynamic json) => TimeCardEntry.fromJson(json as Map)) 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 5c9b9369..43d271d4 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 @@ -28,7 +28,7 @@ class AttireRepositoryImpl implements AttireRepository { @override Future> getAttireOptions() async { final ApiResponse response = await _api.get(StaffEndpoints.attire); - final List items = response.data['items'] as List; + final List items = response.data['items'] as List? ?? []; return items .map((dynamic json) => AttireChecklist.fromJson(json as Map)) 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 52be33d2..afa3430f 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 @@ -18,7 +18,7 @@ class EmergencyContactRepositoryImpl Future> getContacts() async { final ApiResponse response = await _api.get(StaffEndpoints.emergencyContacts); - final List items = response.data['contacts'] as List; + final List items = response.data['contacts'] as List? ?? []; return items .map((dynamic json) => EmergencyContact.fromJson(json as Map)) 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 28ae1150..0684e2a5 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 @@ -17,14 +17,14 @@ class ExperienceRepositoryImpl implements ExperienceRepositoryInterface { Future> getIndustries() async { final ApiResponse response = await _api.get(StaffEndpoints.industries); - final List items = response.data['industries'] as List; + final List items = response.data['industries'] as List? ?? []; return items.map((dynamic e) => e.toString()).toList(); } @override Future> getSkills() async { final ApiResponse response = await _api.get(StaffEndpoints.skills); - final List items = response.data['skills'] as List; + final List items = response.data['skills'] as List? ?? []; return items.map((dynamic e) => e.toString()).toList(); } 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 7e6fea1a..48fd3b11 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 @@ -9,10 +9,10 @@ import 'package:staff_faqs/src/domain/repositories/faqs_repository_interface.dar /// Fetches FAQ data from the V2 REST backend via [ApiService]. class FaqsRepositoryImpl implements FaqsRepositoryInterface { /// Creates a [FaqsRepositoryImpl] backed by the given [apiService]. - FaqsRepositoryImpl({required ApiService apiService}) + FaqsRepositoryImpl({required BaseApiService apiService}) : _apiService = apiService; - final ApiService _apiService; + final BaseApiService _apiService; @override Future> getFaqs() async { @@ -40,7 +40,7 @@ class FaqsRepositoryImpl implements FaqsRepositoryInterface { /// Parses the `items` array from a V2 API response into [FaqCategory] list. List _parseCategories(ApiResponse response) { - final List items = response.data['items'] as List; + final List items = response.data['items'] as List? ?? []; return items .map( (dynamic item) => 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 f3da2ab6..4765c74c 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,5 +1,6 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart' show BaseApiService; import 'package:staff_faqs/src/data/repositories_impl/faqs_repository_impl.dart'; import 'package:staff_faqs/src/domain/repositories/faqs_repository_interface.dart'; @@ -21,7 +22,7 @@ class FaqsModule extends Module { // Repository i.addLazySingleton( () => FaqsRepositoryImpl( - apiService: i(), + apiService: i.get(), ), ); From e1d30c124b73feb8c5462f6afb8e94bb18fecdb9 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 13:44:48 -0400 Subject: [PATCH 16/25] feat: migrate experience management to V2 API; add support for industries and skills --- .../endpoints/staff_endpoints.dart | 4 + .../lib/src/l10n/en.i18n.json | 4 + .../lib/src/l10n/es.i18n.json | 4 + .../packages/domain/lib/krow_domain.dart | 2 + .../src/entities/enums/staff_industry.dart | 48 ++++ .../lib/src/entities/enums/staff_skill.dart | 69 +++++ .../experience_repository_impl.dart | 52 +++- .../arguments/save_experience_arguments.dart | 18 +- .../experience_repository_interface.dart | 14 +- .../get_staff_industries_usecase.dart | 12 +- .../usecases/get_staff_skills_usecase.dart | 13 +- .../usecases/save_experience_usecase.dart | 9 +- .../presentation/blocs/experience_bloc.dart | 168 +++-------- .../presentation/blocs/experience_event.dart | 53 ++++ .../presentation/blocs/experience_state.dart | 77 +++++ .../presentation/pages/experience_page.dart | 270 ++++++++---------- 16 files changed, 503 insertions(+), 314 deletions(-) create mode 100644 apps/mobile/packages/domain/lib/src/entities/enums/staff_industry.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/enums/staff_skill.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_event.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_state.dart diff --git a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart index 878c3708..c98c780e 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart @@ -75,6 +75,10 @@ abstract final class StaffEndpoints { /// Skills. static const ApiEndpoint skills = ApiEndpoint('/staff/profile/skills'); + /// Save/update experience (industries + skills). + static const ApiEndpoint experience = + ApiEndpoint('/staff/profile/experience'); + /// Documents. static const ApiEndpoint documents = ApiEndpoint('/staff/profile/documents'); 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 1b6e1532..a7a83a54 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 @@ -823,6 +823,8 @@ "custom_skills_title": "Custom Skills:", "custom_skill_hint": "Add custom skill...", "save_button": "Save & Continue", + "save_success": "Experience saved successfully", + "save_error": "An error occurred", "industries": { "hospitality": "Hospitality", "food_service": "Food Service", @@ -830,6 +832,8 @@ "events": "Events", "retail": "Retail", "healthcare": "Healthcare", + "catering": "Catering", + "cafe": "Cafe", "other": "Other" }, "skills": { 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 dc509e86..22d14d50 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 @@ -818,6 +818,8 @@ "custom_skills_title": "Habilidades personalizadas:", "custom_skill_hint": "A\u00f1adir habilidad...", "save_button": "Guardar y continuar", + "save_success": "Experiencia guardada exitosamente", + "save_error": "Ocurrió un error", "industries": { "hospitality": "Hoteler\u00eda", "food_service": "Servicio de alimentos", @@ -825,6 +827,8 @@ "events": "Eventos", "retail": "Venta al por menor", "healthcare": "Cuidado de la salud", + "catering": "Catering", + "cafe": "Cafetería", "other": "Otro" }, "skills": { diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index d848a73b..37569eec 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -19,6 +19,8 @@ export 'src/entities/enums/onboarding_status.dart'; export 'src/entities/enums/order_type.dart'; export 'src/entities/enums/payment_status.dart'; export 'src/entities/enums/shift_status.dart'; +export 'src/entities/enums/staff_industry.dart'; +export 'src/entities/enums/staff_skill.dart'; export 'src/entities/enums/staff_status.dart'; export 'src/entities/enums/user_role.dart'; diff --git a/apps/mobile/packages/domain/lib/src/entities/enums/staff_industry.dart b/apps/mobile/packages/domain/lib/src/entities/enums/staff_industry.dart new file mode 100644 index 00000000..74f964c1 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/enums/staff_industry.dart @@ -0,0 +1,48 @@ +/// Industry options for staff experience profiles. +/// +/// Values match the V2 API format (UPPER_SNAKE_CASE). +enum StaffIndustry { + /// Hospitality industry. + hospitality('HOSPITALITY'), + + /// Food service industry. + foodService('FOOD_SERVICE'), + + /// Warehouse / logistics industry. + warehouse('WAREHOUSE'), + + /// Events industry. + events('EVENTS'), + + /// Retail industry. + retail('RETAIL'), + + /// Healthcare industry. + healthcare('HEALTHCARE'), + + /// Catering industry. + catering('CATERING'), + + /// Cafe / coffee shop industry. + cafe('CAFE'), + + /// Other / unspecified industry. + other('OTHER'); + + const StaffIndustry(this.value); + + /// The V2 API string representation. + final String value; + + /// Deserialises from a V2 API string with safe fallback. + static StaffIndustry? fromJson(String? value) { + if (value == null) return null; + for (final StaffIndustry industry in StaffIndustry.values) { + if (industry.value == value) return industry; + } + return null; + } + + /// Serialises to the V2 API string. + String toJson() => value; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/enums/staff_skill.dart b/apps/mobile/packages/domain/lib/src/entities/enums/staff_skill.dart new file mode 100644 index 00000000..9f36e276 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/enums/staff_skill.dart @@ -0,0 +1,69 @@ +/// Skill options for staff experience profiles. +/// +/// Values match the V2 API format (UPPER_SNAKE_CASE). +enum StaffSkill { + /// Food service skill. + foodService('FOOD_SERVICE'), + + /// Bartending skill. + bartending('BARTENDING'), + + /// Event setup skill. + eventSetup('EVENT_SETUP'), + + /// Hospitality skill. + hospitality('HOSPITALITY'), + + /// Warehouse skill. + warehouse('WAREHOUSE'), + + /// Customer service skill. + customerService('CUSTOMER_SERVICE'), + + /// Cleaning skill. + cleaning('CLEANING'), + + /// Security skill. + security('SECURITY'), + + /// Retail skill. + retail('RETAIL'), + + /// Driving skill. + driving('DRIVING'), + + /// Cooking skill. + cooking('COOKING'), + + /// Cashier skill. + cashier('CASHIER'), + + /// Server skill. + server('SERVER'), + + /// Barista skill. + barista('BARISTA'), + + /// Host / hostess skill. + hostHostess('HOST_HOSTESS'), + + /// Busser skill. + busser('BUSSER'); + + const StaffSkill(this.value); + + /// The V2 API string representation. + final String value; + + /// Deserialises from a V2 API string with safe fallback. + static StaffSkill? fromJson(String? value) { + if (value == null) return null; + for (final StaffSkill skill in StaffSkill.values) { + if (skill.value == value) return skill; + } + return null; + } + + /// Serialises to the V2 API string. + String toJson() => value; +} 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 0684e2a5..a532ad73 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 @@ -4,8 +4,6 @@ import 'package:krow_domain/krow_domain.dart'; import 'package:staff_profile_experience/src/domain/repositories/experience_repository_interface.dart'; /// Implementation of [ExperienceRepositoryInterface] using the V2 API. -/// -/// Replaces the previous Firebase Data Connect implementation. class ExperienceRepositoryImpl implements ExperienceRepositoryInterface { /// Creates an [ExperienceRepositoryImpl]. ExperienceRepositoryImpl({required BaseApiService apiService}) @@ -14,30 +12,54 @@ class ExperienceRepositoryImpl implements ExperienceRepositoryInterface { final BaseApiService _api; @override - Future> getIndustries() async { - final ApiResponse response = - await _api.get(StaffEndpoints.industries); - final List items = response.data['industries'] as List? ?? []; - return items.map((dynamic e) => e.toString()).toList(); + Future> getIndustries() async { + final ApiResponse response = await _api.get(StaffEndpoints.industries); + final List items = + response.data['items'] as List? ?? []; + return items + .map((dynamic e) => StaffIndustry.fromJson(e.toString())) + .whereType() + .toList(); } @override - Future> getSkills() async { + Future<({List skills, List customSkills})> + getSkills() async { final ApiResponse response = await _api.get(StaffEndpoints.skills); - final List items = response.data['skills'] as List? ?? []; - return items.map((dynamic e) => e.toString()).toList(); + final List items = + response.data['items'] as List? ?? []; + + final List skills = []; + final List customSkills = []; + + for (final dynamic item in items) { + final String value = item.toString(); + final StaffSkill? parsed = StaffSkill.fromJson(value); + if (parsed != null) { + skills.add(parsed); + } else { + customSkills.add(value); + } + } + + return (skills: skills, customSkills: customSkills); } @override Future saveExperience( - List industries, - List skills, + List industries, + List skills, + List customSkills, ) async { await _api.put( - StaffEndpoints.personalInfo, + StaffEndpoints.experience, data: { - 'industries': industries, - 'skills': skills, + 'industries': + industries.map((StaffIndustry i) => i.value).toList(), + 'skills': [ + ...skills.map((StaffSkill s) => s.value), + ...customSkills, + ], }, ); } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/arguments/save_experience_arguments.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/arguments/save_experience_arguments.dart index aa3385b9..1adc1703 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/arguments/save_experience_arguments.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/arguments/save_experience_arguments.dart @@ -1,14 +1,24 @@ import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart' show StaffIndustry, StaffSkill; +/// Arguments for the [SaveExperienceUseCase]. class SaveExperienceArguments extends UseCaseArgument { - final List industries; - final List skills; - + /// Creates a [SaveExperienceArguments]. const SaveExperienceArguments({ required this.industries, required this.skills, + this.customSkills = const [], }); + /// Selected industries. + final List industries; + + /// Selected predefined skills. + final List skills; + + /// Custom skills not in the [StaffSkill] enum. + final List customSkills; + @override - List get props => [industries, skills]; + List get props => [industries, skills, customSkills]; } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/repositories/experience_repository_interface.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/repositories/experience_repository_interface.dart index 8ddff2ef..3900ec1e 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/repositories/experience_repository_interface.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/repositories/experience_repository_interface.dart @@ -1,14 +1,20 @@ +import 'package:krow_domain/krow_domain.dart' show StaffIndustry, StaffSkill; + /// Interface for accessing staff experience data. abstract class ExperienceRepositoryInterface { /// Fetches the list of industries associated with the staff member. - Future> getIndustries(); + Future> getIndustries(); /// Fetches the list of skills associated with the staff member. - Future> getSkills(); + /// + /// Returns recognised [StaffSkill] values. Unrecognised API values are + /// returned in [customSkills]. + Future<({List skills, List customSkills})> getSkills(); /// Saves the staff member's experience (industries and skills). Future saveExperience( - List industries, - List skills, + List industries, + List skills, + List customSkills, ); } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/get_staff_industries_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/get_staff_industries_usecase.dart index 6094247c..e28192d9 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/get_staff_industries_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/get_staff_industries_usecase.dart @@ -1,14 +1,18 @@ import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart' show StaffIndustry; + import '../repositories/experience_repository_interface.dart'; /// Use case for fetching staff industries. -class GetStaffIndustriesUseCase implements NoInputUseCase> { - final ExperienceRepositoryInterface _repository; - +class GetStaffIndustriesUseCase + implements NoInputUseCase> { + /// Creates a [GetStaffIndustriesUseCase]. GetStaffIndustriesUseCase(this._repository); + final ExperienceRepositoryInterface _repository; + @override - Future> call() { + Future> call() { return _repository.getIndustries(); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/get_staff_skills_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/get_staff_skills_usecase.dart index d21234d7..9ca3b97d 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/get_staff_skills_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/get_staff_skills_usecase.dart @@ -1,14 +1,19 @@ import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart' show StaffSkill; + import '../repositories/experience_repository_interface.dart'; /// Use case for fetching staff skills. -class GetStaffSkillsUseCase implements NoInputUseCase> { - final ExperienceRepositoryInterface _repository; - +class GetStaffSkillsUseCase + implements + NoInputUseCase<({List skills, List customSkills})> { + /// Creates a [GetStaffSkillsUseCase]. GetStaffSkillsUseCase(this._repository); + final ExperienceRepositoryInterface _repository; + @override - Future> call() { + Future<({List skills, List customSkills})> call() { return _repository.getSkills(); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/save_experience_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/save_experience_usecase.dart index 117ec4d2..ad0c0cbf 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/save_experience_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/save_experience_usecase.dart @@ -1,21 +1,22 @@ import 'package:krow_core/core.dart'; + import '../arguments/save_experience_arguments.dart'; import '../repositories/experience_repository_interface.dart'; /// Use case for saving staff experience details. -/// -/// Delegates the saving logic to [ExperienceRepositoryInterface]. class SaveExperienceUseCase extends UseCase { - final ExperienceRepositoryInterface repository; - /// Creates a [SaveExperienceUseCase]. SaveExperienceUseCase(this.repository); + /// The experience repository. + final ExperienceRepositoryInterface repository; + @override Future call(SaveExperienceArguments params) { return repository.saveExperience( params.industries, params.skills, + params.customSkills, ); } } 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 562ffb6a..28b9838a 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,144 +1,26 @@ -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' show StaffIndustry, StaffSkill; + import '../../domain/arguments/save_experience_arguments.dart'; import '../../domain/usecases/get_staff_industries_usecase.dart'; import '../../domain/usecases/get_staff_skills_usecase.dart'; import '../../domain/usecases/save_experience_usecase.dart'; +import 'experience_event.dart'; +import 'experience_state.dart'; -// Events -abstract class ExperienceEvent extends Equatable { - const ExperienceEvent(); +export 'experience_event.dart'; +export 'experience_state.dart'; - @override - List get props => []; -} - -class ExperienceLoaded extends ExperienceEvent {} - -class ExperienceIndustryToggled extends ExperienceEvent { - final String industry; - const ExperienceIndustryToggled(this.industry); - - @override - List get props => [industry]; -} - -class ExperienceSkillToggled extends ExperienceEvent { - final String skill; - const ExperienceSkillToggled(this.skill); - - @override - List get props => [skill]; -} - -class ExperienceCustomSkillAdded extends ExperienceEvent { - final String skill; - const ExperienceCustomSkillAdded(this.skill); - - @override - List get props => [skill]; -} - -class ExperienceSubmitted extends ExperienceEvent {} - -// State -enum ExperienceStatus { initial, loading, success, failure } - -class ExperienceState extends Equatable { - final ExperienceStatus status; - final List selectedIndustries; - final List selectedSkills; - final List availableIndustries; - final List availableSkills; - final String? errorMessage; - - const ExperienceState({ - this.status = ExperienceStatus.initial, - this.selectedIndustries = const [], - this.selectedSkills = const [], - this.availableIndustries = const [], - this.availableSkills = const [], - this.errorMessage, - }); - - ExperienceState copyWith({ - ExperienceStatus? status, - List? selectedIndustries, - List? selectedSkills, - List? availableIndustries, - List? availableSkills, - String? errorMessage, - }) { - return ExperienceState( - status: status ?? this.status, - selectedIndustries: selectedIndustries ?? this.selectedIndustries, - selectedSkills: selectedSkills ?? this.selectedSkills, - availableIndustries: availableIndustries ?? this.availableIndustries, - availableSkills: availableSkills ?? this.availableSkills, - errorMessage: errorMessage ?? this.errorMessage, - ); - } - - @override - List get props => [ - status, - selectedIndustries, - selectedSkills, - availableIndustries, - availableSkills, - errorMessage, - ]; -} - -/// 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 +/// BLoC that manages the staff experience (industries & skills) selection. class ExperienceBloc extends Bloc with BlocErrorHandler { - final GetStaffIndustriesUseCase getIndustries; - final GetStaffSkillsUseCase getSkills; - final SaveExperienceUseCase saveExperience; - + /// Creates an [ExperienceBloc]. ExperienceBloc({ required this.getIndustries, required this.getSkills, required this.saveExperience, - }) : super( - const ExperienceState( - availableIndustries: _kAvailableIndustries, - availableSkills: _kAvailableSkills, - ), - ) { + }) : super(const ExperienceState()) { on(_onLoaded); on(_onIndustryToggled); on(_onSkillToggled); @@ -148,6 +30,15 @@ class ExperienceBloc extends Bloc add(ExperienceLoaded()); } + /// Use case for fetching saved industries. + final GetStaffIndustriesUseCase getIndustries; + + /// Use case for fetching saved skills. + final GetStaffSkillsUseCase getSkills; + + /// Use case for saving experience selections. + final SaveExperienceUseCase saveExperience; + Future _onLoaded( ExperienceLoaded event, Emitter emit, @@ -156,13 +47,16 @@ class ExperienceBloc extends Bloc await handleError( emit: emit.call, action: () async { - final results = await Future.wait([getIndustries(), getSkills()]); + final List industries = await getIndustries(); + final ({List skills, List customSkills}) skillsResult = + await getSkills(); emit( state.copyWith( status: ExperienceStatus.initial, - selectedIndustries: results[0], - selectedSkills: results[1], + selectedIndustries: industries, + selectedSkills: skillsResult.skills, + customSkills: skillsResult.customSkills, ), ); }, @@ -177,7 +71,8 @@ class ExperienceBloc extends Bloc ExperienceIndustryToggled event, Emitter emit, ) { - final industries = List.from(state.selectedIndustries); + final List industries = + List.from(state.selectedIndustries); if (industries.contains(event.industry)) { industries.remove(event.industry); } else { @@ -190,7 +85,8 @@ class ExperienceBloc extends Bloc ExperienceSkillToggled event, Emitter emit, ) { - final skills = List.from(state.selectedSkills); + final List skills = + List.from(state.selectedSkills); if (skills.contains(event.skill)) { skills.remove(event.skill); } else { @@ -203,9 +99,10 @@ class ExperienceBloc extends Bloc ExperienceCustomSkillAdded event, Emitter emit, ) { - if (!state.selectedSkills.contains(event.skill)) { - final skills = List.from(state.selectedSkills)..add(event.skill); - emit(state.copyWith(selectedSkills: skills)); + if (!state.customSkills.contains(event.skill)) { + final List custom = List.from(state.customSkills) + ..add(event.skill); + emit(state.copyWith(customSkills: custom)); } } @@ -221,6 +118,7 @@ class ExperienceBloc extends Bloc SaveExperienceArguments( industries: state.selectedIndustries, skills: state.selectedSkills, + customSkills: state.customSkills, ), ); emit(state.copyWith(status: ExperienceStatus.success)); diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_event.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_event.dart new file mode 100644 index 00000000..aa54f2b7 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_event.dart @@ -0,0 +1,53 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart' show StaffIndustry, StaffSkill; + +/// Base event for the experience BLoC. +abstract class ExperienceEvent extends Equatable { + /// Creates an [ExperienceEvent]. + const ExperienceEvent(); + + @override + List get props => []; +} + +/// Triggers initial load of saved industries and skills. +class ExperienceLoaded extends ExperienceEvent {} + +/// Toggles an industry selection on or off. +class ExperienceIndustryToggled extends ExperienceEvent { + /// Creates an [ExperienceIndustryToggled] event. + const ExperienceIndustryToggled(this.industry); + + /// The industry to toggle. + final StaffIndustry industry; + + @override + List get props => [industry]; +} + +/// Toggles a skill selection on or off. +class ExperienceSkillToggled extends ExperienceEvent { + /// Creates an [ExperienceSkillToggled] event. + const ExperienceSkillToggled(this.skill); + + /// The skill to toggle. + final StaffSkill skill; + + @override + List get props => [skill]; +} + +/// Adds a custom skill not in the predefined [StaffSkill] enum. +class ExperienceCustomSkillAdded extends ExperienceEvent { + /// Creates an [ExperienceCustomSkillAdded] event. + const ExperienceCustomSkillAdded(this.skill); + + /// The custom skill value to add. + final String skill; + + @override + List get props => [skill]; +} + +/// Submits the selected industries and skills to the backend. +class ExperienceSubmitted extends ExperienceEvent {} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_state.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_state.dart new file mode 100644 index 00000000..6bd50bae --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_state.dart @@ -0,0 +1,77 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart' show StaffIndustry, StaffSkill; + +/// Status of the experience feature. +enum ExperienceStatus { + /// Initial state before any action. + initial, + + /// Loading data from the backend. + loading, + + /// Operation completed successfully. + success, + + /// An error occurred. + failure, +} + +/// State for the experience BLoC. +class ExperienceState extends Equatable { + /// Creates an [ExperienceState]. + const ExperienceState({ + this.status = ExperienceStatus.initial, + this.selectedIndustries = const [], + this.selectedSkills = const [], + this.customSkills = const [], + this.errorMessage, + }); + + /// Current operation status. + final ExperienceStatus status; + + /// Industries the staff member has selected. + final List selectedIndustries; + + /// Skills the staff member has selected. + final List selectedSkills; + + /// Custom skills not in [StaffSkill] that the user added. + final List customSkills; + + /// Error message key when [status] is [ExperienceStatus.failure]. + final String? errorMessage; + + /// All selected skill values as API strings (enum + custom combined). + List get allSkillValues => + [ + ...selectedSkills.map((StaffSkill s) => s.value), + ...customSkills, + ]; + + /// Creates a copy with the given fields replaced. + ExperienceState copyWith({ + ExperienceStatus? status, + List? selectedIndustries, + List? selectedSkills, + List? customSkills, + String? errorMessage, + }) { + return ExperienceState( + status: status ?? this.status, + selectedIndustries: selectedIndustries ?? this.selectedIndustries, + selectedSkills: selectedSkills ?? this.selectedSkills, + customSkills: customSkills ?? this.customSkills, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + List get props => [ + status, + selectedIndustries, + selectedSkills, + customSkills, + errorMessage, + ]; +} 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 2bf00f85..6955234e 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,90 +4,33 @@ 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' show StaffIndustry, StaffSkill; import '../blocs/experience_bloc.dart'; import '../widgets/experience_section_title.dart'; +/// Page for selecting staff industries and skills. class ExperiencePage extends StatelessWidget { + /// Creates an [ExperiencePage]. const ExperiencePage({super.key}); - String _getIndustryLabel(dynamic node, String industry) { - switch (industry) { - case 'hospitality': - return node.hospitality; - case 'food_service': - return node.food_service; - case 'warehouse': - return node.warehouse; - case 'events': - return node.events; - case 'retail': - return node.retail; - case 'healthcare': - return node.healthcare; - case 'other': - return node.other; - default: - return industry; - } - } - - String _getSkillLabel(dynamic node, String skill) { - switch (skill) { - case 'food_service': - return node.food_service; - case 'bartending': - return node.bartending; - case 'event_setup': - return node.event_setup; - case 'hospitality': - return node.hospitality; - case 'warehouse': - return node.warehouse; - case 'customer_service': - return node.customer_service; - case 'cleaning': - return node.cleaning; - case 'security': - return node.security; - case 'retail': - return node.retail; - case 'driving': - return node.driving; - case 'cooking': - return node.cooking; - case 'cashier': - return node.cashier; - case 'server': - return node.server; - case 'barista': - return node.barista; - case 'host_hostess': - return node.host_hostess; - case 'busser': - return node.busser; - default: - return skill; - } - } - @override Widget build(BuildContext context) { - final i18n = Translations.of(context).staff.onboarding.experience; + final dynamic i18n = Translations.of(context).staff.onboarding.experience; return Scaffold( appBar: UiAppBar( - title: i18n.title, + title: i18n.title as String, onLeadingPressed: () => Modular.to.toProfile(), ), body: BlocProvider( - create: (context) => Modular.get(), + create: (BuildContext context) => Modular.get(), child: BlocConsumer( - listener: (context, state) { + listener: (BuildContext context, ExperienceState state) { if (state.status == ExperienceStatus.success) { UiSnackbar.show( context, - message: 'Experience saved successfully', + message: i18n.save_success as String, type: UiSnackbarType.success, margin: const EdgeInsets.only( bottom: 120, @@ -100,7 +43,7 @@ class ExperiencePage extends StatelessWidget { context, message: state.errorMessage != null ? translateErrorKey(state.errorMessage!) - : 'An error occurred', + : i18n.save_error as String, type: UiSnackbarType.error, margin: const EdgeInsets.only( bottom: 120, @@ -110,65 +53,28 @@ class ExperiencePage extends StatelessWidget { ); } }, - builder: (context, state) { + builder: (BuildContext context, ExperienceState state) { return Column( - children: [ + children: [ Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(UiConstants.space5), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ ExperienceSectionTitle( - title: i18n.industries_title, - subtitle: i18n.industries_subtitle, + title: i18n.industries_title as String, + subtitle: i18n.industries_subtitle as String, ), const SizedBox(height: UiConstants.space3), - Wrap( - spacing: UiConstants.space2, - runSpacing: UiConstants.space2, - children: state.availableIndustries - .map( - (i) => UiChip( - label: _getIndustryLabel(i18n.industries, i), - isSelected: state.selectedIndustries.contains( - i, - ), - onTap: () => BlocProvider.of( - context, - ).add(ExperienceIndustryToggled(i)), - variant: state.selectedIndustries.contains(i) - ? UiChipVariant.primary - : UiChipVariant.secondary, - ), - ) - .toList(), - ), + _buildIndustryChips(context, state, i18n), const SizedBox(height: UiConstants.space10), ExperienceSectionTitle( - title: i18n.skills_title, - subtitle: i18n.skills_subtitle, + title: i18n.skills_title as String, + subtitle: i18n.skills_subtitle as String, ), const SizedBox(height: UiConstants.space3), - Wrap( - spacing: UiConstants.space2, - runSpacing: UiConstants.space2, - children: state.availableSkills - .map( - (s) => UiChip( - label: _getSkillLabel(i18n.skills, s), - isSelected: state.selectedSkills.contains(s), - onTap: () => BlocProvider.of( - context, - ).add(ExperienceSkillToggled(s)), - variant: - state.selectedSkills.contains(s) - ? UiChipVariant.primary - : UiChipVariant.secondary, - ), - ) - .toList(), - ), + _buildSkillChips(context, state, i18n), ], ), ), @@ -182,28 +88,45 @@ class ExperiencePage extends StatelessWidget { ); } - Widget _buildCustomSkillsList(ExperienceState state, dynamic i18n) { - final customSkills = state.selectedSkills - .where((s) => !state.availableSkills.contains(s)) - .toList(); - if (customSkills.isEmpty) return const SizedBox.shrink(); + Widget _buildIndustryChips( + BuildContext context, + ExperienceState state, + dynamic i18n, + ) { + return Wrap( + spacing: UiConstants.space2, + runSpacing: UiConstants.space2, + children: StaffIndustry.values.map((StaffIndustry industry) { + final bool isSelected = state.selectedIndustries.contains(industry); + return UiChip( + label: _getIndustryLabel(i18n.industries, industry), + isSelected: isSelected, + onTap: () => BlocProvider.of(context) + .add(ExperienceIndustryToggled(industry)), + variant: isSelected ? UiChipVariant.primary : UiChipVariant.secondary, + ); + }).toList(), + ); + } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - i18n.custom_skills_title, - style: UiTypography.body2m.textSecondary, - ), - const SizedBox(height: UiConstants.space2), - Wrap( - spacing: UiConstants.space2, - runSpacing: UiConstants.space2, - children: customSkills.map((skill) { - return UiChip(label: skill, variant: UiChipVariant.accent); - }).toList(), - ), - ], + Widget _buildSkillChips( + BuildContext context, + ExperienceState state, + dynamic i18n, + ) { + return Wrap( + spacing: UiConstants.space2, + runSpacing: UiConstants.space2, + children: StaffSkill.values.map((StaffSkill skill) { + final bool isSelected = state.selectedSkills.contains(skill); + return UiChip( + label: _getSkillLabel(i18n.skills, skill), + isSelected: isSelected, + onTap: () => BlocProvider.of(context) + .add(ExperienceSkillToggled(skill)), + variant: isSelected ? UiChipVariant.primary : UiChipVariant.secondary, + ); + }).toList(), ); } @@ -212,6 +135,7 @@ class ExperiencePage extends StatelessWidget { ExperienceState state, dynamic i18n, ) { + final bool isLoading = state.status == ExperienceStatus.loading; return Container( padding: const EdgeInsets.all(UiConstants.space4), decoration: const BoxDecoration( @@ -220,24 +144,20 @@ class ExperiencePage extends StatelessWidget { ), child: SafeArea( child: UiButton.primary( - onPressed: state.status == ExperienceStatus.loading + onPressed: isLoading ? null - : () => BlocProvider.of( - context, - ).add(ExperienceSubmitted()), + : () => BlocProvider.of(context) + .add(ExperienceSubmitted()), fullWidth: true, - text: state.status == ExperienceStatus.loading - ? null - : i18n.save_button, - child: state.status == ExperienceStatus.loading + text: isLoading ? null : i18n.save_button as String, + child: isLoading ? const SizedBox( height: UiConstants.iconMd, width: UiConstants.iconMd, child: CircularProgressIndicator( strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - UiColors.white, - ), // UiColors.primaryForeground is white mostly + valueColor: + AlwaysStoppedAnimation(UiColors.white), ), ) : null, @@ -245,4 +165,66 @@ class ExperiencePage extends StatelessWidget { ), ); } + + /// Maps a [StaffIndustry] to its localized label. + String _getIndustryLabel(dynamic node, StaffIndustry industry) { + switch (industry) { + case StaffIndustry.hospitality: + return node.hospitality as String; + case StaffIndustry.foodService: + return node.food_service as String; + case StaffIndustry.warehouse: + return node.warehouse as String; + case StaffIndustry.events: + return node.events as String; + case StaffIndustry.retail: + return node.retail as String; + case StaffIndustry.healthcare: + return node.healthcare as String; + case StaffIndustry.catering: + return node.catering as String; + case StaffIndustry.cafe: + return node.cafe as String; + case StaffIndustry.other: + return node.other as String; + } + } + + /// Maps a [StaffSkill] to its localized label. + String _getSkillLabel(dynamic node, StaffSkill skill) { + switch (skill) { + case StaffSkill.foodService: + return node.food_service as String; + case StaffSkill.bartending: + return node.bartending as String; + case StaffSkill.eventSetup: + return node.event_setup as String; + case StaffSkill.hospitality: + return node.hospitality as String; + case StaffSkill.warehouse: + return node.warehouse as String; + case StaffSkill.customerService: + return node.customer_service as String; + case StaffSkill.cleaning: + return node.cleaning as String; + case StaffSkill.security: + return node.security as String; + case StaffSkill.retail: + return node.retail as String; + case StaffSkill.driving: + return node.driving as String; + case StaffSkill.cooking: + return node.cooking as String; + case StaffSkill.cashier: + return node.cashier as String; + case StaffSkill.server: + return node.server as String; + case StaffSkill.barista: + return node.barista as String; + case StaffSkill.hostHostess: + return node.host_hostess as String; + case StaffSkill.busser: + return node.busser as String; + } + } } From d6ddb7829e7d6d6dbf2fe43f0afb1c1a557943ad Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 14:00:41 -0400 Subject: [PATCH 17/25] fix: change saveContacts method to use POST instead of PUT for emergency contacts --- .../data/repositories/emergency_contact_repository_impl.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 afa3430f..a60c8928 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 @@ -27,7 +27,7 @@ class EmergencyContactRepositoryImpl @override Future saveContacts(List contacts) async { - await _api.put( + await _api.post( StaffEndpoints.emergencyContacts, data: { 'contacts': From b6a655a26137740ecbbe6e07e8203f84bb1bf4f0 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 14:15:26 -0400 Subject: [PATCH 18/25] feat: enhance API error handling and response structure; introduce ApiException for V2 error codes --- .../src/services/api_service/api_service.dart | 84 ++++++++++++------- .../services/api_services/api_response.dart | 11 ++- .../api_services/base_core_service.dart | 6 +- .../lib/src/exceptions/app_exception.dart | 48 +++++++++++ .../auth_repository_impl.dart | 19 ----- 5 files changed, 116 insertions(+), 52 deletions(-) diff --git a/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart index 80e7a86b..a9a1ce88 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart @@ -5,7 +5,9 @@ import 'feature_gate.dart'; /// A service that handles HTTP communication using the [Dio] client. /// -/// Integrates [FeatureGate] to validate endpoint scopes before each request. +/// Integrates [FeatureGate] for scope validation and throws typed domain +/// exceptions ([ApiException], [NetworkException], [ServerException]) on +/// error responses so repositories never receive silent failures. class ApiService implements BaseApiService { /// Creates an [ApiService] with the given [Dio] instance. ApiService(this._dio); @@ -27,7 +29,7 @@ class ApiService implements BaseApiService { ); return _handleResponse(response); } on DioException catch (e) { - return _handleError(e); + throw _mapDioException(e); } } @@ -47,7 +49,7 @@ class ApiService implements BaseApiService { ); return _handleResponse(response); } on DioException catch (e) { - return _handleError(e); + throw _mapDioException(e); } } @@ -67,7 +69,7 @@ class ApiService implements BaseApiService { ); return _handleResponse(response); } on DioException catch (e) { - return _handleError(e); + throw _mapDioException(e); } } @@ -87,7 +89,7 @@ class ApiService implements BaseApiService { ); return _handleResponse(response); } on DioException catch (e) { - return _handleError(e); + throw _mapDioException(e); } } @@ -107,7 +109,7 @@ class ApiService implements BaseApiService { ); return _handleResponse(response); } on DioException catch (e) { - return _handleError(e); + throw _mapDioException(e); } } @@ -117,43 +119,63 @@ class ApiService implements BaseApiService { /// Extracts [ApiResponse] from a successful [Response]. ApiResponse _handleResponse(Response response) { + final dynamic body = response.data; + final String message = body is Map + ? body['message']?.toString() ?? 'Success' + : 'Success'; + return ApiResponse( code: response.statusCode?.toString() ?? '200', - message: response.data['message']?.toString() ?? 'Success', - data: response.data, + message: message, + data: body, ); } - /// Extracts [ApiResponse] from a [DioException]. + /// Maps a [DioException] to a typed domain exception. /// - /// Supports both legacy error format and V2 API error envelope - /// (`{ code, message, details, requestId }`). - ApiResponse _handleError(DioException e) { + /// The V2 API error envelope is `{ code, message, details, requestId }`. + /// This method parses it and throws the appropriate [AppException] subclass + /// so that `BlocErrorHandler` can translate it for the user. + AppException _mapDioException(DioException e) { + // Network-level failures (no response from server). + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout || + e.type == DioExceptionType.sendTimeout || + e.type == DioExceptionType.connectionError) { + return NetworkException(technicalMessage: e.message); + } + + final int? statusCode = e.response?.statusCode; + + // Parse V2 error envelope if available. if (e.response?.data is Map) { final Map body = e.response!.data as Map; - return ApiResponse( - code: - body['code']?.toString() ?? - e.response?.statusCode?.toString() ?? - 'error', - message: body['message']?.toString() ?? e.message ?? 'Error occurred', - data: body['data'] ?? body['details'], - errors: _parseErrors(body['errors']), + + final String apiCode = + body['code']?.toString() ?? statusCode?.toString() ?? 'UNKNOWN'; + final String apiMessage = + body['message']?.toString() ?? e.message ?? 'An error occurred'; + + // Map well-known codes to specific exceptions. + if (apiCode == 'UNAUTHENTICATED' || statusCode == 401) { + return NotAuthenticatedException(technicalMessage: apiMessage); + } + + return ApiException( + apiCode: apiCode, + apiMessage: apiMessage, + statusCode: statusCode, + details: body['details'], + technicalMessage: '$apiCode: $apiMessage', ); } - return ApiResponse( - code: e.response?.statusCode?.toString() ?? 'error', - message: e.message ?? 'Unknown error', - errors: {'exception': e.type.toString()}, - ); - } - /// Helper to parse the errors map from various possible formats. - Map _parseErrors(dynamic errors) { - if (errors is Map) { - return Map.from(errors); + // Server error without a parseable body. + if (statusCode != null && statusCode >= 500) { + return ServerException(technicalMessage: e.message); } - return const {}; + + return UnknownException(technicalMessage: e.message); } } diff --git a/apps/mobile/packages/domain/lib/src/core/services/api_services/api_response.dart b/apps/mobile/packages/domain/lib/src/core/services/api_services/api_response.dart index 3e6a5435..de1e228e 100644 --- a/apps/mobile/packages/domain/lib/src/core/services/api_services/api_response.dart +++ b/apps/mobile/packages/domain/lib/src/core/services/api_services/api_response.dart @@ -8,7 +8,7 @@ class ApiResponse { this.errors = const {}, }); - /// The response code (e.g., '200', '404', or custom error code). + /// The response code (e.g., '200', '404', or V2 error code like 'VALIDATION_ERROR'). final String code; /// A descriptive message about the response. @@ -19,4 +19,13 @@ class ApiResponse { /// A map of field-specific error messages, if any. final Map errors; + + /// Whether the response indicates success (HTTP 2xx). + bool get isSuccess { + final int? statusCode = int.tryParse(code); + return statusCode != null && statusCode >= 200 && statusCode < 300; + } + + /// Whether the response indicates failure. + bool get isFailure => !isSuccess; } diff --git a/apps/mobile/packages/domain/lib/src/core/services/api_services/base_core_service.dart b/apps/mobile/packages/domain/lib/src/core/services/api_services/base_core_service.dart index 1acda2e3..495e30e3 100644 --- a/apps/mobile/packages/domain/lib/src/core/services/api_services/base_core_service.dart +++ b/apps/mobile/packages/domain/lib/src/core/services/api_services/base_core_service.dart @@ -1,3 +1,4 @@ +import '../../../exceptions/app_exception.dart'; import 'api_response.dart'; import 'base_api_service.dart'; @@ -14,10 +15,13 @@ abstract class BaseCoreService { /// Standardized wrapper to execute API actions. /// - /// This handles generic error normalization for unexpected non-HTTP errors. + /// Rethrows [AppException] subclasses (domain errors) directly. + /// Wraps unexpected non-HTTP errors into an error [ApiResponse]. Future action(Future Function() execution) async { try { return await execution(); + } on AppException { + rethrow; } catch (e) { return ApiResponse( code: 'CORE_INTERNAL_ERROR', diff --git a/apps/mobile/packages/domain/lib/src/exceptions/app_exception.dart b/apps/mobile/packages/domain/lib/src/exceptions/app_exception.dart index f8abd5c0..659aad24 100644 --- a/apps/mobile/packages/domain/lib/src/exceptions/app_exception.dart +++ b/apps/mobile/packages/domain/lib/src/exceptions/app_exception.dart @@ -286,6 +286,54 @@ class NoActiveShiftException extends ShiftException { // NETWORK/GENERIC EXCEPTIONS // ============================================================ +// ============================================================ +// API EXCEPTIONS (mapped from V2 error envelope codes) +// ============================================================ + +/// Thrown when the V2 API returns a non-success response. +/// +/// Carries the full error envelope so callers can inspect details. +class ApiException extends AppException { + /// Creates an [ApiException]. + const ApiException({ + required String apiCode, + required String apiMessage, + this.statusCode, + this.details, + super.technicalMessage, + }) : super(code: apiCode); + + /// The HTTP status code (e.g. 400, 404, 500). + final int? statusCode; + + /// The V2 API error code string (e.g. 'VALIDATION_ERROR'). + String get apiCode => code; + + /// Optional details from the error envelope. + final dynamic details; + + @override + String get messageKey { + switch (code) { + case 'VALIDATION_ERROR': + return 'errors.generic.validation_error'; + case 'NOT_FOUND': + return 'errors.generic.not_found'; + case 'FORBIDDEN': + return 'errors.generic.forbidden'; + case 'UNAUTHENTICATED': + return 'errors.auth.not_authenticated'; + case 'CONFLICT': + return 'errors.generic.conflict'; + default: + if (statusCode != null && statusCode! >= 500) { + return 'errors.generic.server_error'; + } + return 'errors.generic.unknown'; + } + } +} + /// Thrown when there is no network connection. class NetworkException extends AppException { const NetworkException({super.technicalMessage}) 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 08185e59..c8fc50a3 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 @@ -55,20 +55,6 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { 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. @@ -115,12 +101,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { }, ); - // 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 From de388c9a77759169c64f49a54d26243f19a4a9c6 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 14:32:26 -0400 Subject: [PATCH 19/25] feat: update profile setup and bank account management; enhance API integration and data handling --- .../lib/src/entities/users/staff_session.dart | 2 +- .../profile_setup_repository_impl.dart | 17 +++++- .../profile_setup_repository.dart | 4 +- .../submit_profile_setup_usecase.dart | 2 + .../profile_setup/profile_setup_bloc.dart | 9 ++++ .../lib/src/staff_authentication_module.dart | 1 + .../clock_in_repository_impl.dart | 2 +- .../certificates_repository_impl.dart | 2 +- .../documents_repository_impl.dart | 2 +- .../tax_forms_repository_impl.dart | 2 +- .../bank_account_repository_impl.dart | 7 +-- .../arguments/add_bank_account_params.dart | 48 +++++++++++++---- .../repositories/bank_account_repository.dart | 10 ++-- .../usecases/add_bank_account_usecase.dart | 8 +-- .../blocs/bank_account_cubit.dart | 16 +++--- .../time_card_repository_impl.dart | 2 +- .../emergency_contact_repository_impl.dart | 32 ++++++++--- .../personal_info_repository_impl.dart | 54 ++++++++----------- .../lib/src/staff_profile_info_module.dart | 2 - .../onboarding/profile_info/pubspec.yaml | 1 + .../shifts_repository_impl.dart | 4 +- 21 files changed, 142 insertions(+), 85 deletions(-) diff --git a/apps/mobile/packages/domain/lib/src/entities/users/staff_session.dart b/apps/mobile/packages/domain/lib/src/entities/users/staff_session.dart index a1b9e4de..dca4eeeb 100644 --- a/apps/mobile/packages/domain/lib/src/entities/users/staff_session.dart +++ b/apps/mobile/packages/domain/lib/src/entities/users/staff_session.dart @@ -61,7 +61,7 @@ class StaffSession extends Equatable { vendorId: staff['vendorId'] as String?, workforceNumber: staff['workforceNumber'] as String?, metadata: (staff['metadata'] as Map?) ?? const {}, - userId: user['userId'] as String?, + userId: (user['userId'] ?? user['id']) as String?, tenantName: tenant['tenantName'] as String?, tenantSlug: tenant['tenantSlug'] as String?, ); 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 2ca01e07..64a45151 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,3 +1,4 @@ +import 'package:firebase_auth/firebase_auth.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -20,18 +21,32 @@ class ProfileSetupRepositoryImpl implements ProfileSetupRepository { @override Future submitProfile({ required String fullName, + required String phoneNumber, String? bio, required List preferredLocations, required double maxDistanceMiles, required List industries, required List skills, }) async { + // Convert location label strings to the object shape the V2 API expects. + // The backend zod schema requires: { label, city?, state?, ... }. + final List> locationObjects = preferredLocations + .map((String label) => {'label': label}) + .toList(); + + // Resolve the phone number: prefer the explicit parameter, but fall back + // to the Firebase Auth current user's phone if the caller passed empty. + final String resolvedPhone = phoneNumber.isNotEmpty + ? phoneNumber + : (FirebaseAuth.instance.currentUser?.phoneNumber ?? ''); + final ApiResponse response = await _apiService.post( StaffEndpoints.profileSetup, data: { 'fullName': fullName, + 'phoneNumber': resolvedPhone, if (bio != null && bio.isNotEmpty) 'bio': bio, - 'preferredLocations': preferredLocations, + 'preferredLocations': locationObjects, 'maxDistanceMiles': maxDistanceMiles.toInt(), 'industries': industries, 'skills': skills, diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/profile_setup_repository.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/profile_setup_repository.dart index 3c5b17c7..830f9a96 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/profile_setup_repository.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/profile_setup_repository.dart @@ -1,7 +1,9 @@ - +/// Interface for the staff profile setup repository. abstract class ProfileSetupRepository { + /// Submits the staff profile setup data to the backend. Future submitProfile({ required String fullName, + required String phoneNumber, String? bio, required List preferredLocations, required double maxDistanceMiles, 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 f3a944ad..68a2a337 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 @@ -13,6 +13,7 @@ class SubmitProfileSetup { /// Submits the profile setup with the given data. Future call({ required String fullName, + required String phoneNumber, String? bio, required List preferredLocations, required double maxDistanceMiles, @@ -21,6 +22,7 @@ class SubmitProfileSetup { }) { return repository.submitProfile( fullName: fullName, + phoneNumber: phoneNumber, bio: bio, preferredLocations: preferredLocations, maxDistanceMiles: maxDistanceMiles, 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 c35bb6e4..8f563aa6 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 @@ -12,11 +12,16 @@ export 'package:staff_authentication/src/presentation/blocs/profile_setup/profil class ProfileSetupBloc extends Bloc with BlocErrorHandler { /// Creates a [ProfileSetupBloc]. + /// + /// [phoneNumber] is the authenticated user's phone from the sign-up flow, + /// required by the V2 profile-setup endpoint. ProfileSetupBloc({ required SubmitProfileSetup submitProfileSetup, required SearchCitiesUseCase searchCities, + required String phoneNumber, }) : _submitProfileSetup = submitProfileSetup, _searchCities = searchCities, + _phoneNumber = phoneNumber, super(const ProfileSetupState()) { on(_onFullNameChanged); on(_onBioChanged); @@ -35,6 +40,9 @@ class ProfileSetupBloc extends Bloc /// The use case for searching cities. final SearchCitiesUseCase _searchCities; + /// The user's phone number from the sign-up flow. + final String _phoneNumber; + /// Handles the [ProfileSetupFullNameChanged] event. void _onFullNameChanged( ProfileSetupFullNameChanged event, @@ -95,6 +103,7 @@ class ProfileSetupBloc extends Bloc action: () async { await _submitProfileSetup( fullName: state.fullName, + phoneNumber: _phoneNumber, bio: state.bio.isEmpty ? null : state.bio, preferredLocations: state.preferredLocations, maxDistanceMiles: state.maxDistanceMiles, 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 0f0fabd4..cfaf7c81 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 @@ -56,6 +56,7 @@ class StaffAuthenticationModule extends Module { () => ProfileSetupBloc( submitProfileSetup: i.get(), searchCities: i.get(), + phoneNumber: i.get().state.phoneNumber, ), ); } 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 96992d9b..3006a1ed 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 @@ -78,7 +78,7 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface { id: json['shiftId'] as String, orderId: json['orderId'] as String? ?? '', title: json['clientName'] as String? ?? json['roleName'] as String? ?? '', - status: ShiftStatus.fromJson(json['attendanceStatus'] as String?), + status: ShiftStatus.assigned, startsAt: DateTime.parse(json['startTime'] as String), endsAt: DateTime.parse(json['endTime'] as String), locationName: json['location'] as String?, 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 60b45251..7de2157e 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 @@ -29,7 +29,7 @@ class CertificatesRepositoryImpl implements CertificatesRepository { final ApiResponse response = await _api.get(StaffEndpoints.certificates); final List items = - response.data['certificates'] as List? ?? []; + response.data['items'] as List? ?? []; return items .map((dynamic json) => StaffCertificate.fromJson(json as Map)) 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 56ade1eb..929cfd35 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 @@ -28,7 +28,7 @@ class DocumentsRepositoryImpl implements DocumentsRepository { Future> getDocuments() async { final ApiResponse response = await _api.get(StaffEndpoints.documents); - final List items = response.data['documents'] as List? ?? []; + final List items = response.data['items'] as List? ?? []; return items .map((dynamic json) => ProfileDocument.fromJson(json as Map)) 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 6145e964..23984b22 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 @@ -19,7 +19,7 @@ class TaxFormsRepositoryImpl implements TaxFormsRepository { Future> getTaxForms() async { final ApiResponse response = await _api.get(StaffEndpoints.taxForms); - final List items = response.data['taxForms'] as List? ?? []; + final List items = response.data['items'] as List? ?? []; return items .map((dynamic json) => TaxForm.fromJson(json as Map)) 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 bc387187..daf7d393 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,6 +1,7 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_bank_account/src/domain/arguments/add_bank_account_params.dart'; import 'package:staff_bank_account/src/domain/repositories/bank_account_repository.dart'; /// Implementation of [BankAccountRepository] using the V2 API. @@ -17,7 +18,7 @@ class BankAccountRepositoryImpl implements BankAccountRepository { Future> getAccounts() async { final ApiResponse response = await _api.get(StaffEndpoints.bankAccounts); - final List items = response.data['accounts'] as List? ?? []; + final List items = response.data['items'] as List? ?? []; return items .map((dynamic json) => BankAccount.fromJson(json as Map)) @@ -25,10 +26,10 @@ class BankAccountRepositoryImpl implements BankAccountRepository { } @override - Future addAccount(BankAccount account) async { + Future addAccount(AddBankAccountParams params) async { await _api.post( StaffEndpoints.bankAccounts, - data: account.toJson(), + data: params.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 3a6aa13a..70fa1ad2 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 @@ -1,16 +1,44 @@ -import 'package:equatable/equatable.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; +import 'package:krow_domain/krow_domain.dart' show AccountType; -/// Arguments for adding a bank account. -class AddBankAccountParams extends UseCaseArgument with EquatableMixin { +/// Parameters for creating a new bank account via the V2 API. +/// +/// Maps directly to the `bankAccountCreateSchema` zod schema: +/// `{ bankName, accountNumber, routingNumber, accountType }`. +class AddBankAccountParams extends UseCaseArgument { + /// Creates an [AddBankAccountParams]. + const AddBankAccountParams({ + required this.bankName, + required this.accountNumber, + required this.routingNumber, + required this.accountType, + }); - const AddBankAccountParams({required this.account}); - final BankAccount account; + /// Name of the bank / financial institution. + final String bankName; + + /// Full account number. + final String accountNumber; + + /// Routing / transit number. + final String routingNumber; + + /// Account type (checking or savings). + final AccountType accountType; + + /// Serialises to the V2 API request body. + Map toJson() => { + 'bankName': bankName, + 'accountNumber': accountNumber, + 'routingNumber': routingNumber, + 'accountType': accountType.toJson(), + }; @override - List get props => [account]; - - @override - bool? get stringify => true; + List get props => [ + bankName, + accountNumber, + routingNumber, + accountType, + ]; } 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 21e68e73..faecb774 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,12 +1,12 @@ -import 'package:krow_domain/krow_domain.dart'; +import 'package:krow_domain/krow_domain.dart' show BankAccount; + +import '../arguments/add_bank_account_params.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 staff member. Future> getAccounts(); - /// Adds a new bank account. - Future addAccount(BankAccount account); + /// Creates a new bank account with the given [params]. + Future addAccount(AddBankAccountParams params); } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/add_bank_account_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/add_bank_account_usecase.dart index 2403b32d..840637ed 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/add_bank_account_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/add_bank_account_usecase.dart @@ -1,15 +1,17 @@ import 'package:krow_core/core.dart'; -import '../repositories/bank_account_repository.dart'; + import '../arguments/add_bank_account_params.dart'; +import '../repositories/bank_account_repository.dart'; /// Use case to add a bank account. class AddBankAccountUseCase implements UseCase { - + /// Creates an [AddBankAccountUseCase]. AddBankAccountUseCase(this._repository); + final BankAccountRepository _repository; @override Future call(AddBankAccountParams params) { - return _repository.addAccount(params.account); + return _repository.addAccount(params); } } 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 70ee70ce..e041aefa 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 @@ -1,6 +1,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; +import 'package:krow_domain/krow_domain.dart' show AccountType, BankAccount; + import '../../domain/arguments/add_bank_account_params.dart'; import '../../domain/usecases/add_bank_account_usecase.dart'; import '../../domain/usecases/get_bank_accounts_usecase.dart'; @@ -47,15 +48,10 @@ class BankAccountCubit extends Cubit }) async { emit(state.copyWith(status: BankAccountStatus.loading)); - // Create domain entity - final BankAccount newAccount = BankAccount( - accountId: '', // Generated by server + final AddBankAccountParams params = AddBankAccountParams( bankName: bankName, - providerReference: routingNumber, - last4: accountNumber.length > 4 - ? accountNumber.substring(accountNumber.length - 4) - : accountNumber, - isPrimary: false, + accountNumber: accountNumber, + routingNumber: routingNumber, accountType: type == 'CHECKING' ? AccountType.checking : AccountType.savings, @@ -64,7 +60,7 @@ class BankAccountCubit extends Cubit await handleError( emit: emit, action: () async { - await _addBankAccountUseCase(AddBankAccountParams(account: newAccount)); + await _addBankAccountUseCase(params); // Re-fetch to get latest state including server-generated IDs await loadAccounts(); 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 cd103430..3dda95a0 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 @@ -22,7 +22,7 @@ class TimeCardRepositoryImpl implements TimeCardRepository { 'month': month.month, }, ); - final List items = response.data['entries'] as List? ?? []; + final List items = response.data['items'] as List? ?? []; return items .map((dynamic json) => TimeCardEntry.fromJson(json as Map)) 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 a60c8928..d20f61eb 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 @@ -18,7 +18,8 @@ class EmergencyContactRepositoryImpl Future> getContacts() async { final ApiResponse response = await _api.get(StaffEndpoints.emergencyContacts); - final List items = response.data['contacts'] as List? ?? []; + final List items = + response.data['items'] as List? ?? []; return items .map((dynamic json) => EmergencyContact.fromJson(json as Map)) @@ -27,12 +28,27 @@ class EmergencyContactRepositoryImpl @override Future saveContacts(List contacts) async { - await _api.post( - StaffEndpoints.emergencyContacts, - data: { - 'contacts': - contacts.map((EmergencyContact c) => c.toJson()).toList(), - }, - ); + for (final EmergencyContact contact in contacts) { + final Map body = { + 'fullName': contact.fullName, + 'phone': contact.phone, + 'relationshipType': contact.relationshipType, + 'isPrimary': contact.isPrimary, + }; + + if (contact.contactId.isNotEmpty) { + // Existing contact — update via PUT. + await _api.put( + StaffEndpoints.emergencyContactUpdate(contact.contactId), + data: body, + ); + } else { + // New contact — create via POST. + await _api.post( + StaffEndpoints.emergencyContacts, + data: body, + ); + } + } } } 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 75fc0a01..5cba61fc 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,3 +1,4 @@ +import 'package:dio/dio.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -10,20 +11,12 @@ import 'package:staff_profile_info/src/domain/repositories/personal_info_reposit class PersonalInfoRepositoryImpl implements PersonalInfoRepositoryInterface { /// Creates a [PersonalInfoRepositoryImpl]. /// - /// Requires the V2 [BaseApiService] for HTTP communication, - /// [FileUploadService] for uploading files to cloud storage, and - /// [SignedUrlService] for generating signed download URLs. + /// Requires the V2 [BaseApiService] for HTTP communication. PersonalInfoRepositoryImpl({ required BaseApiService apiService, - required FileUploadService uploadService, - required SignedUrlService signedUrlService, - }) : _api = apiService, - _uploadService = uploadService, - _signedUrlService = signedUrlService; + }) : _api = apiService; final BaseApiService _api; - final FileUploadService _uploadService; - final SignedUrlService _signedUrlService; @override Future getStaffProfile() async { @@ -39,39 +32,34 @@ class PersonalInfoRepositoryImpl implements PersonalInfoRepositoryInterface { required String staffId, required Map data, }) async { - final ApiResponse response = await _api.put( + // The PUT response returns { staffId, fullName, email, phone, metadata } + // which does not match the StaffPersonalInfo shape. Perform the update + // and then re-fetch the full profile to return the correct entity. + await _api.put( StaffEndpoints.personalInfo, data: data, ); - final Map json = - response.data as Map; - return StaffPersonalInfo.fromJson(json); + return getStaffProfile(); } @override Future uploadProfilePhoto(String filePath) async { - // 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, - ); + // The backend expects a multipart file upload at /staff/profile/photo. + // It uploads to GCS, updates staff metadata, and returns a signed URL. + final String fileName = + 'staff_profile_photo_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final FormData formData = FormData.fromMap({ + 'file': await MultipartFile.fromFile(filePath, filename: fileName), + }); - // 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( + final ApiResponse response = await _api.post( StaffEndpoints.profilePhoto, - data: { - 'fileUri': uploadRes.fileUri, - 'photoUrl': photoUrl, - }, + data: formData, ); + final Map json = + response.data as Map; - return photoUrl; + // Backend returns { staffId, fileUri, signedUrl, expiresAt }. + return json['signedUrl'] as String? ?? ''; } } 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 c7a47872..37336302 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 @@ -27,8 +27,6 @@ class StaffProfileInfoModule extends Module { i.addLazySingleton( () => PersonalInfoRepositoryImpl( apiService: i.get(), - uploadService: i.get(), - signedUrlService: 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 e8c7b321..ed42fef5 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 @@ -14,6 +14,7 @@ dependencies: flutter_bloc: ^8.1.0 bloc: ^8.1.0 flutter_modular: ^6.3.0 + dio: ^5.9.1 equatable: ^2.0.5 # Architecture Packages 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 ee27ea03..8835d825 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 @@ -96,12 +96,10 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { final ApiResponse response = await _apiService.get(StaffEndpoints.shiftsCompleted); final List items = _extractItems(response.data); - var x = items + return items .map((dynamic json) => CompletedShift.fromJson(json as Map)) .toList(); - - return x; } @override From e2d833dc58ff79cbe03ac8f89140f877b2d50587 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 14:37:56 -0400 Subject: [PATCH 20/25] feat: update UiNoticeBanner layout; align children to start and adjust description color --- .../design_system/lib/src/widgets/ui_notice_banner.dart | 2 +- .../lib/src/presentation/widgets/security_notice.dart | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart b/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart index 878b34ea..c2e47037 100644 --- a/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart +++ b/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart @@ -68,6 +68,7 @@ class UiNoticeBanner extends StatelessWidget { borderRadius: borderRadius ?? UiConstants.radiusLg, ), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -96,7 +97,6 @@ class UiNoticeBanner extends StatelessWidget { Text( description!, style: UiTypography.body3r.copyWith( - //color: descriptionColor ?? UiColors.textSecondary, color: descriptionColor ?? UiColors.primary, ), ), diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/security_notice.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/security_notice.dart index b0739b2f..8543148b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/security_notice.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/security_notice.dart @@ -2,12 +2,12 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; class SecurityNotice extends StatelessWidget { - final dynamic strings; - const SecurityNotice({ super.key, required this.strings, }); + + final dynamic strings; @override Widget build(BuildContext context) { From a12539ba072b8f4d802e843274a22ed132089c8a Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 14:47:01 -0400 Subject: [PATCH 21/25] feat: normalize user ID in auth envelope and update hub manager assignment logic --- .../repositories_impl/auth_repository_impl.dart | 15 ++++++++++++++- .../repositories_impl/hub_repository_impl.dart | 14 ++++++++------ .../client_order_query_repository_impl.dart | 2 +- 3 files changed, 23 insertions(+), 8 deletions(-) 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 c8fc50a3..908b57f2 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 @@ -190,7 +190,20 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { envelope['business'] as Map?; if (businessJson != null) { - final ClientSession clientSession = ClientSession.fromJson(envelope); + // The auth envelope from buildAuthEnvelope uses `user.id` but + // ClientSession.fromJson expects `user.userId` (matching the shape + // returned by loadActorContext / GET /client/session). Normalise the + // key so the session is populated correctly. + final Map normalisedEnvelope = { + ...envelope, + if (userJson != null) + 'user': { + ...userJson, + 'userId': userJson['id'] ?? userJson['userId'], + }, + }; + final ClientSession clientSession = + ClientSession.fromJson(normalisedEnvelope); ClientSessionStore.instance.setSession(clientSession); } 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 22b066d4..ea492685 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 @@ -142,11 +142,13 @@ class HubRepositoryImpl implements HubRepositoryInterface { required String hubId, required List businessMembershipIds, }) async { - await _apiService.post( - ClientEndpoints.hubAssignManagers(hubId), - data: { - 'businessMembershipIds': businessMembershipIds, - }, - ); + for (final String membershipId in businessMembershipIds) { + await _apiService.post( + ClientEndpoints.hubAssignManagers(hubId), + data: { + 'businessMembershipId': membershipId, + }, + ); + } } } 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 967da1b6..5d32f51d 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 @@ -39,7 +39,7 @@ class ClientOrderQueryRepositoryImpl 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, + ((role['hourlyRateCents'] as num?)?.toDouble() ?? 0) / 100.0, ); }).toList(); } From cfe8c0bc6c239185222837048b60f4ba2876de2b Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 14:55:10 -0400 Subject: [PATCH 22/25] feat: update certificate card to display issuer name if available --- .../lib/src/presentation/widgets/certificate_card.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 40b733d7..db11aa2d 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 @@ -138,7 +138,7 @@ class CertificateCard extends StatelessWidget { ), const SizedBox(height: 2), Text( - certificate.certificateType, + certificate.issuer ?? certificate.certificateType, style: UiTypography.body3r.textSecondary, ), if (showComplete) ...[ From 1252211a12b8c323b1b484bb6b9784572e769c3b Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 15:03:08 -0400 Subject: [PATCH 23/25] fix: correct clock-in validation logic to check for too early time --- .../src/domain/validators/validators/time_window_validator.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/time_window_validator.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/time_window_validator.dart index 3710c228..5bc54d65 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/time_window_validator.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/validators/validators/time_window_validator.dart @@ -39,7 +39,7 @@ class TimeWindowValidator implements ClockInValidator { const Duration(minutes: _earlyWindowMinutes), ); - if (windowStart.isBefore(DateTime.now())) { + if (windowStart.isAfter(DateTime.now())) { return const ClockInValidationResult.invalid('too_early_clock_in'); } From 42945b3b60d5c6370ab0a0a413b766b3881fab9d Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 15:15:18 -0400 Subject: [PATCH 24/25] feat: update ReorderWidget layout for responsive design; adjust item dimensions based on screen size --- .../presentation/widgets/reorder_widget.dart | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) 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 4a8f8e1f..c3cf54d2 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 @@ -35,12 +35,13 @@ class ReorderWidget extends StatelessWidget { } final TranslationsClientHomeReorderEn i18n = t.client_home.reorder; + final Size size = MediaQuery.sizeOf(context); return SectionLayout( title: title, subtitle: subtitle, child: SizedBox( - height: 140, + height: size.height * 0.18, child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: orders.length, @@ -50,7 +51,7 @@ class ReorderWidget extends StatelessWidget { final RecentOrder order = orders[index]; return Container( - width: 260, + width: size.width * 0.8, padding: const EdgeInsets.all(UiConstants.space3), decoration: BoxDecoration( color: UiColors.white, @@ -86,7 +87,7 @@ class ReorderWidget extends StatelessWidget { children: [ Text( order.title, - style: UiTypography.body2b, + style: UiTypography.body2m, overflow: TextOverflow.ellipsis, ), if (order.hubName != null && @@ -103,18 +104,18 @@ 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( - '${order.positionCount} positions', - style: UiTypography.footnote2r.textSecondary, - ), - ], - ), + // Column( + // crossAxisAlignment: CrossAxisAlignment.end, + // children: [ + // // ASSUMPTION: No i18n key for 'positions' under + // // reorder section — carrying forward existing + // // hardcoded string pattern for this migration. + // Text( + // '${order.positionCount} positions', + // style: UiTypography.footnote2r.textSecondary, + // ), + // ], + // ), ], ), const SizedBox(height: UiConstants.space3), From 2c7094bb546b2a7daaf64a2f2f42c175e5dbb7a9 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 16:18:45 -0400 Subject: [PATCH 25/25] feat: load assigned, pending, and cancelled shifts in parallel for improved performance --- .../blocs/shifts/shifts_bloc.dart | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) 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 9db418d2..0d05ffa6 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 @@ -79,16 +79,29 @@ class ShiftsBloc extends Bloc emit: emit.call, action: () async { final List days = _getCalendarDaysForOffset(0); - final List myShiftsResult = await getAssignedShifts( - GetAssignedShiftsArguments(start: days.first, end: days.last), - ); + + // Load assigned, pending, and cancelled shifts in parallel. + final List results = await Future.wait(>[ + getAssignedShifts( + GetAssignedShiftsArguments(start: days.first, end: days.last), + ), + getPendingAssignments(), + getCancelledShifts(), + ]); + + final List myShiftsResult = + results[0] as List; + final List pendingResult = + results[1] as List; + final List cancelledResult = + results[2] as List; emit( state.copyWith( status: ShiftsStatus.loaded, myShifts: myShiftsResult, - pendingShifts: const [], - cancelledShifts: const [], + pendingShifts: pendingResult, + cancelledShifts: cancelledResult, availableShifts: const [], historyShifts: const [], availableLoading: false,