From 8849bf2273c3cf20726563adec8b9c55fc28d10f Mon Sep 17 00:00:00 2001 From: Suriya Date: Fri, 20 Feb 2026 17:20:06 +0530 Subject: [PATCH] feat: architecture overhaul, launchpad-style reports, and uber-style locations - Strengthened Buffer Layer architecture to decouple Data Connect from Domain - Rewired Coverage, Performance, and Forecast reports to match Launchpad logic - Implemented Uber-style Preferred Locations search using Google Places API - Added session recovery logic to prevent crashes on app restart - Synchronized backend schemas & SDK for ShiftStatus enums - Fixed various build/compilation errors and localization duplicates --- .../core/lib/src/routing/staff/navigator.dart | 7 + .../lib/src/routing/staff/route_paths.dart | 6 + .../lib/src/l10n/en.i18n.json | 43 +- .../lib/src/l10n/es.i18n.json | 43 +- .../data_connect/lib/krow_data_connect.dart | 26 +- .../billing_connector_repository_impl.dart | 199 ++++++ .../billing_connector_repository.dart | 24 + .../home_connector_repository_impl.dart | 110 ++++ .../home_connector_repository.dart | 12 + .../hubs_connector_repository_impl.dart | 259 ++++++++ .../hubs_connector_repository.dart | 43 ++ .../reports_connector_repository_impl.dart | 535 ++++++++++++++++ .../reports_connector_repository.dart | 55 ++ .../shifts_connector_repository_impl.dart | 515 +++++++++++++++ .../shifts_connector_repository.dart | 56 ++ .../lib/src/data_connect_module.dart | 32 + .../src/services/data_connect_service.dart | 310 +++++---- .../mixins/session_handler_mixin.dart | 4 +- .../packages/domain/lib/krow_domain.dart | 10 + .../entities/financial/billing_period.dart | 8 + .../entities/reports}/coverage_report.dart | 0 .../entities/reports}/daily_ops_report.dart | 0 .../src/entities/reports/forecast_report.dart | 77 +++ .../src/entities/reports}/no_show_report.dart | 0 .../entities/reports}/performance_report.dart | 0 .../entities/reports}/reports_summary.dart | 0 .../src/entities/reports}/spend_report.dart | 6 +- .../billing_repository_impl.dart | 253 +------- .../lib/src/domain/models/billing_period.dart | 4 - .../repositories/billing_repository.dart | 1 - .../usecases/get_spending_breakdown.dart | 1 - .../src/presentation/blocs/billing_event.dart | 2 +- .../src/presentation/blocs/billing_state.dart | 1 - .../widgets/spending_breakdown_card.dart | 2 +- .../coverage_repository_impl.dart | 190 +----- .../home_repository_impl.dart | 184 +----- .../hub_repository_impl.dart | 418 ++---------- .../reports_repository_impl.dart | 474 +------------- .../src/domain/entities/forecast_report.dart | 33 - .../repositories/reports_repository.dart | 8 +- .../blocs/daily_ops/daily_ops_state.dart | 2 +- .../blocs/forecast/forecast_state.dart | 2 +- .../blocs/no_show/no_show_state.dart | 2 +- .../blocs/performance/performance_state.dart | 2 +- .../presentation/blocs/spend/spend_state.dart | 2 +- .../blocs/summary/reports_summary_state.dart | 2 +- .../pages/coverage_report_page.dart | 300 +++++++++ .../pages/forecast_report_page.dart | 602 +++++++++++------- .../pages/no_show_report_page.dart | 2 +- .../presentation/pages/spend_report_page.dart | 2 +- .../reports_page/quick_reports_section.dart | 16 + .../reports/lib/src/reports_module.dart | 4 + .../blocs/personal_info_bloc.dart | 45 +- .../blocs/personal_info_event.dart | 18 + .../pages/preferred_locations_page.dart | 513 +++++++++++++++ .../widgets/personal_info_content.dart | 31 +- .../widgets/personal_info_form.dart | 141 ++-- .../lib/src/staff_profile_info_module.dart | 8 + .../onboarding/profile_info/pubspec.yaml | 2 + .../shifts_repository_impl.dart | 554 ++-------------- 60 files changed, 3804 insertions(+), 2397 deletions(-) create mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/billing/data/repositories/billing_connector_repository_impl.dart create mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/billing/domain/repositories/billing_connector_repository.dart create mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/home/data/repositories/home_connector_repository_impl.dart create mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/home/domain/repositories/home_connector_repository.dart create mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart create mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/hubs/domain/repositories/hubs_connector_repository.dart create mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/reports/data/repositories/reports_connector_repository_impl.dart create mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/reports/domain/repositories/reports_connector_repository.dart create mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart create mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/shifts/domain/repositories/shifts_connector_repository.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/financial/billing_period.dart rename apps/mobile/packages/{features/client/reports/lib/src/domain/entities => domain/lib/src/entities/reports}/coverage_report.dart (100%) rename apps/mobile/packages/{features/client/reports/lib/src/domain/entities => domain/lib/src/entities/reports}/daily_ops_report.dart (100%) create mode 100644 apps/mobile/packages/domain/lib/src/entities/reports/forecast_report.dart rename apps/mobile/packages/{features/client/reports/lib/src/domain/entities => domain/lib/src/entities/reports}/no_show_report.dart (100%) rename apps/mobile/packages/{features/client/reports/lib/src/domain/entities => domain/lib/src/entities/reports}/performance_report.dart (100%) rename apps/mobile/packages/{features/client/reports/lib/src/domain/entities => domain/lib/src/entities/reports}/reports_summary.dart (100%) rename apps/mobile/packages/{features/client/reports/lib/src/domain/entities => domain/lib/src/entities/reports}/spend_report.dart (99%) delete mode 100644 apps/mobile/packages/features/client/billing/lib/src/domain/models/billing_period.dart delete mode 100644 apps/mobile/packages/features/client/reports/lib/src/domain/entities/forecast_report.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/preferred_locations_page.dart 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 3ba4a8ea..c2cf156f 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart @@ -177,6 +177,13 @@ extension StaffNavigator on IModularNavigator { pushNamed(StaffPaths.onboardingPersonalInfo); } + /// Pushes the preferred locations editing page. + /// + /// Allows staff to search and manage their preferred US work locations. + void toPreferredLocations() { + pushNamed(StaffPaths.preferredLocations); + } + /// Pushes the emergency contact page. /// /// Manage emergency contact details for safety purposes. diff --git a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart index bcb0a472..ef7ab6fe 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart @@ -128,6 +128,12 @@ class StaffPaths { static const String languageSelection = '/worker-main/personal-info/language-selection/'; + /// Preferred locations editing page. + /// + /// Allows staff to search and select their preferred US work locations. + static const String preferredLocations = + '/worker-main/personal-info/preferred-locations/'; + /// Emergency contact information. /// /// Manage emergency contact details for safety purposes. 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 4e60c7fe..19e2ed7d 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 @@ -605,8 +605,21 @@ "languages_hint": "English, Spanish, French...", "locations_label": "Preferred Locations", "locations_hint": "Downtown, Midtown, Brooklyn...", + "locations_summary_none": "Not set", "save_button": "Save Changes", - "save_success": "Personal info saved successfully" + "save_success": "Personal info saved successfully", + "preferred_locations": { + "title": "Preferred Locations", + "description": "Choose up to 5 locations in the US where you prefer to work. We'll prioritize shifts near these areas.", + "search_hint": "Search a city or area...", + "added_label": "YOUR LOCATIONS", + "max_reached": "You've reached the maximum of 5 locations", + "min_hint": "Add at least 1 preferred location", + "save_button": "Save Locations", + "save_success": "Preferred locations saved", + "remove_tooltip": "Remove location", + "empty_state": "No locations added yet.\nSearch above to add your preferred work areas." + } }, "experience": { "title": "Experience & Skills", @@ -1304,17 +1317,31 @@ }, "forecast_report": { "title": "Forecast Report", - "subtitle": "Projected spend & staffing", + "subtitle": "Next 4 weeks projection", "metrics": { - "projected_spend": "Projected Spend", - "workers_needed": "Workers Needed" + "four_week_forecast": "4-Week Forecast", + "avg_weekly": "Avg Weekly", + "total_shifts": "Total Shifts", + "total_hours": "Total Hours" + }, + "badges": { + "total_projected": "Total projected", + "per_week": "Per week", + "scheduled": "Scheduled", + "worker_hours": "Worker hours" }, "chart_title": "Spending Forecast", - "daily_projections": "DAILY PROJECTIONS", - "empty_state": "No projections available", - "shift_item": { - "workers_needed": "$count workers needed" + "weekly_breakdown": { + "title": "WEEKLY BREAKDOWN", + "week": "Week $index", + "shifts": "Shifts", + "hours": "Hours", + "avg_shift": "Avg/Shift" }, + "buttons": { + "export": "Export" + }, + "empty_state": "No projections available", "placeholders": { "export_message": "Exporting Forecast Report (Placeholder)" } 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 18ec6f7c..e96442da 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 @@ -605,8 +605,21 @@ "languages_hint": "Inglés, Español, Francés...", "locations_label": "Ubicaciones Preferidas", "locations_hint": "Centro, Midtown, Brooklyn...", + "locations_summary_none": "No configurado", "save_button": "Guardar Cambios", - "save_success": "Información personal guardada exitosamente" + "save_success": "Información personal guardada exitosamente", + "preferred_locations": { + "title": "Ubicaciones Preferidas", + "description": "Elige hasta 5 ubicaciones en los EE.UU. donde prefieres trabajar. Priorizaremos turnos cerca de estas áreas.", + "search_hint": "Buscar una ciudad o área...", + "added_label": "TUS UBICACIONES", + "max_reached": "Has alcanzado el máximo de 5 ubicaciones", + "min_hint": "Agrega al menos 1 ubicación preferida", + "save_button": "Guardar Ubicaciones", + "save_success": "Ubicaciones preferidas guardadas", + "remove_tooltip": "Eliminar ubicación", + "empty_state": "Aún no has agregado ubicaciones.\nBusca arriba para agregar tus áreas de trabajo preferidas." + } }, "experience": { "title": "Experiencia y habilidades", @@ -1304,17 +1317,31 @@ }, "forecast_report": { "title": "Informe de Previsión", - "subtitle": "Gastos y personal proyectados", + "subtitle": "Proyección próximas 4 semanas", "metrics": { - "projected_spend": "Gasto Proyectado", - "workers_needed": "Trabajadores Necesarios" + "four_week_forecast": "Previsión 4 Semanas", + "avg_weekly": "Promedio Semanal", + "total_shifts": "Total de Turnos", + "total_hours": "Total de Horas" + }, + "badges": { + "total_projected": "Total proyectado", + "per_week": "Por semana", + "scheduled": "Programado", + "worker_hours": "Horas de trabajo" }, "chart_title": "Previsión de Gastos", - "daily_projections": "PROYECCIONES DIARIAS", - "empty_state": "No hay proyecciones disponibles", - "shift_item": { - "workers_needed": "$count trabajadores necesarios" + "weekly_breakdown": { + "title": "DESGLOSE SEMANAL", + "week": "Semana $index", + "shifts": "Turnos", + "hours": "Horas", + "avg_shift": "Prom./Turno" }, + "buttons": { + "export": "Exportar" + }, + "empty_state": "No hay proyecciones disponibles", "placeholders": { "export_message": "Exportando Informe de Previsión (Marcador de posición)" } diff --git a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart b/apps/mobile/packages/data_connect/lib/krow_data_connect.dart index 82d0bfb8..55d3782b 100644 --- a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart +++ b/apps/mobile/packages/data_connect/lib/krow_data_connect.dart @@ -27,4 +27,28 @@ export 'src/connectors/staff/domain/usecases/get_experience_completion_usecase.d export 'src/connectors/staff/domain/usecases/get_tax_forms_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'; \ No newline at end of file +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 Home Connector +export 'src/connectors/home/domain/repositories/home_connector_repository.dart'; +export 'src/connectors/home/data/repositories/home_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'; \ No newline at end of file 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 new file mode 100644 index 00000000..3a4c6192 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/billing/data/repositories/billing_connector_repository_impl.dart @@ -0,0 +1,199 @@ +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 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 result = await _service.connector + .listInvoicesByBusinessId(businessId: businessId) + .execute(); + + return result.data.invoices + .map(_mapInvoice) + .where((i) => i.status == InvoiceStatus.open) + .fold(0.0, (sum, item) => sum + item.totalAmount); + }); + } + + @override + Future> getInvoiceHistory({required String businessId}) async { + return _service.run(() async { + final result = await _service.connector + .listInvoicesByBusinessId(businessId: businessId) + .limit(10) + .execute(); + + return result.data.invoices.map(_mapInvoice).toList(); + }); + } + + @override + Future> getPendingInvoices({required String businessId}) async { + return _service.run(() async { + final result = await _service.connector + .listInvoicesByBusinessId(businessId: businessId) + .execute(); + + return result.data.invoices + .map(_mapInvoice) + .where((i) => + i.status == InvoiceStatus.open || i.status == InvoiceStatus.disputed) + .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 result = await _service.connector + .listShiftRolesByBusinessAndDatesSummary( + businessId: businessId, + start: _service.toTimestamp(start), + end: _service.toTimestamp(end), + ) + .execute(); + + final shiftRoles = result.data.shiftRoles; + if (shiftRoles.isEmpty) return []; + + final Map summary = {}; + for (final role in shiftRoles) { + final roleId = role.roleId; + final roleName = role.role.name; + final hours = role.hours ?? 0.0; + final totalValue = role.totalValue ?? 0.0; + + final 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((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(); + }); + } + + // --- MAPPERS --- + + Invoice _mapInvoice(dynamic invoice) { + 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)!, + ); + } + + 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 new file mode 100644 index 00000000..aef57604 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/billing/domain/repositories/billing_connector_repository.dart @@ -0,0 +1,24 @@ +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, + }); +} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/home/data/repositories/home_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/home/data/repositories/home_connector_repository_impl.dart new file mode 100644 index 00000000..155385a6 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/home/data/repositories/home_connector_repository_impl.dart @@ -0,0 +1,110 @@ +import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_domain/krow_domain.dart'; +import '../../domain/repositories/home_connector_repository.dart'; + +/// Implementation of [HomeConnectorRepository]. +class HomeConnectorRepositoryImpl implements HomeConnectorRepository { + HomeConnectorRepositoryImpl({ + dc.DataConnectService? service, + }) : _service = service ?? dc.DataConnectService.instance; + + final dc.DataConnectService _service; + + @override + Future getDashboardData({required String businessId}) async { + return _service.run(() async { + final now = DateTime.now(); + final daysFromMonday = now.weekday - DateTime.monday; + final monday = DateTime(now.year, now.month, now.day).subtract(Duration(days: daysFromMonday)); + final weekRangeStart = monday; + final weekRangeEnd = monday.add(const Duration(days: 13, hours: 23, minutes: 59, seconds: 59)); + + final 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 shift in completedResult.data.shifts) { + final shiftDate = _service.toDateTime(shift.date); + if (shiftDate == null) continue; + + final offset = shiftDate.difference(weekRangeStart).inDays; + if (offset < 0 || offset > 13) continue; + + final cost = shift.cost ?? 0.0; + if (offset <= 6) { + weeklySpending += cost; + weeklyShifts += 1; + } else { + next7DaysSpending += cost; + next7DaysScheduled += 1; + } + } + + final start = DateTime(now.year, now.month, now.day); + final end = start.add(const Duration(hours: 23, minutes: 59, seconds: 59)); + + final result = await _service.connector + .listShiftRolesByBusinessAndDateRange( + businessId: businessId, + start: _service.toTimestamp(start), + end: _service.toTimestamp(end), + ) + .execute(); + + int totalNeeded = 0; + int totalFilled = 0; + for (final 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, + ); + }); + } + + @override + Future> getRecentReorders({required String businessId}) async { + return _service.run(() async { + final now = DateTime.now(); + final start = now.subtract(const Duration(days: 30)); + + final result = await _service.connector + .listShiftRolesByBusinessDateRangeCompletedOrders( + businessId: businessId, + start: _service.toTimestamp(start), + end: _service.toTimestamp(now), + ) + .execute(); + + return result.data.shiftRoles.map((shiftRole) { + final String location = shiftRole.shift.location ?? shiftRole.shift.locationAddress ?? ''; + final String type = shiftRole.shift.order.orderType.stringValue; + return ReorderItem( + orderId: shiftRole.shift.order.id, + title: '${shiftRole.role.name} - ${shiftRole.shift.title}', + location: location, + hourlyRate: shiftRole.role.costPerHour, + hours: shiftRole.hours ?? 0, + workers: shiftRole.count, + type: type, + ); + }).toList(); + }); + } +} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/home/domain/repositories/home_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/home/domain/repositories/home_connector_repository.dart new file mode 100644 index 00000000..365c09b4 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/home/domain/repositories/home_connector_repository.dart @@ -0,0 +1,12 @@ +import 'package:krow_domain/krow_domain.dart'; + +/// Repository interface for home connector operations. +/// +/// This acts as a buffer layer between the domain repository and the Data Connect SDK. +abstract interface class HomeConnectorRepository { + /// Fetches dashboard data for a business. + Future getDashboardData({required String businessId}); + + /// Fetches recent reorder items for a business. + Future> getRecentReorders({required String businessId}); +} 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 new file mode 100644 index 00000000..7e5f7a98 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart @@ -0,0 +1,259 @@ +import 'dart:convert'; +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 response = await _service.connector + .getTeamHubsByTeamId(teamId: teamId) + .execute(); + + return response.data.teamHubs.map((h) { + return Hub( + id: h.id, + businessId: businessId, + name: h.hubName, + address: h.address, + nfcTagId: null, + status: h.isActive ? HubStatus.active : HubStatus.inactive, + ); + }).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, + }) async { + return _service.run(() async { + final String teamId = await _getOrCreateTeamId(businessId); + final _PlaceAddress? placeAddress = (placeId != null && placeId.isNotEmpty) + ? await _fetchPlaceAddress(placeId) + : null; + + final 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(); + + return Hub( + id: result.data.teamHub_insert.id, + businessId: businessId, + name: name, + address: address, + nfcTagId: null, + status: HubStatus.active, + ); + }); + } + + @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, + }) async { + return _service.run(() async { + final _PlaceAddress? placeAddress = (placeId != null && placeId.isNotEmpty) + ? await _fetchPlaceAddress(placeId) + : null; + + final 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(); + + // Return a basic hub object reflecting changes (or we could re-fetch) + return Hub( + id: id, + businessId: businessId, + name: name ?? '', + address: address ?? '', + nfcTagId: null, + status: HubStatus.active, + ); + }); + } + + @override + Future deleteHub({required String businessId, required String id}) async { + return _service.run(() async { + final 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 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 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 response = await http.get(uri); + if (response.statusCode != 200) return null; + + final payload = json.decode(response.body) as Map; + if (payload['status'] != 'OK') return null; + + final result = payload['result'] as Map?; + final 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 component = entry as Map; + final types = component['types'] as List? ?? []; + final longName = component['long_name'] as String?; + final 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 street = [streetNumber, route] + .where((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 new file mode 100644 index 00000000..28e10e3d --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/hubs/domain/repositories/hubs_connector_repository.dart @@ -0,0 +1,43 @@ +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, + }); + + /// 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, + }); + + /// 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 new file mode 100644 index 00000000..f474fd56 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/reports/data/repositories/reports_connector_repository_impl.dart @@ -0,0 +1,535 @@ +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 response = await _service.connector + .listShiftsForDailyOpsByBusiness( + businessId: id, + date: _service.toTimestamp(date), + ) + .execute(); + + final shifts = response.data.shifts; + + int scheduledShifts = shifts.length; + int workersConfirmed = 0; + int inProgressShifts = 0; + int completedShifts = 0; + + final List dailyOpsShifts = []; + + for (final shift in shifts) { + workersConfirmed += shift.filled ?? 0; + final 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 response = await _service.connector + .listInvoicesForSpendByBusiness( + businessId: id, + startDate: _service.toTimestamp(startDate), + endDate: _service.toTimestamp(endDate), + ) + .execute(); + + final 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 inv in invoices) { + final amount = (inv.amount ?? 0.0).toDouble(); + totalSpend += amount; + + final statusStr = inv.status.stringValue; + if (statusStr == 'PAID') { + paidInvoices++; + } else if (statusStr == 'PENDING') { + pendingInvoices++; + } else if (statusStr == 'OVERDUE') { + overdueInvoices++; + } + + final industry = inv.vendor?.serviceSpecialty ?? 'Other'; + industryAggregates[industry] = (industryAggregates[industry] ?? 0.0) + amount; + + final 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 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 date = startDate.add(Duration(days: i)); + final normalizedDate = DateTime(date.year, date.month, date.day); + completeDailyAggregates[normalizedDate] = + dailyAggregates[normalizedDate] ?? 0.0; + } + + final List chartData = completeDailyAggregates.entries + .map((e) => SpendChartPoint(date: e.key, amount: e.value)) + .toList() + ..sort((a, b) => a.date.compareTo(b.date)); + + final List industryBreakdown = industryAggregates.entries + .map((e) => SpendIndustryCategory( + name: e.key, + amount: e.value, + percentage: totalSpend > 0 ? (e.value / totalSpend * 100) : 0, + )) + .toList() + ..sort((a, b) => b.amount.compareTo(a.amount)); + + final 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 response = await _service.connector + .listShiftsForCoverage( + businessId: id, + startDate: _service.toTimestamp(startDate), + endDate: _service.toTimestamp(endDate), + ) + .execute(); + + final shifts = response.data.shifts; + + int totalNeeded = 0; + int totalFilled = 0; + final Map dailyStats = {}; + + for (final shift in shifts) { + final shiftDate = shift.date?.toDateTime() ?? DateTime.now(); + final date = DateTime(shiftDate.year, shiftDate.month, shiftDate.day); + + final needed = shift.workersNeeded ?? 0; + final filled = shift.filled ?? 0; + + totalNeeded += needed; + totalFilled += filled; + + final current = dailyStats[date] ?? (0, 0); + dailyStats[date] = (current.$1 + needed, current.$2 + filled); + } + + final List dailyCoverage = dailyStats.entries.map((e) { + final needed = e.value.$1; + final filled = e.value.$2; + return CoverageDay( + date: e.key, + needed: needed, + filled: filled, + percentage: needed == 0 ? 100.0 : (filled / needed) * 100.0, + ); + }).toList()..sort((a, 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 response = await _service.connector + .listShiftsForForecastByBusiness( + businessId: id, + startDate: _service.toTimestamp(startDate), + endDate: _service.toTimestamp(endDate), + ) + .execute(); + + final 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 shift in shifts) { + final shiftDate = shift.date?.toDateTime() ?? DateTime.now(); + final date = DateTime(shiftDate.year, shiftDate.month, shiftDate.day); + + final cost = (shift.cost ?? 0.0).toDouble(); + final workers = shift.workersNeeded ?? 0; + final hoursVal = (shift.hours ?? 0).toDouble(); + final shiftTotalHours = hoursVal * workers; + + projectedSpend += cost; + projectedWorkers += workers; + totalHours += shiftTotalHours; + + final current = dailyStats[date] ?? (0.0, 0); + dailyStats[date] = (current.$1 + cost, current.$2 + workers); + + // Weekly logic + final diffDays = shiftDate.difference(startDate).inDays; + if (diffDays >= 0) { + final weekIndex = diffDays ~/ 7; + if (weekIndex < 4) { + final wCurrent = weeklyStats[weekIndex]!; + weeklyStats[weekIndex] = ( + wCurrent.$1 + cost, + wCurrent.$2 + 1, + wCurrent.$3 + shiftTotalHours, + ); + } + } + } + + final List chartData = dailyStats.entries.map((e) { + return ForecastPoint( + date: e.key, + projectedCost: e.value.$1, + workersNeeded: e.value.$2, + ); + }).toList()..sort((a, b) => a.date.compareTo(b.date)); + + final List weeklyBreakdown = []; + for (int i = 0; i < 4; i++) { + final 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 weeksCount = (endDate.difference(startDate).inDays / 7).ceil(); + final 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 response = await _service.connector + .listShiftsForPerformanceByBusiness( + businessId: id, + startDate: _service.toTimestamp(startDate), + endDate: _service.toTimestamp(endDate), + ) + .execute(); + + final shifts = response.data.shifts; + + int totalNeeded = 0; + int totalFilled = 0; + int completedCount = 0; + double totalFillTimeSeconds = 0.0; + int filledShiftsWithTime = 0; + + for (final 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 createdAt = shift.createdAt!.toDateTime(); + final 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 shiftsResponse = await _service.connector + .listShiftsForNoShowRangeByBusiness( + businessId: id, + startDate: _service.toTimestamp(startDate), + endDate: _service.toTimestamp(endDate), + ) + .execute(); + + final shiftIds = shiftsResponse.data.shifts.map((s) => s.id).toList(); + if (shiftIds.isEmpty) { + return const NoShowReport(totalNoShows: 0, noShowRate: 0, flaggedWorkers: []); + } + + final appsResponse = await _service.connector + .listApplicationsForNoShowRange(shiftIds: shiftIds) + .execute(); + + final apps = appsResponse.data.applications; + final noShowApps = apps.where((a) => (a.status.stringValue) == 'NO_SHOW').toList(); + final noShowStaffIds = noShowApps.map((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 staffResponse = await _service.connector + .listStaffForNoShowReport(staffIds: noShowStaffIds) + .execute(); + + final staffList = staffResponse.data.staffs; + + final List flaggedWorkers = staffList.map((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 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 perfResponse = await _service.connector + .listShiftsForPerformanceByBusiness( + businessId: id, + startDate: _service.toTimestamp(startDate), + endDate: _service.toTimestamp(endDate), + ) + .execute(); + + final invoicesResponse = await _service.connector + .listInvoicesForSpendByBusiness( + businessId: id, + startDate: _service.toTimestamp(startDate), + endDate: _service.toTimestamp(endDate), + ) + .execute(); + + final forecastShifts = shiftsResponse.data.shifts; + final perfShifts = perfResponse.data.shifts; + final invoices = invoicesResponse.data.invoices; + + // Aggregate hours and fill rate from forecast shifts + double totalHours = 0; + int totalNeeded = 0; + + for (final 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 shift in perfShifts) { + perfNeeded += shift.workersNeeded ?? 0; + perfFilled += shift.filled ?? 0; + + if (shift.filledAt != null && shift.createdAt != null) { + final createdAt = shift.createdAt!.toDateTime(); + final filledAt = shift.filledAt!.toDateTime(); + totalFillTimeSeconds += filledAt.difference(createdAt).inSeconds; + filledShiftsWithTime++; + } + } + + // Aggregate total spend from invoices + double totalSpend = 0; + for (final inv in invoices) { + totalSpend += (inv.amount ?? 0).toDouble(); + } + + // Fetch no-show rate using forecast shift IDs + final shiftIds = forecastShifts.map((s) => s.id).toList(); + double noShowRate = 0; + if (shiftIds.isNotEmpty) { + final appsResponse = await _service.connector + .listApplicationsForNoShowRange(shiftIds: shiftIds) + .execute(); + final apps = appsResponse.data.applications; + final noShowApps = apps.where((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 new file mode 100644 index 00000000..14c44db9 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/reports/domain/repositories/reports_connector_repository.dart @@ -0,0 +1,55 @@ +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 new file mode 100644 index 00000000..dc862cea --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart @@ -0,0 +1,515 @@ +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 query = _service.connector + .getApplicationsByStaffId(staffId: staffId) + .dayStart(_service.toTimestamp(start)) + .dayEnd(_service.toTimestamp(end)); + + final 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 response = await _service.connector + .listShiftRolesByVendorId(vendorId: vendorId) + .execute(); + + final allShiftRoles = response.data.shiftRoles; + + // Fetch current applications to filter out already booked shifts + final myAppsResponse = await _service.connector + .getApplicationsByStaffId(staffId: staffId) + .execute(); + final Set appliedShiftIds = + myAppsResponse.data.applications.map((a) => a.shiftId).toSet(); + + final List mappedShifts = []; + for (final sr in allShiftRoles) { + if (appliedShiftIds.contains(sr.shiftId)) continue; + + final DateTime? shiftDate = _service.toDateTime(sr.shift.date); + final startDt = _service.toDateTime(sr.startTime); + final endDt = _service.toDateTime(sr.endTime); + final createdDt = _service.toDateTime(sr.createdAt); + + 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: startDt != null ? DateFormat('HH:mm').format(startDt) : '', + endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '', + createdDate: createdDt?.toIso8601String() ?? '', + status: sr.shift.status?.stringValue.toLowerCase() ?? 'open', + description: sr.shift.description, + durationDays: sr.shift.durationDays, + requiredSlots: sr.count, + filledSlots: sr.assigned ?? 0, + latitude: sr.shift.latitude, + longitude: sr.shift.longitude, + breakInfo: BreakAdapter.fromData( + isPaid: sr.isBreakPaid ?? false, + breakTime: sr.breakType?.stringValue, + ), + ), + ); + } + + if (query != null && query.isNotEmpty) { + final lowerQuery = query.toLowerCase(); + return mappedShifts.where((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 roleResult = await _service.connector + .getShiftRoleById(shiftId: shiftId, roleId: roleId) + .execute(); + final 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 appsResponse = await _service.connector + .getApplicationsByStaffId(staffId: staffId) + .execute(); + + final app = appsResponse.data.applications + .where((a) => a.shiftId == shiftId && a.shiftRole.roleId == roleId) + .firstOrNull; + + if (app != null) { + hasApplied = true; + final 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 result = await _service.connector.getShiftById(id: shiftId).execute(); + final s = result.data.shift; + if (s == null) return null; + + int? required; + int? filled; + Break? breakInfo; + + try { + final rolesRes = await _service.connector + .listShiftRolesByShiftId(shiftId: shiftId) + .execute(); + if (rolesRes.data.shiftRoles.isNotEmpty) { + required = 0; + filled = 0; + for (var r in rolesRes.data.shiftRoles) { + required = (required ?? 0) + r.count; + filled = (filled ?? 0) + (r.assigned ?? 0); + } + final firstRole = rolesRes.data.shiftRoles.first; + breakInfo = BreakAdapter.fromData( + isPaid: firstRole.isBreakPaid ?? false, + breakTime: firstRole.breakType?.stringValue, + ); + } + } catch (_) {} + + final startDt = _service.toDateTime(s.startTime); + final endDt = _service.toDateTime(s.endTime); + final 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 targetRoleId = roleId ?? ''; + if (targetRoleId.isEmpty) throw Exception('Missing role id.'); + + final roleResult = await _service.connector + .getShiftRoleById(shiftId: shiftId, roleId: targetRoleId) + .execute(); + final role = roleResult.data.shiftRole; + if (role == null) throw Exception('Shift role not found'); + + final shiftResult = await _service.connector.getShiftById(id: shiftId).execute(); + final shift = shiftResult.data.shift; + if (shift == null) throw Exception('Shift not found'); + + // Validate daily limit + final DateTime? shiftDate = _service.toDateTime(shift.date); + if (shiftDate != null) { + final DateTime dayStartUtc = DateTime.utc(shiftDate.year, shiftDate.month, shiftDate.day); + final DateTime dayEndUtc = dayStartUtc.add(const Duration(days: 1)).subtract(const Duration(microseconds: 1)); + + final 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 existingAppRes = await _service.connector + .getApplicationByStaffShiftAndRole( + staffId: staffId, + shiftId: shiftId, + roleId: targetRoleId, + ) + .execute(); + if (existingAppRes.data.applications.isNotEmpty) { + throw Exception('Application already exists.'); + } + + if ((role.assigned ?? 0) >= role.count) { + throw Exception('This shift is full.'); + } + + final int currentAssigned = role.assigned ?? 0; + final int currentFilled = shift.filled ?? 0; + + String? createdAppId; + try { + final createRes = await _service.connector.createApplication( + shiftId: shiftId, + staffId: staffId, + roleId: targetRoleId, + status: dc.ApplicationStatus.CONFIRMED, // Matches existing logic + origin: dc.ApplicationOrigin.STAFF, + ).execute(); + + createdAppId = createRes.data.application_insert.id; + + await _service.connector + .updateShiftRole(shiftId: shiftId, roleId: targetRoleId) + .assigned(currentAssigned + 1) + .execute(); + + await _service.connector + .updateShift(id: shiftId) + .filled(currentFilled + 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 response = await _service.connector + .listCompletedApplicationsByStaffId(staffId: staffId) + .execute(); + + final List shifts = []; + for (final 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 appsResponse = await _service.connector + .getApplicationsByStaffId(staffId: staffId) + .execute(); + + final app = appsResponse.data.applications + .where((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 rolesRes = await _service.connector + .listShiftRolesByShiftId(shiftId: shiftId) + .execute(); + + if (rolesRes.data.shiftRoles.isNotEmpty) { + final 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"); + } + }); + } +} 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 new file mode 100644 index 00000000..bb8b50af --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/shifts/domain/repositories/shifts_connector_repository.dart @@ -0,0 +1,56 @@ +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/data_connect_module.dart b/apps/mobile/packages/data_connect/lib/src/data_connect_module.dart index 5704afb6..0f234576 100644 --- a/apps/mobile/packages/data_connect/lib/src/data_connect_module.dart +++ b/apps/mobile/packages/data_connect/lib/src/data_connect_module.dart @@ -1,4 +1,16 @@ 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/home/domain/repositories/home_connector_repository.dart'; +import 'connectors/home/data/repositories/home_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. @@ -6,5 +18,25 @@ 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( + HomeConnectorRepositoryImpl.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 index 19799467..6d77df28 100644 --- 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 @@ -1,12 +1,23 @@ -import 'dart:async'; - -import 'package:firebase_auth/firebase_auth.dart' as firebase_auth; +import 'package:firebase_auth/firebase_auth.dart' as firebase; import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; -import 'package:flutter/material.dart'; -import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; +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 '../../krow_data_connect.dart' as dc; +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/home/domain/repositories/home_connector_repository.dart'; +import '../connectors/home/data/repositories/home_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'; @@ -22,176 +33,203 @@ class DataConnectService with DataErrorHandler, SessionHandlerMixin { /// The Data Connect connector used for data operations. final dc.ExampleConnector connector = dc.ExampleConnector.instance; - /// The Firebase Auth instance. - firebase_auth.FirebaseAuth get auth => _auth; - final firebase_auth.FirebaseAuth _auth = firebase_auth.FirebaseAuth.instance; + // Repositories + ReportsConnectorRepository? _reportsRepository; + ShiftsConnectorRepository? _shiftsRepository; + HubsConnectorRepository? _hubsRepository; + BillingConnectorRepository? _billingRepository; + HomeConnectorRepository? _homeRepository; + CoverageConnectorRepository? _coverageRepository; + StaffConnectorRepository? _staffRepository; - /// Cache for the current staff ID to avoid redundant lookups. - String? _cachedStaffId; + /// Gets the reports connector repository. + ReportsConnectorRepository getReportsRepository() { + return _reportsRepository ??= ReportsConnectorRepositoryImpl(service: this); + } - /// Cache for the current business ID to avoid redundant lookups. - String? _cachedBusinessId; + /// Gets the shifts connector repository. + ShiftsConnectorRepository getShiftsRepository() { + return _shiftsRepository ??= ShiftsConnectorRepositoryImpl(service: this); + } - /// Gets the current staff ID from session store or persistent storage. + /// 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 home connector repository. + HomeConnectorRepository getHomeRepository() { + return _homeRepository ??= HomeConnectorRepositoryImpl(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 { - // 1. Check Session Store - final dc.StaffSession? session = dc.StaffSessionStore.instance.session; - if (session?.staff?.id != null) { - return session!.staff!.id; - } - - // 2. Check Cache - if (_cachedStaffId != null) return _cachedStaffId!; - - // 3. Fetch from Data Connect using Firebase UID - final firebase_auth.User? user = _auth.currentUser; - if (user == null) { - throw const NotAuthenticatedException( - technicalMessage: 'User is not authenticated', - ); - } - - try { - final fdc.QueryResult< - dc.GetStaffByUserIdData, - dc.GetStaffByUserIdVariables - > - response = await executeProtected( - () => connector.getStaffByUserId(userId: user.uid).execute(), - ); - - if (response.data.staffs.isNotEmpty) { - _cachedStaffId = response.data.staffs.first.id; - return _cachedStaffId!; + String? staffId = dc.StaffSessionStore.instance.session?.ownerId; + + 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?.ownerId; } - } catch (e) { - throw Exception('Failed to fetch staff ID from Data Connect: $e'); } - // 4. Fallback (should ideally not happen if DB is seeded) - return user.uid; + if (staffId == null || staffId.isEmpty) { + throw Exception('No staff ID found in session.'); + } + return staffId; } - /// Gets the current business ID from session store or persistent storage. + /// Helper to get the current business ID from the session. Future getBusinessId() async { - // 1. Check Session Store - final dc.ClientSession? session = dc.ClientSessionStore.instance.session; - if (session?.business?.id != null) { - return session!.business!.id; - } + String? businessId = dc.ClientSessionStore.instance.session?.business?.id; - // 2. Check Cache - if (_cachedBusinessId != null) return _cachedBusinessId!; - - // 3. Fetch from Data Connect using Firebase UID - final firebase_auth.User? user = _auth.currentUser; - if (user == null) { - throw const NotAuthenticatedException( - technicalMessage: 'User is not authenticated', - ); - } - - try { - final fdc.QueryResult< - dc.GetBusinessesByUserIdData, - dc.GetBusinessesByUserIdVariables - > - response = await executeProtected( - () => connector.getBusinessesByUserId(userId: user.uid).execute(), - ); - - if (response.data.businesses.isNotEmpty) { - _cachedBusinessId = response.data.businesses.first.id; - return _cachedBusinessId!; + 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; } - } catch (e) { - throw Exception('Failed to fetch business ID from Data Connect: $e'); } - // 4. Fallback (should ideally not happen if DB is seeded) - return user.uid; + if (businessId == null || businessId.isEmpty) { + throw Exception('No business ID found in session.'); + } + return businessId; } - /// Converts a Data Connect timestamp/string/json to a [DateTime]. - DateTime? toDateTime(dynamic t) { - if (t == null) return null; - DateTime? dt; - if (t is fdc.Timestamp) { - dt = t.toDateTime(); - } else if (t is String) { - dt = DateTime.tryParse(t); - } else { - try { - dt = DateTime.tryParse(t.toJson() as String); - } catch (_) { - try { - dt = DateTime.tryParse(t.toString()); - } catch (e) { - dt = null; + /// 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.id, + 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, + ), + ), + ); } } - } - if (dt != null) { - return DateTimeUtils.toDeviceTime(dt); + // 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]. + DateTime? toDateTime(dynamic timestamp) { + if (timestamp == null) return null; + if (timestamp is fdc.Timestamp) { + return timestamp.toDateTime(); } return null; } - /// Converts a [DateTime] to a Firebase Data Connect [Timestamp]. + /// Converts a Dart [DateTime] to a Data Connect [Timestamp]. fdc.Timestamp toTimestamp(DateTime dateTime) { final DateTime utc = dateTime.toUtc(); - final int seconds = utc.millisecondsSinceEpoch ~/ 1000; - final int nanoseconds = (utc.microsecondsSinceEpoch % 1000000) * 1000; - return fdc.Timestamp(nanoseconds, seconds); + final int millis = utc.millisecondsSinceEpoch; + final int seconds = millis ~/ 1000; + final int nanos = (millis % 1000) * 1000000; + return fdc.Timestamp(nanos, seconds); } - // --- 3. Unified Execution --- - // Repositories call this to benefit from centralized error handling/logging + /// 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. + @override Future run( - Future Function() action, { + Future Function() operation, { bool requiresAuthentication = true, }) async { - if (requiresAuthentication && auth.currentUser == null) { - throw const NotAuthenticatedException( - technicalMessage: 'User must be authenticated to perform this action', - ); - } - - return executeProtected(() async { - // Ensure session token is valid and refresh if needed + if (requiresAuthentication) { await ensureSessionValid(); - return action(); - }); - } - - /// Clears the internal cache (e.g., on logout). - void clearCache() { - _cachedStaffId = null; - _cachedBusinessId = null; - } - - /// Handle session sign-out by clearing caches. - void handleSignOut() { - clearCache(); + } + return executeProtected(operation); } + /// Implementation for SessionHandlerMixin. @override Future fetchUserRole(String userId) async { try { - final fdc.QueryResult - response = await executeProtected( - () => connector.getUserById(id: userId).execute(), - ); + final response = await connector.getUserById(id: userId).execute(); return response.data.user?.userRole; } catch (e) { - debugPrint('Failed to fetch user role: $e'); return null; } } - /// Dispose all resources (call on app shutdown). - Future dispose() async { - await disposeSessionHandler(); + /// Clears Cached Repositories and Session data. + void clearCache() { + _reportsRepository = null; + _shiftsRepository = null; + _hubsRepository = null; + _billingRepository = null; + _homeRepository = 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/session_handler_mixin.dart b/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart index 393f4b8a..d04a2cb3 100644 --- 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 @@ -96,7 +96,7 @@ mixin SessionHandlerMixin { _authStateSubscription = auth.authStateChanges().listen( (firebase_auth.User? user) async { if (user == null) { - _handleSignOut(); + handleSignOut(); } else { await _handleSignIn(user); } @@ -235,7 +235,7 @@ mixin SessionHandlerMixin { } /// Handle user sign-out event. - void _handleSignOut() { + void handleSignOut() { _emitSessionState(SessionState.unauthenticated()); } diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index c604550c..e1ca4d10 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -57,6 +57,7 @@ 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/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'; @@ -111,3 +112,12 @@ export 'src/adapters/financial/payment_adapter.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/entities/financial/billing_period.dart b/apps/mobile/packages/domain/lib/src/entities/financial/billing_period.dart new file mode 100644 index 00000000..c26a4108 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/financial/billing_period.dart @@ -0,0 +1,8 @@ +/// Defines the period for billing calculations. +enum BillingPeriod { + /// Weekly billing period. + week, + + /// Monthly billing period. + month, +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/entities/coverage_report.dart b/apps/mobile/packages/domain/lib/src/entities/reports/coverage_report.dart similarity index 100% rename from apps/mobile/packages/features/client/reports/lib/src/domain/entities/coverage_report.dart rename to apps/mobile/packages/domain/lib/src/entities/reports/coverage_report.dart diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/entities/daily_ops_report.dart b/apps/mobile/packages/domain/lib/src/entities/reports/daily_ops_report.dart similarity index 100% rename from apps/mobile/packages/features/client/reports/lib/src/domain/entities/daily_ops_report.dart rename to apps/mobile/packages/domain/lib/src/entities/reports/daily_ops_report.dart 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 new file mode 100644 index 00000000..a9861aaf --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/reports/forecast_report.dart @@ -0,0 +1,77 @@ +import 'package:equatable/equatable.dart'; + +class ForecastReport extends Equatable { + final double projectedSpend; + final int projectedWorkers; + final double averageLaborCost; + final List chartData; + + // New fields for the updated design + final int totalShifts; + final double totalHours; + final double avgWeeklySpend; + final List weeklyBreakdown; + + 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 [], + }); + + @override + List get props => [ + projectedSpend, + projectedWorkers, + averageLaborCost, + chartData, + totalShifts, + totalHours, + avgWeeklySpend, + weeklyBreakdown, + ]; +} + +class ForecastPoint extends Equatable { + final DateTime date; + final double projectedCost; + final int workersNeeded; + + const ForecastPoint({ + required this.date, + required this.projectedCost, + required this.workersNeeded, + }); + + @override + List get props => [date, projectedCost, workersNeeded]; +} + +class ForecastWeek extends Equatable { + final int weekNumber; + final double totalCost; + final int shiftsCount; + final double hoursCount; + final double avgCostPerShift; + + const ForecastWeek({ + required this.weekNumber, + required this.totalCost, + required this.shiftsCount, + required this.hoursCount, + required this.avgCostPerShift, + }); + + @override + List get props => [ + weekNumber, + totalCost, + shiftsCount, + hoursCount, + avgCostPerShift, + ]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/entities/no_show_report.dart b/apps/mobile/packages/domain/lib/src/entities/reports/no_show_report.dart similarity index 100% rename from apps/mobile/packages/features/client/reports/lib/src/domain/entities/no_show_report.dart rename to apps/mobile/packages/domain/lib/src/entities/reports/no_show_report.dart diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/entities/performance_report.dart b/apps/mobile/packages/domain/lib/src/entities/reports/performance_report.dart similarity index 100% rename from apps/mobile/packages/features/client/reports/lib/src/domain/entities/performance_report.dart rename to apps/mobile/packages/domain/lib/src/entities/reports/performance_report.dart diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/entities/reports_summary.dart b/apps/mobile/packages/domain/lib/src/entities/reports/reports_summary.dart similarity index 100% rename from apps/mobile/packages/features/client/reports/lib/src/domain/entities/reports_summary.dart rename to apps/mobile/packages/domain/lib/src/entities/reports/reports_summary.dart diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/entities/spend_report.dart b/apps/mobile/packages/domain/lib/src/entities/reports/spend_report.dart similarity index 99% rename from apps/mobile/packages/features/client/reports/lib/src/domain/entities/spend_report.dart rename to apps/mobile/packages/domain/lib/src/entities/reports/spend_report.dart index 3e342c00..55ea1a83 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/domain/entities/spend_report.dart +++ b/apps/mobile/packages/domain/lib/src/entities/reports/spend_report.dart @@ -8,6 +8,7 @@ class SpendReport extends Equatable { final int overdueInvoices; final List invoices; final List chartData; + final List industryBreakdown; const SpendReport({ required this.totalSpend, @@ -20,8 +21,6 @@ class SpendReport extends Equatable { required this.industryBreakdown, }); - final List industryBreakdown; - @override List get props => [ totalSpend, @@ -57,6 +56,7 @@ class SpendInvoice extends Equatable { final double amount; final String status; final String vendorName; + final String? industry; const SpendInvoice({ required this.id, @@ -68,8 +68,6 @@ class SpendInvoice extends Equatable { this.industry, }); - final String? industry; - @override List get props => [id, invoiceNumber, issueDate, amount, status, vendorName, industry]; } 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 95578127..84ee0e03 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,261 +1,58 @@ -import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; -import 'package:krow_data_connect/krow_data_connect.dart' as data_connect; +import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/models/billing_period.dart'; import '../../domain/repositories/billing_repository.dart'; -/// Implementation of [BillingRepository] in the Data layer. +/// Implementation of [BillingRepository] that delegates to [dc.BillingConnectorRepository]. /// -/// This class is responsible for retrieving billing data from the -/// Data Connect layer and mapping it to Domain entities. +/// This implementation follows the "Buffer Layer" pattern by using a dedicated +/// connector repository from the data_connect package. class BillingRepositoryImpl implements BillingRepository { - /// Creates a [BillingRepositoryImpl]. + final dc.BillingConnectorRepository _connectorRepository; + final dc.DataConnectService _service; + BillingRepositoryImpl({ - data_connect.DataConnectService? service, - }) : _service = service ?? data_connect.DataConnectService.instance; + dc.BillingConnectorRepository? connectorRepository, + dc.DataConnectService? service, + }) : _connectorRepository = connectorRepository ?? + dc.DataConnectService.instance.getBillingRepository(), + _service = service ?? dc.DataConnectService.instance; - final data_connect.DataConnectService _service; - - /// Fetches bank accounts associated with the business. @override Future> getBankAccounts() async { - return _service.run(() async { - final String businessId = await _service.getBusinessId(); - - final fdc.QueryResult< - data_connect.GetAccountsByOwnerIdData, - data_connect.GetAccountsByOwnerIdVariables> result = - await _service.connector - .getAccountsByOwnerId(ownerId: businessId) - .execute(); - - return result.data.accounts.map(_mapBankAccount).toList(); - }); + final businessId = await _service.getBusinessId(); + return _connectorRepository.getBankAccounts(businessId: businessId); } - /// Fetches the current bill amount by aggregating open invoices. @override Future getCurrentBillAmount() async { - return _service.run(() async { - final String businessId = await _service.getBusinessId(); - - final fdc.QueryResult 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, - ); - }); + final businessId = await _service.getBusinessId(); + return _connectorRepository.getCurrentBillAmount(businessId: businessId); } - /// Fetches the history of paid invoices. @override Future> getInvoiceHistory() async { - return _service.run(() async { - final String businessId = await _service.getBusinessId(); - - final fdc.QueryResult result = - await _service.connector - .listInvoicesByBusinessId( - businessId: businessId, - ) - .limit(10) - .execute(); - - return result.data.invoices.map(_mapInvoice).toList(); - }); + final businessId = await _service.getBusinessId(); + return _connectorRepository.getInvoiceHistory(businessId: businessId); } - /// Fetches pending invoices (Open or Disputed). @override Future> getPendingInvoices() async { - return _service.run(() async { - final String businessId = await _service.getBusinessId(); - - final fdc.QueryResult result = - await _service.connector - .listInvoicesByBusinessId(businessId: businessId) - .execute(); - - return result.data.invoices - .map(_mapInvoice) - .where( - (Invoice i) => - i.status == InvoiceStatus.open || - i.status == InvoiceStatus.disputed, - ) - .toList(); - }); + final businessId = await _service.getBusinessId(); + return _connectorRepository.getPendingInvoices(businessId: businessId); } - /// Fetches the estimated savings amount. @override Future getSavingsAmount() async { - // Simulating savings calculation (e.g., comparing to market rates). - await Future.delayed(const Duration(milliseconds: 0)); + // Simulating savings calculation return 0.0; } - /// Fetches the breakdown of spending. @override Future> getSpendingBreakdown(BillingPeriod period) async { - return _service.run(() async { - final String businessId = await _service.getBusinessId(); - - 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 = DateTime(monday.year, monday.month, monday.day); - end = DateTime( - monday.year, monday.month, monday.day + 6, 23, 59, 59, 999); - } else { - start = DateTime(now.year, now.month, 1); - end = DateTime(now.year, now.month + 1, 0, 23, 59, 59, 999); - } - - final fdc.QueryResult< - data_connect.ListShiftRolesByBusinessAndDatesSummaryData, - data_connect.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 data_connect - .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(); - }); - } - - Invoice _mapInvoice(data_connect.ListInvoicesByBusinessIdInvoices invoice) { - return Invoice( - id: invoice.id, - eventId: invoice.orderId, - businessId: invoice.businessId, - status: _mapInvoiceStatus(invoice.status), - totalAmount: invoice.amount, - workAmount: invoice.amount, - addonsAmount: invoice.otherCharges ?? 0, - invoiceNumber: invoice.invoiceNumber, - issueDate: _service.toDateTime(invoice.issueDate)!, - ); - } - - BusinessBankAccount _mapBankAccount( - data_connect.GetAccountsByOwnerIdAccounts account, - ) { - return BusinessBankAccountAdapter.fromPrimitives( - id: account.id, - bank: account.bank, - last4: account.last4, - isPrimary: account.isPrimary ?? false, - expiryTime: _service.toDateTime(account.expiryTime), - ); - } - - InvoiceStatus _mapInvoiceStatus( - data_connect.EnumValue status, - ) { - if (status is data_connect.Known) { - switch (status.value) { - case data_connect.InvoiceStatus.PAID: - return InvoiceStatus.paid; - case data_connect.InvoiceStatus.OVERDUE: - return InvoiceStatus.overdue; - case data_connect.InvoiceStatus.DISPUTED: - return InvoiceStatus.disputed; - case data_connect.InvoiceStatus.APPROVED: - return InvoiceStatus.verified; - case data_connect.InvoiceStatus.PENDING_REVIEW: - case data_connect.InvoiceStatus.PENDING: - case data_connect.InvoiceStatus.DRAFT: - return InvoiceStatus.open; - } - } - 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, + final businessId = await _service.getBusinessId(); + return _connectorRepository.getSpendingBreakdown( + businessId: businessId, + period: period, ); } } diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/models/billing_period.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/models/billing_period.dart deleted file mode 100644 index a3ea057b..00000000 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/models/billing_period.dart +++ /dev/null @@ -1,4 +0,0 @@ -enum BillingPeriod { - week, - month, -} 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 d631a40b..26d64a42 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 @@ -1,5 +1,4 @@ import 'package:krow_domain/krow_domain.dart'; -import '../models/billing_period.dart'; /// Repository interface for billing related operations. /// 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 09193e70..69e4c34b 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,6 +1,5 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../models/billing_period.dart'; import '../repositories/billing_repository.dart'; /// Use case for fetching the spending breakdown items. 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 f27060dc..1b6996fe 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,5 @@ import 'package:equatable/equatable.dart'; -import '../../domain/models/billing_period.dart'; +import 'package:krow_domain/krow_domain.dart'; /// Base class for all billing events. abstract class BillingEvent extends Equatable { 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 ef3ba019..98d8d0fd 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,6 +1,5 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/models/billing_period.dart'; import '../models/billing_invoice_model.dart'; import '../models/spending_breakdown_model.dart'; 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 8f47c604..45b5f670 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 @@ -2,7 +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 '../../domain/models/billing_period.dart'; +import 'package:krow_domain/krow_domain.dart'; import '../blocs/billing_bloc.dart'; import '../blocs/billing_state.dart'; import '../blocs/billing_event.dart'; 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 8dec3263..2a446dea 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,68 +1,35 @@ -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 '../../domain/repositories/coverage_repository.dart'; -/// Implementation of [CoverageRepository] in the Data layer. +/// Implementation of [CoverageRepository] that delegates to [dc.CoverageConnectorRepository]. /// -/// This class provides mock data for the coverage feature. -/// In a production environment, this would delegate to `packages/data_connect` -/// for real data access (e.g., Firebase Data Connect, REST API). -/// -/// It strictly adheres to the Clean Architecture data layer responsibilities: -/// - No business logic (except necessary data transformation). -/// - Delegates to data sources (currently mock data, will be `data_connect`). -/// - Returns domain entities from `domain/ui_entities`. +/// This implementation follows the "Buffer Layer" pattern by using a dedicated +/// connector repository from the data_connect package. class CoverageRepositoryImpl implements CoverageRepository { - /// Creates a [CoverageRepositoryImpl]. - CoverageRepositoryImpl({required dc.DataConnectService service}) : _service = service; - + final dc.CoverageConnectorRepository _connectorRepository; final dc.DataConnectService _service; - /// Fetches shifts for a specific date. + CoverageRepositoryImpl({ + dc.CoverageConnectorRepository? connectorRepository, + dc.DataConnectService? service, + }) : _connectorRepository = connectorRepository ?? + dc.DataConnectService.instance.getCoverageRepository(), + _service = service ?? dc.DataConnectService.instance; + @override Future> getShiftsForDate({required DateTime date}) async { - return _service.run(() async { - final String businessId = await _service.getBusinessId(); - - 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 fdc.QueryResult shiftRolesResult = - await _service.connector - .listShiftRolesByBusinessAndDateRange( - businessId: businessId, - start: _service.toTimestamp(start), - end: _service.toTimestamp(end), - ) - .execute(); - - final fdc.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, - ); - }); + final businessId = await _service.getBusinessId(); + return _connectorRepository.getShiftsForDate( + businessId: businessId, + date: date, + ); } - /// Fetches coverage statistics for a specific date. @override Future getCoverageStats({required DateTime date}) async { - // Get shifts for the date final List shifts = await getShiftsForDate(date: date); - // Calculate statistics final int totalNeeded = shifts.fold( 0, (int sum, CoverageShift shift) => sum + shift.workersNeeded, @@ -90,129 +57,4 @@ class CoverageRepositoryImpl implements CoverageRepository { late: late, ); } - - List _mapCoverageShifts( - List shiftRoles, - List applications, - DateTime date, - ) { - if (shiftRoles.isEmpty && applications.isEmpty) { - return []; - } - - final Map groups = {}; - for (final dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole - in shiftRoles) { - final String key = '${shiftRole.shiftId}:${shiftRole.roleId}'; - groups[key] = _CoverageGroup( - shiftId: shiftRole.shiftId, - roleId: shiftRole.roleId, - title: shiftRole.role.name, - location: shiftRole.shift.location ?? shiftRole.shift.locationAddress ?? '', - startTime: _formatTime(shiftRole.startTime) ?? '00:00', - workersNeeded: shiftRole.count, - date: shiftRole.shift.date?.toDateTime() ?? date, - workers: [], - ); - } - - for (final dc.ListStaffsApplicationsByBusinessForDayApplications app - in applications) { - final String key = '${app.shiftId}:${app.roleId}'; - final _CoverageGroup existing = groups[key] ?? - _CoverageGroup( - shiftId: app.shiftId, - roleId: app.roleId, - title: app.shiftRole.role.name, - location: app.shiftRole.shift.location ?? - app.shiftRole.shift.locationAddress ?? - '', - startTime: _formatTime(app.shiftRole.startTime) ?? '00:00', - workersNeeded: app.shiftRole.count, - date: app.shiftRole.shift.date?.toDateTime() ?? date, - workers: [], - ); - - existing.workers.add( - CoverageWorker( - name: app.staff.fullName, - status: _mapWorkerStatus(app.status), - checkInTime: _formatTime(app.checkInTime), - ), - ); - groups[key] = existing; - } - - return groups.values - .map( - (_CoverageGroup group) => CoverageShift( - id: '${group.shiftId}:${group.roleId}', - title: group.title, - location: group.location, - startTime: group.startTime, - workersNeeded: group.workersNeeded, - date: group.date, - workers: group.workers, - ), - ) - .toList(); - } - - CoverageWorkerStatus _mapWorkerStatus( - dc.EnumValue status, - ) { - if (status is dc.Known) { - switch (status.value) { - case dc.ApplicationStatus.PENDING: - return CoverageWorkerStatus.pending; - case dc.ApplicationStatus.REJECTED: - return CoverageWorkerStatus.rejected; - case dc.ApplicationStatus.CONFIRMED: - return CoverageWorkerStatus.confirmed; - case dc.ApplicationStatus.CHECKED_IN: - return CoverageWorkerStatus.checkedIn; - case dc.ApplicationStatus.CHECKED_OUT: - return CoverageWorkerStatus.checkedOut; - case dc.ApplicationStatus.LATE: - return CoverageWorkerStatus.late; - case dc.ApplicationStatus.NO_SHOW: - return CoverageWorkerStatus.noShow; - case dc.ApplicationStatus.COMPLETED: - return CoverageWorkerStatus.completed; - } - } - return CoverageWorkerStatus.pending; - } - - String? _formatTime(fdc.Timestamp? timestamp) { - if (timestamp == null) { - return null; - } - final DateTime date = timestamp.toDateTime().toLocal(); - final String hour = date.hour.toString().padLeft(2, '0'); - final String minute = date.minute.toString().padLeft(2, '0'); - return '$hour:$minute'; - } -} - -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/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 7d89f676..51181cf0 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,119 +1,26 @@ -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 '../../domain/repositories/home_repository_interface.dart'; -/// Implementation of [HomeRepositoryInterface] that delegates to [HomeRepositoryMock]. +/// Implementation of [HomeRepositoryInterface] that delegates to [dc.HomeConnectorRepository]. /// -/// This implementation resides in the data layer and acts as a bridge between the -/// domain layer and the data source (in this case, a mock from data_connect). +/// This implementation follows the "Buffer Layer" pattern by using a dedicated +/// connector repository from the data_connect package. class HomeRepositoryImpl implements HomeRepositoryInterface { - /// Creates a [HomeRepositoryImpl]. - HomeRepositoryImpl(this._service); + final dc.HomeConnectorRepository _connectorRepository; final dc.DataConnectService _service; + HomeRepositoryImpl({ + dc.HomeConnectorRepository? connectorRepository, + dc.DataConnectService? service, + }) : _connectorRepository = connectorRepository ?? + dc.DataConnectService.instance.getHomeRepository(), + _service = service ?? dc.DataConnectService.instance; + @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 = DateTime( - monday.year, - monday.month, - monday.day, - ); - final DateTime weekRangeEnd = DateTime( - monday.year, - monday.month, - monday.day + 13, - 23, - 59, - 59, - 999, - ); - final fdc.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 = shift.date?.toDateTime(); - 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 = DateTime( - now.year, - now.month, - now.day, - 23, - 59, - 59, - 999, - ); - - final fdc.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, - ); - }); + final businessId = await _service.getBusinessId(); + return _connectorRepository.getDashboardData(businessId: businessId); } @override @@ -121,7 +28,6 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { final dc.ClientSession? session = dc.ClientSessionStore.instance.session; final dc.ClientBusinessSession? business = session?.business; - // If session data is available, return it immediately if (business != null) { return UserSessionData( businessName: business.businessName, @@ -130,74 +36,38 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { } return await _service.run(() async { - // If session is not initialized, attempt to fetch business data to populate session final String businessId = await _service.getBusinessId(); - final fdc.QueryResult - businessResult = await _service.connector + final businessResult = await _service.connector .getBusinessById(id: businessId) .execute(); - if (businessResult.data.business == null) { + final b = businessResult.data.business; + if (b == null) { throw Exception('Business data not found for ID: $businessId'); } - final dc.ClientSession updatedSession = dc.ClientSession( + final updatedSession = dc.ClientSession( business: dc.ClientBusinessSession( - id: businessResult.data.business!.id, - businessName: businessResult.data.business?.businessName ?? '', - email: businessResult.data.business?.email ?? '', - city: businessResult.data.business?.city ?? '', - contactName: businessResult.data.business?.contactName ?? '', - companyLogoUrl: businessResult.data.business?.companyLogoUrl, + 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: businessResult.data.business!.businessName, - photoUrl: businessResult.data.business!.companyLogoUrl, + 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 fdc.Timestamp startTimestamp = _service.toTimestamp(start); - final fdc.Timestamp endTimestamp = _service.toTimestamp(now); - - final fdc.QueryResult< - dc.ListShiftRolesByBusinessDateRangeCompletedOrdersData, - dc.ListShiftRolesByBusinessDateRangeCompletedOrdersVariables - > - result = await _service.connector - .listShiftRolesByBusinessDateRangeCompletedOrders( - businessId: businessId, - start: startTimestamp, - end: endTimestamp, - ) - .execute(); - - return result.data.shiftRoles.map(( - dc.ListShiftRolesByBusinessDateRangeCompletedOrdersShiftRoles shiftRole, - ) { - final String location = - shiftRole.shift.location ?? shiftRole.shift.locationAddress ?? ''; - final String type = shiftRole.shift.order.orderType.stringValue; - return ReorderItem( - orderId: shiftRole.shift.order.id, - title: '${shiftRole.role.name} - ${shiftRole.shift.title}', - location: location, - hourlyRate: shiftRole.role.costPerHour, - hours: shiftRole.hours ?? 0, - workers: shiftRole.count, - type: type, - ); - }).toList(); - }); + final businessId = await _service.getBusinessId(); + return _connectorRepository.getRecentReorders(businessId: businessId); } } 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 c79d15cd..162ebf1e 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,38 +1,30 @@ -import 'dart:convert'; - -import 'package:firebase_auth/firebase_auth.dart' as firebase; -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' as domain; -import 'package:krow_domain/krow_domain.dart' - show - HubHasOrdersException, - BusinessNotFoundException, - NotAuthenticatedException; - +import 'package:krow_domain/krow_domain.dart'; import '../../domain/repositories/hub_repository_interface.dart'; -/// Implementation of [HubRepositoryInterface] backed by Data Connect. +/// Implementation of [HubRepositoryInterface] that delegates to [dc.HubsConnectorRepository]. +/// +/// This implementation follows the "Buffer Layer" pattern by using a dedicated +/// connector repository from the data_connect package. class HubRepositoryImpl implements HubRepositoryInterface { - HubRepositoryImpl({required dc.DataConnectService service}) - : _service = service; - + final dc.HubsConnectorRepository _connectorRepository; final dc.DataConnectService _service; + HubRepositoryImpl({ + dc.HubsConnectorRepository? connectorRepository, + dc.DataConnectService? service, + }) : _connectorRepository = connectorRepository ?? + dc.DataConnectService.instance.getHubsRepository(), + _service = service ?? dc.DataConnectService.instance; + @override - Future> getHubs() async { - return _service.run(() async { - final dc.GetBusinessesByUserIdBusinesses business = - await _getBusinessForCurrentUser(); - final String teamId = await _getOrCreateTeamId(business); - return _fetchHubsForTeam(teamId: teamId, businessId: business.id); - }); + Future> getHubs() async { + final businessId = await _service.getBusinessId(); + return _connectorRepository.getHubs(businessId: businessId); } @override - Future createHub({ + Future createHub({ required String name, required String address, String? placeId, @@ -44,77 +36,26 @@ class HubRepositoryImpl implements HubRepositoryInterface { String? country, String? zipCode, }) async { - return _service.run(() async { - final dc.GetBusinessesByUserIdBusinesses business = - await _getBusinessForCurrentUser(); - final String teamId = await _getOrCreateTeamId(business); - final _PlaceAddress? placeAddress = placeId == null || placeId.isEmpty - ? null - : await _fetchPlaceAddress(placeId); - final String? cityValue = city ?? placeAddress?.city ?? business.city; - final String? stateValue = state ?? placeAddress?.state; - final String? streetValue = street ?? placeAddress?.street; - final String? countryValue = country ?? placeAddress?.country; - final String? zipCodeValue = zipCode ?? placeAddress?.zipCode; - - final OperationResult - result = await _service.connector - .createTeamHub(teamId: teamId, hubName: name, address: address) - .placeId(placeId) - .latitude(latitude) - .longitude(longitude) - .city(cityValue?.isNotEmpty == true ? cityValue : '') - .state(stateValue) - .street(streetValue) - .country(countryValue) - .zipCode(zipCodeValue) - .execute(); - final String createdId = result.data.teamHub_insert.id; - - final List hubs = await _fetchHubsForTeam( - teamId: teamId, - businessId: business.id, - ); - domain.Hub? createdHub; - for (final domain.Hub hub in hubs) { - if (hub.id == createdId) { - createdHub = hub; - break; - } - } - return createdHub ?? - domain.Hub( - id: createdId, - businessId: business.id, - name: name, - address: address, - nfcTagId: null, - status: domain.HubStatus.active, - ); - }); + final 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, + ); } @override Future deleteHub(String id) async { - return _service.run(() async { - final String businessId = await _service.getBusinessId(); - - final QueryResult< - dc.ListOrdersByBusinessAndTeamHubData, - dc.ListOrdersByBusinessAndTeamHubVariables - > - result = await _service.connector - .listOrdersByBusinessAndTeamHub(businessId: businessId, teamHubId: id) - .execute(); - - if (result.data.orders.isNotEmpty) { - throw HubHasOrdersException( - technicalMessage: 'Hub $id has ${result.data.orders.length} orders', - ); - } - - await _service.connector.deleteTeamHub(id: id).execute(); - }); + final businessId = await _service.getBusinessId(); + return _connectorRepository.deleteHub(businessId: businessId, id: id); } @override @@ -125,7 +66,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { } @override - Future updateHub({ + Future updateHub({ required String id, String? name, String? address, @@ -138,283 +79,20 @@ class HubRepositoryImpl implements HubRepositoryInterface { String? country, String? zipCode, }) async { - return _service.run(() async { - final _PlaceAddress? placeAddress = - placeId == null || placeId.isEmpty - ? null - : await _fetchPlaceAddress(placeId); - - final dc.UpdateTeamHubVariablesBuilder builder = _service.connector - .updateTeamHub(id: id); - - if (name != null) builder.hubName(name); - if (address != null) builder.address(address); - if (placeId != null || placeAddress != null) { - builder.placeId(placeId ?? placeAddress?.street); - } - 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(); - - final dc.GetBusinessesByUserIdBusinesses business = - await _getBusinessForCurrentUser(); - final String teamId = await _getOrCreateTeamId(business); - final List hubs = await _fetchHubsForTeam( - teamId: teamId, - businessId: business.id, - ); - - for (final domain.Hub hub in hubs) { - if (hub.id == id) return hub; - } - - // Fallback: return a reconstructed Hub from the update inputs. - return domain.Hub( - id: id, - businessId: business.id, - name: name ?? '', - address: address ?? '', - nfcTagId: null, - status: domain.HubStatus.active, - ); - }); - } - - Future - _getBusinessForCurrentUser() async { - final dc.ClientSession? session = dc.ClientSessionStore.instance.session; - final dc.ClientBusinessSession? cachedBusiness = session?.business; - if (cachedBusiness != null) { - return dc.GetBusinessesByUserIdBusinesses( - id: cachedBusiness.id, - businessName: cachedBusiness.businessName, - userId: _service.auth.currentUser?.uid ?? '', - rateGroup: const dc.Known( - dc.BusinessRateGroup.STANDARD, - ), - status: const dc.Known(dc.BusinessStatus.ACTIVE), - contactName: cachedBusiness.contactName, - companyLogoUrl: cachedBusiness.companyLogoUrl, - phone: null, - email: cachedBusiness.email, - hubBuilding: null, - address: null, - city: cachedBusiness.city, - area: null, - sector: null, - notes: null, - createdAt: null, - updatedAt: null, - ); - } - - final firebase.User? user = _service.auth.currentUser; - if (user == null) { - throw const NotAuthenticatedException( - technicalMessage: 'No Firebase user in currentUser', - ); - } - - final QueryResult< - dc.GetBusinessesByUserIdData, - dc.GetBusinessesByUserIdVariables - > - result = await _service.connector - .getBusinessesByUserId(userId: user.uid) - .execute(); - if (result.data.businesses.isEmpty) { - await _service.auth.signOut(); - throw BusinessNotFoundException( - technicalMessage: 'No business found for user ${user.uid}', - ); - } - - final dc.GetBusinessesByUserIdBusinesses business = - result.data.businesses.first; - if (session != null) { - dc.ClientSessionStore.instance.setSession( - dc.ClientSession( - business: dc.ClientBusinessSession( - id: business.id, - businessName: business.businessName, - email: business.email, - city: business.city, - contactName: business.contactName, - companyLogoUrl: business.companyLogoUrl, - ), - ), - ); - } - - return business; - } - - Future _getOrCreateTeamId( - dc.GetBusinessesByUserIdBusinesses business, - ) async { - final QueryResult - teamsResult = await _service.connector - .getTeamsByOwnerId(ownerId: business.id) - .execute(); - if (teamsResult.data.teams.isNotEmpty) { - return teamsResult.data.teams.first.id; - } - - final dc.CreateTeamVariablesBuilder createTeamBuilder = _service.connector - .createTeam( - teamName: '${business.businessName} Team', - ownerId: business.id, - ownerName: business.contactName ?? '', - ownerRole: 'OWNER', - ); - if (business.email != null) { - createTeamBuilder.email(business.email); - } - - final OperationResult - createTeamResult = await createTeamBuilder.execute(); - final String teamId = createTeamResult.data.team_insert.id; - - return teamId; - } - - Future> _fetchHubsForTeam({ - required String teamId, - required String businessId, - }) async { - final QueryResult< - dc.GetTeamHubsByTeamIdData, - dc.GetTeamHubsByTeamIdVariables - > - hubsResult = await _service.connector - .getTeamHubsByTeamId(teamId: teamId) - .execute(); - - return hubsResult.data.teamHubs - .map( - (dc.GetTeamHubsByTeamIdTeamHubs hub) => domain.Hub( - id: hub.id, - businessId: businessId, - name: hub.hubName, - address: hub.address, - nfcTagId: null, - status: hub.isActive - ? domain.HubStatus.active - : domain.HubStatus.inactive, - ), - ) - .toList(); - } - - 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, - }, + final 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, ); - 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; - String? route; - String? city; - String? state; - String? country; - String? zipCode; - - for (final dynamic 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('postal_town')) { - city ??= longName; - } else if (types.contains('administrative_area_level_2')) { - 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 streetValue = [streetNumber, route] - .where((String? value) => value != null && value.isNotEmpty) - .join(' ') - .trim(); - - return _PlaceAddress( - street: streetValue.isEmpty == true ? null : streetValue, - 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/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 d395f8b8..f3b76176 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,493 +1,89 @@ import 'package:krow_data_connect/krow_data_connect.dart'; -import '../../domain/entities/daily_ops_report.dart'; -import '../../domain/entities/spend_report.dart'; -import '../../domain/entities/coverage_report.dart'; -import '../../domain/entities/forecast_report.dart'; -import '../../domain/entities/performance_report.dart'; -import '../../domain/entities/no_show_report.dart'; -import '../../domain/entities/reports_summary.dart'; +import 'package:krow_domain/krow_domain.dart'; import '../../domain/repositories/reports_repository.dart'; +/// Implementation of [ReportsRepository] that delegates to [ReportsConnectorRepository]. +/// +/// This implementation follows the "Buffer Layer" pattern by using a dedicated +/// connector repository from the data_connect package. class ReportsRepositoryImpl implements ReportsRepository { - final DataConnectService _service; + final ReportsConnectorRepository _connectorRepository; - ReportsRepositoryImpl({DataConnectService? service}) - : _service = service ?? DataConnectService.instance; + ReportsRepositoryImpl({ReportsConnectorRepository? connectorRepository}) + : _connectorRepository = connectorRepository ?? DataConnectService.instance.getReportsRepository(); @override Future getDailyOpsReport({ String? businessId, required DateTime date, - }) async { - return await _service.run(() async { - final String id = businessId ?? await _service.getBusinessId(); - final response = await _service.connector - .listShiftsForDailyOpsByBusiness( - businessId: id, - date: _service.toTimestamp(date), - ) - .execute(); - - final shifts = response.data.shifts; - - int scheduledShifts = shifts.length; - int workersConfirmed = 0; - int inProgressShifts = 0; - int completedShifts = 0; - - final List dailyOpsShifts = []; - - for (final shift in shifts) { - workersConfirmed += shift.filled ?? 0; - final 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, + }) => _connectorRepository.getDailyOpsReport( + businessId: businessId, + date: date, ); - }); - } @override Future getSpendReport({ String? businessId, required DateTime startDate, required DateTime endDate, - }) async { - return await _service.run(() async { - final String id = businessId ?? await _service.getBusinessId(); - final response = await _service.connector - .listInvoicesForSpendByBusiness( - businessId: id, - startDate: _service.toTimestamp(startDate), - endDate: _service.toTimestamp(endDate), - ) - .execute(); - - final 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 inv in invoices) { - final amount = (inv.amount ?? 0.0).toDouble(); - totalSpend += amount; - - final statusStr = inv.status.stringValue; - if (statusStr == 'PAID') { - paidInvoices++; - } else if (statusStr == 'PENDING') { - pendingInvoices++; - } else if (statusStr == 'OVERDUE') { - overdueInvoices++; - } - - final industry = inv.vendor?.serviceSpecialty ?? 'Other'; - industryAggregates[industry] = (industryAggregates[industry] ?? 0.0) + amount; - - final 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 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 date = startDate.add(Duration(days: i)); - final normalizedDate = DateTime(date.year, date.month, date.day); - completeDailyAggregates[normalizedDate] = - dailyAggregates[normalizedDate] ?? 0.0; - } - - final List chartData = completeDailyAggregates.entries - .map((e) => SpendChartPoint(date: e.key, amount: e.value)) - .toList() - ..sort((a, b) => a.date.compareTo(b.date)); - - final List industryBreakdown = industryAggregates.entries - .map((e) => SpendIndustryCategory( - name: e.key, - amount: e.value, - percentage: totalSpend > 0 ? (e.value / totalSpend * 100) : 0, - )) - .toList() - ..sort((a, b) => b.amount.compareTo(a.amount)); - - final 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, + }) => _connectorRepository.getSpendReport( + businessId: businessId, + startDate: startDate, + endDate: endDate, ); - }); - } @override Future getCoverageReport({ String? businessId, required DateTime startDate, required DateTime endDate, - }) async { - return await _service.run(() async { - final String id = businessId ?? await _service.getBusinessId(); - final response = await _service.connector - .listShiftsForCoverage( - businessId: id, - startDate: _service.toTimestamp(startDate), - endDate: _service.toTimestamp(endDate), - ) - .execute(); - - final shifts = response.data.shifts; - - int totalNeeded = 0; - int totalFilled = 0; - final Map dailyStats = {}; - - for (final shift in shifts) { - final shiftDate = shift.date?.toDateTime() ?? DateTime.now(); - final date = DateTime(shiftDate.year, shiftDate.month, shiftDate.day); - - final needed = shift.workersNeeded ?? 0; - final filled = shift.filled ?? 0; - - totalNeeded += needed; - totalFilled += filled; - - final current = dailyStats[date] ?? (0, 0); - dailyStats[date] = (current.$1 + needed, current.$2 + filled); - } - - final List dailyCoverage = dailyStats.entries.map((e) { - final needed = e.value.$1; - final filled = e.value.$2; - return CoverageDay( - date: e.key, - needed: needed, - filled: filled, - percentage: needed == 0 ? 100.0 : (filled / needed) * 100.0, - ); - }).toList()..sort((a, b) => a.date.compareTo(b.date)); - - return CoverageReport( - overallCoverage: totalNeeded == 0 ? 100.0 : (totalFilled / totalNeeded) * 100.0, - totalNeeded: totalNeeded, - totalFilled: totalFilled, - dailyCoverage: dailyCoverage, + }) => _connectorRepository.getCoverageReport( + businessId: businessId, + startDate: startDate, + endDate: endDate, ); - }); - } @override Future getForecastReport({ String? businessId, required DateTime startDate, required DateTime endDate, - }) async { - return await _service.run(() async { - final String id = businessId ?? await _service.getBusinessId(); - final response = await _service.connector - .listShiftsForForecastByBusiness( - businessId: id, - startDate: _service.toTimestamp(startDate), - endDate: _service.toTimestamp(endDate), - ) - .execute(); - - final shifts = response.data.shifts; - - double projectedSpend = 0.0; - int projectedWorkers = 0; - final Map dailyStats = {}; - - for (final shift in shifts) { - final shiftDate = shift.date?.toDateTime() ?? DateTime.now(); - final date = DateTime(shiftDate.year, shiftDate.month, shiftDate.day); - - final cost = (shift.cost ?? 0.0).toDouble(); - final workers = shift.workersNeeded ?? 0; - - projectedSpend += cost; - projectedWorkers += workers; - - final current = dailyStats[date] ?? (0.0, 0); - dailyStats[date] = (current.$1 + cost, current.$2 + workers); - } - - final List chartData = dailyStats.entries.map((e) { - return ForecastPoint( - date: e.key, - projectedCost: e.value.$1, - workersNeeded: e.value.$2, - ); - }).toList()..sort((a, b) => a.date.compareTo(b.date)); - - return ForecastReport( - projectedSpend: projectedSpend, - projectedWorkers: projectedWorkers, - averageLaborCost: projectedWorkers == 0 ? 0.0 : projectedSpend / projectedWorkers, - chartData: chartData, + }) => _connectorRepository.getForecastReport( + businessId: businessId, + startDate: startDate, + endDate: endDate, ); - }); - } @override Future getPerformanceReport({ String? businessId, required DateTime startDate, required DateTime endDate, - }) async { - return await _service.run(() async { - final String id = businessId ?? await _service.getBusinessId(); - final response = await _service.connector - .listShiftsForPerformanceByBusiness( - businessId: id, - startDate: _service.toTimestamp(startDate), - endDate: _service.toTimestamp(endDate), - ) - .execute(); - - final shifts = response.data.shifts; - - int totalNeeded = 0; - int totalFilled = 0; - int completedCount = 0; - double totalFillTimeSeconds = 0.0; - int filledShiftsWithTime = 0; - - for (final 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 createdAt = shift.createdAt!.toDateTime(); - final 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), - ], + }) => _connectorRepository.getPerformanceReport( + businessId: businessId, + startDate: startDate, + endDate: endDate, ); - }); - } @override Future getNoShowReport({ String? businessId, required DateTime startDate, required DateTime endDate, - }) async { - return await _service.run(() async { - final String id = businessId ?? await _service.getBusinessId(); - - final shiftsResponse = await _service.connector - .listShiftsForNoShowRangeByBusiness( - businessId: id, - startDate: _service.toTimestamp(startDate), - endDate: _service.toTimestamp(endDate), - ) - .execute(); - - final shiftIds = shiftsResponse.data.shifts.map((s) => s.id).toList(); - if (shiftIds.isEmpty) { - return const NoShowReport(totalNoShows: 0, noShowRate: 0, flaggedWorkers: []); - } - - final appsResponse = await _service.connector - .listApplicationsForNoShowRange(shiftIds: shiftIds) - .execute(); - - final apps = appsResponse.data.applications; - final noShowApps = apps.where((a) => (a.status.stringValue) == 'NO_SHOW').toList(); - final noShowStaffIds = noShowApps.map((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 staffResponse = await _service.connector - .listStaffForNoShowReport(staffIds: noShowStaffIds) - .execute(); - - final staffList = staffResponse.data.staffs; - - final List flaggedWorkers = staffList.map((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, + }) => _connectorRepository.getNoShowReport( + businessId: businessId, + startDate: startDate, + endDate: endDate, ); - }); - } @override Future getReportsSummary({ String? businessId, required DateTime startDate, required DateTime endDate, - }) async { - return await _service.run(() async { - final String id = businessId ?? await _service.getBusinessId(); - - // Use forecast query for hours/cost data - final 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 perfResponse = await _service.connector - .listShiftsForPerformanceByBusiness( - businessId: id, - startDate: _service.toTimestamp(startDate), - endDate: _service.toTimestamp(endDate), - ) - .execute(); - - final invoicesResponse = await _service.connector - .listInvoicesForSpendByBusiness( - businessId: id, - startDate: _service.toTimestamp(startDate), - endDate: _service.toTimestamp(endDate), - ) - .execute(); - - final forecastShifts = shiftsResponse.data.shifts; - final perfShifts = perfResponse.data.shifts; - final invoices = invoicesResponse.data.invoices; - - // Aggregate hours and fill rate from forecast shifts - double totalHours = 0; - int totalNeeded = 0; - int totalFilled = 0; - - for (final shift in forecastShifts) { - totalHours += (shift.hours ?? 0).toDouble(); - totalNeeded += shift.workersNeeded ?? 0; - // Forecast query doesn't have 'filled' — use workersNeeded as proxy - // (fill rate will be computed from performance shifts below) - } - - // Aggregate fill rate from performance shifts (has 'filled' field) - int perfNeeded = 0; - int perfFilled = 0; - double totalFillTimeSeconds = 0; - int filledShiftsWithTime = 0; - - for (final shift in perfShifts) { - perfNeeded += shift.workersNeeded ?? 0; - perfFilled += shift.filled ?? 0; - - if (shift.filledAt != null && shift.createdAt != null) { - final createdAt = shift.createdAt!.toDateTime(); - final filledAt = shift.filledAt!.toDateTime(); - totalFillTimeSeconds += filledAt.difference(createdAt).inSeconds; - filledShiftsWithTime++; - } - } - - // Aggregate total spend from invoices - double totalSpend = 0; - for (final inv in invoices) { - totalSpend += (inv.amount ?? 0).toDouble(); - } - - // Fetch no-show rate using forecast shift IDs - final shiftIds = forecastShifts.map((s) => s.id).toList(); - double noShowRate = 0; - if (shiftIds.isNotEmpty) { - final appsResponse = await _service.connector - .listApplicationsForNoShowRange(shiftIds: shiftIds) - .execute(); - final apps = appsResponse.data.applications; - final noShowApps = apps.where((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, + }) => _connectorRepository.getReportsSummary( + businessId: businessId, + startDate: startDate, + endDate: endDate, ); - }); - } } diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/entities/forecast_report.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/entities/forecast_report.dart deleted file mode 100644 index f4d5e3b4..00000000 --- a/apps/mobile/packages/features/client/reports/lib/src/domain/entities/forecast_report.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:equatable/equatable.dart'; - -class ForecastReport extends Equatable { - final double projectedSpend; - final int projectedWorkers; - final double averageLaborCost; - final List chartData; - - const ForecastReport({ - required this.projectedSpend, - required this.projectedWorkers, - required this.averageLaborCost, - required this.chartData, - }); - - @override - List get props => [projectedSpend, projectedWorkers, averageLaborCost, chartData]; -} - -class ForecastPoint extends Equatable { - final DateTime date; - final double projectedCost; - final int workersNeeded; - - const ForecastPoint({ - required this.date, - required this.projectedCost, - required this.workersNeeded, - }); - - @override - List get props => [date, projectedCost, workersNeeded]; -} 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 2a2da7b1..36ff5d47 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,10 +1,4 @@ -import '../entities/daily_ops_report.dart'; -import '../entities/spend_report.dart'; -import '../entities/coverage_report.dart'; -import '../entities/forecast_report.dart'; -import '../entities/performance_report.dart'; -import '../entities/no_show_report.dart'; -import '../entities/reports_summary.dart'; +import 'package:krow_domain/krow_domain.dart'; abstract class ReportsRepository { Future getDailyOpsReport({ diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_state.dart index 8c3598c9..27a6d555 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_state.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_state.dart @@ -1,5 +1,5 @@ import 'package:equatable/equatable.dart'; -import '../../../domain/entities/daily_ops_report.dart'; +import 'package:krow_domain/krow_domain.dart'; abstract class DailyOpsState extends Equatable { const DailyOpsState(); diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_state.dart index dcf2bdd5..7bd31d30 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_state.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_state.dart @@ -1,5 +1,5 @@ import 'package:equatable/equatable.dart'; -import '../../../domain/entities/forecast_report.dart'; +import 'package:krow_domain/krow_domain.dart'; abstract class ForecastState extends Equatable { const ForecastState(); diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_state.dart index 22b1bac9..9775e9c0 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_state.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_state.dart @@ -1,5 +1,5 @@ import 'package:equatable/equatable.dart'; -import '../../../domain/entities/no_show_report.dart'; +import 'package:krow_domain/krow_domain.dart'; abstract class NoShowState extends Equatable { const NoShowState(); diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_state.dart index f28d74ed..412a5bc7 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_state.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_state.dart @@ -1,5 +1,5 @@ import 'package:equatable/equatable.dart'; -import '../../../domain/entities/performance_report.dart'; +import 'package:krow_domain/krow_domain.dart'; abstract class PerformanceState extends Equatable { const PerformanceState(); diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_state.dart index 5fba9714..beb35c6e 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_state.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_state.dart @@ -1,5 +1,5 @@ import 'package:equatable/equatable.dart'; -import '../../../domain/entities/spend_report.dart'; +import 'package:krow_domain/krow_domain.dart'; abstract class SpendState extends Equatable { const SpendState(); 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 8b9079d1..58b81142 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,5 +1,5 @@ import 'package:equatable/equatable.dart'; -import '../../../domain/entities/reports_summary.dart'; +import 'package:krow_domain/krow_domain.dart'; abstract class ReportsSummaryState extends Equatable { const ReportsSummaryState(); 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 new file mode 100644 index 00000000..cdb55fd2 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart @@ -0,0 +1,300 @@ +import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.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:krow_domain/krow_domain.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'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:intl/intl.dart'; + +class CoverageReportPage extends StatefulWidget { + const CoverageReportPage({super.key}); + + @override + State createState() => _CoverageReportPageState(); +} + +class _CoverageReportPageState extends State { + final DateTime _startDate = DateTime.now(); + final DateTime _endDate = DateTime.now().add(const Duration(days: 14)); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => Modular.get() + ..add(LoadCoverageReport(startDate: _startDate, endDate: _endDate)), + child: Scaffold( + backgroundColor: UiColors.bgMenu, + body: BlocBuilder( + builder: (context, state) { + if (state is CoverageLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is CoverageError) { + return Center(child: Text(state.message)); + } + + if (state is CoverageLoaded) { + final report = state.report; + return SingleChildScrollView( + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.only( + top: 60, + left: 20, + right: 20, + bottom: 32, + ), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [UiColors.primary, UiColors.tagInProgress], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + GestureDetector( + onTap: () => Navigator.of(context).pop(), + 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.coverage_report.title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: UiColors.white, + ), + ), + Text( + context.t.client_reports.coverage_report + .subtitle, + style: TextStyle( + fontSize: 12, + color: UiColors.white.withOpacity(0.7), + ), + ), + ], + ), + ], + ), + ], + ), + ), + + // Content + Transform.translate( + offset: const Offset(0, -16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Summary Cards + Row( + children: [ + Expanded( + child: _CoverageSummaryCard( + label: context.t.client_reports.coverage_report.metrics.avg_coverage, + value: '${report.overallCoverage.toStringAsFixed(1)}%', + icon: UiIcons.chart, + color: UiColors.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _CoverageSummaryCard( + label: context.t.client_reports.coverage_report.metrics.full, + value: '${report.totalFilled}/${report.totalNeeded}', + icon: UiIcons.users, + color: UiColors.success, + ), + ), + ], + ), + const SizedBox(height: 24), + + // Daily List + Text( + context.t.client_reports.coverage_report.next_7_days, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: UiColors.textSecondary, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 16), + if (report.dailyCoverage.isEmpty) + Center(child: Text(context.t.client_reports.coverage_report.empty_state)) + else + ...report.dailyCoverage.map((day) => _CoverageListItem( + date: DateFormat('EEE, MMM dd').format(day.date), + needed: day.needed, + filled: day.filled, + percentage: day.percentage, + )), + const SizedBox(height: 100), + ], + ), + ), + ), + ], + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ), + ); + } +} + +class _CoverageSummaryCard extends StatelessWidget { + final String label; + final String value; + final IconData icon; + final Color color; + + const _CoverageSummaryCard({ + required this.label, + required this.value, + required this.icon, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.04), + blurRadius: 10, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 16, color: color), + ), + const SizedBox(height: 12), + Text(label, style: const TextStyle(fontSize: 12, color: UiColors.textSecondary)), + const SizedBox(height: 4), + Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + ], + ), + ); + } +} + +class _CoverageListItem extends StatelessWidget { + final String date; + final int needed; + final int filled; + final double percentage; + + const _CoverageListItem({ + required this.date, + required this.needed, + required this.filled, + required this.percentage, + }); + + @override + Widget build(BuildContext context) { + Color statusColor; + if (percentage >= 100) { + statusColor = UiColors.success; + } else if (percentage >= 80) { + statusColor = UiColors.textWarning; + } else { + statusColor = UiColors.destructive; + } + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(date, style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + // Progress Bar + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: percentage / 100, + backgroundColor: UiColors.bgMenu, + valueColor: AlwaysStoppedAnimation(statusColor), + minHeight: 6, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '$filled/$needed', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Text( + '${percentage.toStringAsFixed(0)}%', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: statusColor, + ), + ), + ], + ), + ], + ), + ); + } +} 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 b7e11efc..3ef12bef 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,7 +1,7 @@ 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:client_reports/src/domain/entities/forecast_report.dart'; +import 'package:krow_domain/krow_domain.dart'; import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:fl_chart/fl_chart.dart'; @@ -18,8 +18,8 @@ class ForecastReportPage extends StatefulWidget { } class _ForecastReportPageState extends State { - DateTime _startDate = DateTime.now(); - DateTime _endDate = DateTime.now().add(const Duration(days: 14)); + final DateTime _startDate = DateTime.now(); + final DateTime _endDate = DateTime.now().add(const Duration(days: 28)); // 4 weeks @override Widget build(BuildContext context) { @@ -44,159 +44,48 @@ class _ForecastReportPageState extends State { child: Column( children: [ // Header - Container( - padding: const EdgeInsets.only( - top: 60, - left: 20, - right: 20, - bottom: 32, - ), - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [UiColors.primary, UiColors.tagInProgress], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - GestureDetector( - onTap: () => Navigator.of(context).pop(), - 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.forecast_report.title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: UiColors.white, - ), - ), - Text( - context.t.client_reports.forecast_report - .subtitle, - style: TextStyle( - fontSize: 12, - color: UiColors.white.withOpacity(0.7), - ), - ), - ], - ), - ], - ), - ], - ), - ), + _buildHeader(context), // Content Transform.translate( - offset: const Offset(0, -16), + offset: const Offset(0, -20), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Summary Cards - Row( - children: [ - Expanded( - child: _ForecastSummaryCard( - label: context.t.client_reports.forecast_report.metrics.projected_spend, - value: NumberFormat.currency(symbol: r'$') - .format(report.projectedSpend), - icon: UiIcons.dollar, - color: UiColors.primary, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _ForecastSummaryCard( - label: context.t.client_reports.forecast_report.metrics.workers_needed, - value: report.projectedWorkers.toString(), - icon: UiIcons.users, - color: UiColors.primary, - ), - ), - ], - ), - const SizedBox(height: 24), - - // Chart - Container( - height: 300, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: UiColors.black.withOpacity(0.04), - blurRadius: 10, - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.t.client_reports.forecast_report.chart_title, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, - ), - ), - const SizedBox(height: 24), - Expanded( - child: _ForecastChart( - points: report.chartData, - ), - ), - ], - ), - ), - const SizedBox(height: 24), - - // Daily List - Text( - context.t.client_reports.forecast_report.daily_projections, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: UiColors.textSecondary, - letterSpacing: 1.2, - ), - ), + // Metrics Grid + _buildMetricsGrid(context, report), const SizedBox(height: 16), - if (report.chartData.isEmpty) - Center(child: Text(context.t.client_reports.forecast_report.empty_state)) + + // 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, + ), + const SizedBox(height: 12), + + // Weekly Breakdown List + if (report.weeklyBreakdown.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Text( + context.t.client_reports.forecast_report.empty_state, + style: UiTypography.body2r.textSecondary, + ), + ), + ) else - ...report.chartData.map((point) => _ForecastListItem( - date: DateFormat('EEE, MMM dd').format(point.date), - cost: NumberFormat.currency(symbol: r'$') - .format(point.projectedCost), - workers: point.workersNeeded.toString(), - )), - const SizedBox(height: 100), + ...report.weeklyBreakdown.map( + (week) => _WeeklyBreakdownItem(week: week), + ), + + const SizedBox(height: 40), ], ), ), @@ -211,25 +100,135 @@ class _ForecastReportPageState extends State { ), ); } -} -class _ForecastSummaryCard extends StatelessWidget { - final String label; - final String value; - final IconData icon; - final Color color; - - const _ForecastSummaryCard({ - required this.label, - required this.value, - required this.icon, - required this.color, - }); - - @override - Widget build(BuildContext context) { + Widget _buildHeader(BuildContext context) { return Container( - padding: const EdgeInsets.all(16), + 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 + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + GestureDetector( + onTap: () => Navigator.of(context).pop(), + 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.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 t = context.t.client_reports.forecast_report; + return GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.3, + children: [ + _MetricCard( + icon: UiIcons.dollar, + label: t.metrics.four_week_forecast, + value: NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(report.projectedSpend), + badgeText: t.badges.total_projected, + iconColor: UiColors.textWarning, + badgeColor: UiColors.tagPending, // Yellow-ish + ), + _MetricCard( + icon: UiIcons.trendingUp, + label: t.metrics.avg_weekly, + value: NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(report.avgWeeklySpend), + badgeText: t.badges.per_week, + iconColor: UiColors.primary, + badgeColor: UiColors.tagInProgress, // Blue-ish + ), + _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 + ), + _MetricCard( + icon: UiIcons.users, + label: t.metrics.total_hours, + value: report.totalHours.toStringAsFixed(0), + badgeText: t.badges.worker_hours, + iconColor: UiColors.success, + badgeColor: UiColors.tagSuccess, + ), + ], + ); + } + + Widget _buildChartSection(BuildContext context, ForecastReport report) { + return Container( + height: 320, + padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: UiColors.white, borderRadius: BorderRadius.circular(16), @@ -243,24 +242,178 @@ class _ForecastSummaryCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: color.withOpacity(0.1), - shape: BoxShape.circle, - ), - child: Icon(icon, size: 16, color: color), + Text( + context.t.client_reports.forecast_report.chart_title, + 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), + Expanded( + child: _ForecastChart(points: report.chartData), + ), + const SizedBox(height: 8), + // X Axis labels manually if chart doesn't handle them perfectly or for custom look + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: const [ + 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)), + ], ), - const SizedBox(height: 12), - Text(label, style: const TextStyle(fontSize: 12, color: UiColors.textSecondary)), - const SizedBox(height: 4), - Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), ], ), ); } } +class _MetricCard extends StatelessWidget { + final IconData icon; + final String label; + final String value; + final String badgeText; + final Color iconColor; + final Color badgeColor; + + const _MetricCard({ + required this.icon, + required this.label, + required this.value, + required this.badgeText, + required this.iconColor, + required this.badgeColor, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.04), + blurRadius: 8, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: iconColor), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: UiTypography.footnote1r.textSecondary, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + Text( + value, + style: UiTypography.headline3m.copyWith(fontWeight: FontWeight.bold), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: badgeColor, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + badgeText, + style: UiTypography.footnote1r.copyWith( + color: UiColors.textPrimary, // Or specific text color + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } +} + +class _WeeklyBreakdownItem extends StatelessWidget { + final ForecastWeek week; + + const _WeeklyBreakdownItem({required this.week}); + + @override + Widget build(BuildContext context) { + final t = context.t.client_reports.forecast_report.weekly_breakdown; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + t.week(index: week.weekNumber), + style: UiTypography.headline4m, + ), + Container( + 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), + style: UiTypography.body2b.copyWith( + color: UiColors.textWarning, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + 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)), + ], + ), + ], + ), + ); + } + + Widget _buildStat(String label, String value) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: UiTypography.footnote1r.textSecondary), + const SizedBox(height: 4), + Text(value, style: UiTypography.body1m), + ], + ); + } +} + class _ForecastChart extends StatelessWidget { final List points; @@ -268,51 +421,51 @@ class _ForecastChart extends StatelessWidget { @override Widget build(BuildContext context) { + // If no data, show empty or default line? if (points.isEmpty) return const SizedBox(); return LineChart( LineChartData( - gridData: const FlGridData(show: false), - titlesData: FlTitlesData( + gridData: FlGridData( show: true, - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: (value, meta) { - if (value.toInt() < 0 || value.toInt() >= points.length) { - return const SizedBox(); - } - if (value.toInt() % 3 != 0) return const SizedBox(); - return SideTitleWidget( - axisSide: meta.axisSide, - child: Text( - DateFormat('dd').format(points[value.toInt()].date), - style: const TextStyle(fontSize: 10, color: UiColors.textSecondary), - ), - ); - }, - ), - ), - leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + drawVerticalLine: false, + horizontalInterval: 5000, // Dynamic? + getDrawingHorizontalLine: (value) { + return FlLine( + color: UiColors.borderInactive, + strokeWidth: 1, + dashArray: [5, 5], + ); + }, ), + titlesData: const FlTitlesData(show: false), borderData: FlBorderData(show: false), + minX: 0, + maxX: points.length.toDouble() - 1, + // minY: 0, // Let it scale automatically lineBarsData: [ LineChartBarData( - spots: points - .asMap() - .entries - .map((e) => FlSpot(e.key.toDouble(), e.value.projectedCost)) - .toList(), + spots: points.asMap().entries.map((e) { + return FlSpot(e.key.toDouble(), e.value.projectedCost); + }).toList(), isCurved: true, - color: UiColors.primary, + color: UiColors.textWarning, // Orange-ish barWidth: 4, isStrokeCapRound: true, - dotData: const FlDotData(show: false), + dotData: FlDotData( + show: true, + getDotPainter: (spot, percent, barData, index) { + return FlDotCirclePainter( + radius: 4, + color: UiColors.textWarning, + strokeWidth: 2, + strokeColor: UiColors.white, + ); + }, + ), belowBarData: BarAreaData( show: true, - color: UiColors.primary.withOpacity(0.1), + color: UiColors.tagPending.withOpacity(0.5), // Light orange fill ), ), ], @@ -320,40 +473,3 @@ class _ForecastChart extends StatelessWidget { ); } } - -class _ForecastListItem extends StatelessWidget { - final String date; - final String cost; - final String workers; - - const _ForecastListItem({ - required this.date, - required this.cost, - required this.workers, - }); - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(date, style: const TextStyle(fontWeight: FontWeight.bold)), - Text(context.t.client_reports.forecast_report.shift_item.workers_needed(count: workers), style: const TextStyle(fontSize: 11, color: UiColors.textSecondary)), - ], - ), - Text(cost, style: const TextStyle(fontWeight: FontWeight.bold, color: UiColors.primary)), - ], - ), - ); - } -} 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 d2411711..104f9f19 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 @@ -1,4 +1,4 @@ -import 'package:client_reports/src/domain/entities/no_show_report.dart'; +import 'package:krow_domain/krow_domain.dart'; import 'package:client_reports/src/presentation/blocs/no_show/no_show_bloc.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'; 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 77798c80..fa9c16d1 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 @@ -8,7 +8,7 @@ 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:client_reports/src/domain/entities/spend_report.dart'; +import 'package:krow_domain/krow_domain.dart'; class SpendReportPage extends StatefulWidget { const SpendReportPage({super.key}); diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/quick_reports_section.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/quick_reports_section.dart index 88219692..5a2c85ea 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/quick_reports_section.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/quick_reports_section.dart @@ -50,6 +50,14 @@ class QuickReportsSection extends StatelessWidget { iconColor: UiColors.success, route: './spend', ), + // Coverage Report + ReportCard( + icon: UiIcons.users, + name: context.t.client_reports.quick_reports.cards.coverage, + iconBgColor: UiColors.tagInProgress, + iconColor: UiColors.primary, + route: './coverage', + ), // No-Show Rates ReportCard( icon: UiIcons.warning, @@ -58,6 +66,14 @@ class QuickReportsSection extends StatelessWidget { iconColor: UiColors.destructive, route: './no-show', ), + // Forecast Report + ReportCard( + icon: UiIcons.trendingUp, + name: context.t.client_reports.quick_reports.cards.forecast, + iconBgColor: UiColors.tagPending, + iconColor: UiColors.textWarning, + route: './forecast', + ), // Performance Reports ReportCard( icon: UiIcons.chart, 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 d1dc3387..478aa568 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 @@ -12,6 +12,8 @@ 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'; @@ -24,6 +26,7 @@ class ReportsModule extends Module { i.addLazySingleton(ReportsRepositoryImpl.new); i.add(DailyOpsBloc.new); i.add(SpendBloc.new); + i.add(CoverageBloc.new); i.add(ForecastBloc.new); i.add(PerformanceBloc.new); i.add(NoShowBloc.new); @@ -35,6 +38,7 @@ class ReportsModule extends Module { r.child('/', child: (_) => const ReportsPage()); r.child('/daily-ops', child: (_) => const DailyOpsReportPage()); r.child('/spend', child: (_) => const SpendReportPage()); + r.child('/coverage', child: (_) => const CoverageReportPage()); r.child('/forecast', child: (_) => const ForecastReportPage()); r.child('/performance', child: (_) => const PerformanceReportPage()); r.child('/no-show', child: (_) => const NoShowReportPage()); 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 b3d4a8b2..0b7d7649 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 @@ -29,6 +29,8 @@ class PersonalInfoBloc extends Bloc on(_onFieldChanged); on(_onAddressSelected); on(_onSubmitted); + on(_onLocationAdded); + on(_onLocationRemoved); add(const PersonalInfoLoadRequested()); } @@ -133,11 +135,48 @@ class PersonalInfoBloc extends Bloc PersonalInfoAddressSelected event, Emitter emit, ) { - // TODO: Implement Google Places logic if needed + // Legacy address selected – no-op; use PersonalInfoLocationAdded instead. } - /// With _onPhotoUploadRequested and _onSaveRequested removed or renamed, - /// there are no errors pointing to them here. + /// Adds a location to the preferredLocations list (max 5, no duplicates). + void _onLocationAdded( + PersonalInfoLocationAdded event, + Emitter emit, + ) { + final dynamic raw = state.formValues['preferredLocations']; + final List current = _toStringList(raw); + + if (current.length >= 5) return; // max guard + if (current.contains(event.location)) return; // no duplicates + + final List updated = List.from(current)..add(event.location); + final Map updatedValues = Map.from(state.formValues) + ..['preferredLocations'] = updated; + + emit(state.copyWith(formValues: updatedValues)); + } + + /// Removes a location from the preferredLocations list. + void _onLocationRemoved( + PersonalInfoLocationRemoved event, + Emitter emit, + ) { + final dynamic raw = state.formValues['preferredLocations']; + final List current = _toStringList(raw); + + final List updated = List.from(current) + ..remove(event.location); + final Map updatedValues = Map.from(state.formValues) + ..['preferredLocations'] = updated; + + emit(state.copyWith(formValues: updatedValues)); + } + + List _toStringList(dynamic raw) { + if (raw is List) return raw; + if (raw is List) return raw.map((dynamic e) => e.toString()).toList(); + return []; + } @override void dispose() { 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 a577287f..b6a73841 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 @@ -40,3 +40,21 @@ class PersonalInfoAddressSelected extends PersonalInfoEvent { @override List get props => [address]; } + +/// Event to add a preferred location. +class PersonalInfoLocationAdded extends PersonalInfoEvent { + const PersonalInfoLocationAdded({required this.location}); + final String location; + + @override + List get props => [location]; +} + +/// Event to remove a preferred location. +class PersonalInfoLocationRemoved extends PersonalInfoEvent { + const PersonalInfoLocationRemoved({required this.location}); + final String location; + + @override + List get props => [location]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/preferred_locations_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/preferred_locations_page.dart new file mode 100644 index 00000000..c8558eaf --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/preferred_locations_page.dart @@ -0,0 +1,513 @@ +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:google_places_flutter/google_places_flutter.dart'; +import 'package:google_places_flutter/model/prediction.dart'; +import 'package:krow_core/core.dart'; + +import '../blocs/personal_info_bloc.dart'; +import '../blocs/personal_info_event.dart'; +import '../blocs/personal_info_state.dart'; + +/// The maximum number of preferred locations a staff member can add. +const int _kMaxLocations = 5; + +/// Uber-style Preferred Locations editing page. +/// +/// Allows staff to search for US locations using the Google Places API, +/// add them as chips (max 5), and save back to their profile. +class PreferredLocationsPage extends StatefulWidget { + /// Creates a [PreferredLocationsPage]. + const PreferredLocationsPage({super.key}); + + @override + State createState() => _PreferredLocationsPageState(); +} + +class _PreferredLocationsPageState extends State { + late final TextEditingController _searchController; + late final FocusNode _searchFocusNode; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController(); + _searchFocusNode = FocusNode(); + } + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + void _onLocationSelected(Prediction prediction, PersonalInfoBloc bloc) { + final String description = prediction.description ?? ''; + if (description.isEmpty) return; + + bloc.add(PersonalInfoLocationAdded(location: description)); + + // Clear search field after selection + _searchController.clear(); + _searchFocusNode.unfocus(); + } + + void _removeLocation(String location, PersonalInfoBloc bloc) { + bloc.add(PersonalInfoLocationRemoved(location: location)); + } + + void _save(BuildContext context, PersonalInfoBloc bloc, PersonalInfoState state) { + bloc.add(const PersonalInfoFormSubmitted()); + } + + @override + Widget build(BuildContext context) { + final i18n = t.staff.onboarding.personal_info; + // Access the same PersonalInfoBloc singleton managed by the module. + final PersonalInfoBloc bloc = Modular.get(); + + return BlocProvider.value( + value: bloc, + child: BlocConsumer( + listener: (BuildContext context, PersonalInfoState state) { + if (state.status == PersonalInfoStatus.saved) { + UiSnackbar.show( + context, + message: i18n.preferred_locations.save_success, + type: UiSnackbarType.success, + ); + Navigator.of(context).pop(); + } else if (state.status == PersonalInfoStatus.error) { + UiSnackbar.show( + context, + message: state.errorMessage != null + ? translateErrorKey(state.errorMessage!) + : 'An error occurred', + type: UiSnackbarType.error, + ); + } + }, + builder: (BuildContext context, PersonalInfoState state) { + final List locations = _currentLocations(state); + final bool atMax = locations.length >= _kMaxLocations; + final bool isSaving = state.status == PersonalInfoStatus.saving; + + return Scaffold( + backgroundColor: UiColors.background, + appBar: AppBar( + backgroundColor: UiColors.bgPopup, + elevation: 0, + leading: IconButton( + icon: const Icon(UiIcons.chevronLeft, color: UiColors.textSecondary), + onPressed: () => Navigator.of(context).pop(), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + ), + title: Text( + i18n.preferred_locations.title, + style: UiTypography.title1m.textPrimary, + ), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1.0), + child: Container(color: UiColors.border, height: 1.0), + ), + ), + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Description + Padding( + padding: const EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space5, + UiConstants.space5, + UiConstants.space3, + ), + child: Text( + i18n.preferred_locations.description, + style: UiTypography.body2r.textSecondary, + ), + ), + + // ── Search autocomplete field + Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), + child: _PlacesSearchField( + controller: _searchController, + focusNode: _searchFocusNode, + hint: i18n.preferred_locations.search_hint, + enabled: !atMax && !isSaving, + onSelected: (Prediction p) => _onLocationSelected(p, bloc), + ), + ), + + // ── "Max reached" banner + if (atMax) + Padding( + padding: const EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space2, + UiConstants.space5, + 0, + ), + child: Row( + children: [ + const Icon( + UiIcons.info, + size: 14, + color: UiColors.textWarning, + ), + const SizedBox(width: UiConstants.space1), + Text( + i18n.preferred_locations.max_reached, + style: UiTypography.footnote1r.textWarning, + ), + ], + ), + ), + + const SizedBox(height: UiConstants.space5), + + // ── Section label + Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), + child: Text( + i18n.preferred_locations.added_label, + style: UiTypography.titleUppercase3m.textSecondary, + ), + ), + + const SizedBox(height: UiConstants.space3), + + // ── Locations list / empty state + Expanded( + child: locations.isEmpty + ? _EmptyLocationsState(message: i18n.preferred_locations.empty_state) + : _LocationsList( + locations: locations, + isSaving: isSaving, + removeTooltip: i18n.preferred_locations.remove_tooltip, + onRemove: (String loc) => _removeLocation(loc, bloc), + ), + ), + + // ── Save button + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: UiButton.primary( + text: i18n.preferred_locations.save_button, + fullWidth: true, + onPressed: isSaving ? null : () => _save(context, bloc, state), + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + List _currentLocations(PersonalInfoState state) { + final dynamic raw = state.formValues['preferredLocations']; + if (raw is List) return raw; + if (raw is List) return raw.map((dynamic e) => e.toString()).toList(); + return []; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Subwidgets +// ───────────────────────────────────────────────────────────────────────────── + +/// Google Places autocomplete search field, locked to US results. +class _PlacesSearchField extends StatelessWidget { + const _PlacesSearchField({ + required this.controller, + required this.focusNode, + required this.hint, + required this.onSelected, + this.enabled = true, + }); + + final TextEditingController controller; + final FocusNode focusNode; + final String hint; + final bool enabled; + final void Function(Prediction) onSelected; + + @override + Widget build(BuildContext context) { + return GooglePlaceAutoCompleteTextField( + textEditingController: controller, + focusNode: focusNode, + googleAPIKey: AppConfig.googleMapsApiKey, + debounceTime: 400, + countries: const ['us'], + isLatLngRequired: false, + getPlaceDetailWithLatLng: onSelected, + itemClick: (Prediction prediction) { + controller.text = prediction.description ?? ''; + controller.selection = TextSelection.fromPosition( + TextPosition(offset: controller.text.length), + ); + onSelected(prediction); + }, + inputDecoration: InputDecoration( + hintText: hint, + hintStyle: UiTypography.body2r.textSecondary, + prefixIcon: const Icon(UiIcons.search, color: UiColors.iconSecondary, size: 20), + suffixIcon: controller.text.isNotEmpty + ? IconButton( + icon: const Icon(UiIcons.close, size: 18, color: UiColors.iconSecondary), + onPressed: controller.clear, + ) + : null, + contentPadding: const EdgeInsets.symmetric( + horizontal: UiConstants.space3, + vertical: UiConstants.space3, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + borderSide: const BorderSide(color: UiColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + borderSide: const BorderSide(color: UiColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + borderSide: const BorderSide(color: UiColors.primary, width: 1.5), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + borderSide: BorderSide(color: UiColors.border.withValues(alpha: 0.5)), + ), + fillColor: enabled ? UiColors.bgPopup : UiColors.bgSecondary, + filled: true, + ), + textStyle: UiTypography.body2r.textPrimary, + itemBuilder: (BuildContext context, int index, Prediction prediction) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space3, + vertical: UiConstants.space2, + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(UiConstants.space2), + decoration: BoxDecoration( + color: UiColors.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(4.0), + ), + child: const Icon(UiIcons.mapPin, size: 16, color: UiColors.primary), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _mainText(prediction.description ?? ''), + style: UiTypography.body2m.textPrimary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (_subText(prediction.description ?? '').isNotEmpty) + Text( + _subText(prediction.description ?? ''), + style: UiTypography.footnote1r.textSecondary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ); + }, + ); + } + + /// Extracts text before first comma as the primary line. + String _mainText(String description) { + final int commaIndex = description.indexOf(','); + return commaIndex > 0 ? description.substring(0, commaIndex) : description; + } + + /// Extracts text after first comma as the secondary line. + String _subText(String description) { + final int commaIndex = description.indexOf(','); + return commaIndex > 0 ? description.substring(commaIndex + 1).trim() : ''; + } +} + +/// The scrollable list of location chips. +class _LocationsList extends StatelessWidget { + const _LocationsList({ + required this.locations, + required this.isSaving, + required this.removeTooltip, + required this.onRemove, + }); + + final List locations; + final bool isSaving; + final String removeTooltip; + final void Function(String) onRemove; + + @override + Widget build(BuildContext context) { + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), + itemCount: locations.length, + separatorBuilder: (_, __) => const SizedBox(height: UiConstants.space2), + itemBuilder: (BuildContext context, int index) { + final String location = locations[index]; + return _LocationChip( + label: location, + index: index + 1, + total: locations.length, + isSaving: isSaving, + removeTooltip: removeTooltip, + onRemove: () => onRemove(location), + ); + }, + ); + } +} + +/// A single location row with pin icon, label, and remove button. +class _LocationChip extends StatelessWidget { + const _LocationChip({ + required this.label, + required this.index, + required this.total, + required this.isSaving, + required this.removeTooltip, + required this.onRemove, + }); + + final String label; + final int index; + final int total; + final bool isSaving; + final String removeTooltip; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space3, + ), + decoration: BoxDecoration( + color: UiColors.bgPopup, + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + border: Border.all(color: UiColors.border), + ), + child: Row( + children: [ + // Index badge + Container( + width: 28, + height: 28, + alignment: Alignment.center, + decoration: BoxDecoration( + color: UiColors.primary.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Text( + '$index', + style: UiTypography.footnote1m.copyWith(color: UiColors.primary), + ), + ), + const SizedBox(width: UiConstants.space3), + + // Pin icon + const Icon(UiIcons.mapPin, size: 16, color: UiColors.iconSecondary), + const SizedBox(width: UiConstants.space2), + + // Location text + Expanded( + child: Text( + label, + style: UiTypography.body2m.textPrimary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + + // Remove button + if (!isSaving) + Tooltip( + message: removeTooltip, + child: GestureDetector( + onTap: onRemove, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.all(UiConstants.space1), + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: UiColors.bgSecondary, + shape: BoxShape.circle, + ), + child: const Icon(UiIcons.close, size: 14, color: UiColors.iconSecondary), + ), + ), + ), + ), + ], + ), + ); + } +} + +/// Shows when no locations have been added yet. +class _EmptyLocationsState extends StatelessWidget { + const _EmptyLocationsState({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: UiColors.primary.withValues(alpha: 0.08), + shape: BoxShape.circle, + ), + child: const Icon(UiIcons.mapPin, size: 28, color: UiColors.primary), + ), + const SizedBox(height: UiConstants.space4), + Text( + message, + textAlign: TextAlign.center, + style: UiTypography.body2r.textSecondary, + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_content.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_content.dart index 41ed320d..944f5297 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_content.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_content.dart @@ -34,26 +34,22 @@ class PersonalInfoContent extends StatefulWidget { class _PersonalInfoContentState extends State { late final TextEditingController _emailController; late final TextEditingController _phoneController; - late final TextEditingController _locationsController; @override void initState() { super.initState(); _emailController = TextEditingController(text: widget.staff.email); _phoneController = TextEditingController(text: widget.staff.phone ?? ''); - _locationsController = TextEditingController(text: widget.staff.preferredLocations?.join(', ')?? ''); // Listen to changes and update BLoC _emailController.addListener(_onEmailChanged); _phoneController.addListener(_onPhoneChanged); - _locationsController.addListener(_onAddressChanged); } @override void dispose() { _emailController.dispose(); _phoneController.dispose(); - _locationsController.dispose(); super.dispose(); } @@ -76,23 +72,6 @@ class _PersonalInfoContentState extends State { ); } - void _onAddressChanged() { - // Split the comma-separated string into a list for storage - // The backend expects List (JSON/List) for preferredLocations - final List locations = _locationsController.text - .split(',') - .map((String e) => e.trim()) - .where((String e) => e.isNotEmpty) - .toList(); - - context.read().add( - PersonalInfoFieldChanged( - field: 'preferredLocations', - value: locations, - ), - ); - } - void _handleSave() { context.read().add(const PersonalInfoFormSubmitted()); } @@ -129,7 +108,7 @@ class _PersonalInfoContentState extends State { email: widget.staff.email, emailController: _emailController, phoneController: _phoneController, - locationsController: _locationsController, + currentLocations: _toStringList(state.formValues['preferredLocations']), enabled: !isSaving, ), const SizedBox(height: UiConstants.space16), // Space for bottom button @@ -147,4 +126,10 @@ class _PersonalInfoContentState extends State { }, ); } -} \ No newline at end of file + + List _toStringList(dynamic raw) { + if (raw is List) return raw; + if (raw is List) return raw.map((dynamic e) => e.toString()).toList(); + return []; + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_form.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_form.dart index 06f145fb..df0f9f83 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_form.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_form.dart @@ -4,11 +4,11 @@ import 'package:design_system/design_system.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; - /// A form widget containing all personal information fields. /// -/// Includes read-only fields for full name and email, -/// and editable fields for phone and address. +/// Includes read-only fields for full name, +/// and editable fields for email and phone. +/// The Preferred Locations row navigates to a dedicated Uber-style page. /// Uses only design system tokens for colors, typography, and spacing. class PersonalInfoForm extends StatelessWidget { @@ -19,7 +19,7 @@ class PersonalInfoForm extends StatelessWidget { required this.email, required this.emailController, required this.phoneController, - required this.locationsController, + required this.currentLocations, this.enabled = true, }); /// The staff member's full name (read-only). @@ -34,8 +34,8 @@ class PersonalInfoForm extends StatelessWidget { /// Controller for the phone number field. final TextEditingController phoneController; - /// Controller for the address field. - final TextEditingController locationsController; + /// Current preferred locations list to show in the summary row. + final List currentLocations; /// Whether the form fields are enabled for editing. final bool enabled; @@ -43,6 +43,9 @@ class PersonalInfoForm extends StatelessWidget { @override Widget build(BuildContext context) { final TranslationsStaffOnboardingPersonalInfoEn i18n = t.staff.onboarding.personal_info; + final String locationSummary = currentLocations.isEmpty + ? i18n.locations_summary_none + : currentLocations.join(', '); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -69,15 +72,21 @@ class PersonalInfoForm extends StatelessWidget { controller: phoneController, hint: i18n.phone_hint, enabled: enabled, + keyboardType: TextInputType.phone, ), const SizedBox(height: UiConstants.space4), _FieldLabel(text: i18n.locations_label), const SizedBox(height: UiConstants.space2), - _EditableField( - controller: locationsController, + // Uber-style tappable row → navigates to PreferredLocationsPage + _TappableRow( + value: locationSummary, hint: i18n.locations_hint, + icon: UiIcons.mapPin, enabled: enabled, + onTap: enabled + ? () => Modular.to.pushNamed(StaffPaths.preferredLocations) + : null, ), const SizedBox(height: UiConstants.space4), @@ -91,6 +100,68 @@ class PersonalInfoForm extends StatelessWidget { } } +/// An Uber-style tappable row for navigating to a sub-page editor. +/// Displays the current value (or hint if empty) and a chevron arrow. +class _TappableRow extends StatelessWidget { + const _TappableRow({ + required this.value, + required this.hint, + required this.icon, + this.onTap, + this.enabled = true, + }); + + final String value; + final String hint; + final IconData icon; + final VoidCallback? onTap; + final bool enabled; + + @override + Widget build(BuildContext context) { + final bool hasValue = value.isNotEmpty; + return GestureDetector( + onTap: enabled ? onTap : null, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space3, + vertical: UiConstants.space3, + ), + decoration: BoxDecoration( + color: enabled ? UiColors.bgPopup : UiColors.bgSecondary, + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + border: Border.all( + color: enabled ? UiColors.border : UiColors.border.withValues(alpha: 0.5), + ), + ), + child: Row( + children: [ + Icon(icon, size: 18, color: UiColors.iconSecondary), + const SizedBox(width: UiConstants.space2), + Expanded( + child: Text( + hasValue ? value : hint, + style: hasValue + ? UiTypography.body2r.textPrimary + : UiTypography.body2r.textSecondary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (enabled) + Icon( + UiIcons.chevronRight, + size: 18, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ); + } +} + /// A language selector widget that displays the current language and navigates to language selection page. class _LanguageSelector extends StatelessWidget { const _LanguageSelector({ @@ -99,46 +170,43 @@ class _LanguageSelector extends StatelessWidget { final bool enabled; - String _getLanguageLabel(AppLocale locale) { - switch (locale) { - case AppLocale.en: - return 'English'; - case AppLocale.es: - return 'Español'; - } - } - @override Widget build(BuildContext context) { - final AppLocale currentLocale = LocaleSettings.currentLocale; - final String currentLanguage = _getLanguageLabel(currentLocale); + final String currentLocale = Localizations.localeOf(context).languageCode; + final String languageName = currentLocale == 'es' ? 'Español' : 'English'; return GestureDetector( onTap: enabled ? () => Modular.to.pushNamed(StaffPaths.languageSelection) : null, child: Container( - width: double.infinity, padding: const EdgeInsets.symmetric( horizontal: UiConstants.space3, vertical: UiConstants.space3, ), decoration: BoxDecoration( - color: UiColors.bgPopup, + color: enabled ? UiColors.bgPopup : UiColors.bgSecondary, borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), - border: Border.all(color: UiColors.border), + border: Border.all( + color: enabled ? UiColors.border : UiColors.border.withValues(alpha: 0.5), + ), ), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - currentLanguage, - style: UiTypography.body2r.textPrimary, - ), - Icon( - UiIcons.chevronRight, - color: UiColors.textSecondary, + const Icon(UiIcons.settings, size: 18, color: UiColors.iconSecondary), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Text( + languageName, + style: UiTypography.body2r.textPrimary, + ), ), + if (enabled) + const Icon( + UiIcons.chevronRight, + size: 18, + color: UiColors.iconSecondary, + ), ], ), ), @@ -146,10 +214,7 @@ class _LanguageSelector extends StatelessWidget { } } -/// A label widget for form fields. -/// A label widget for form fields. class _FieldLabel extends StatelessWidget { - const _FieldLabel({required this.text}); final String text; @@ -157,13 +222,11 @@ class _FieldLabel extends StatelessWidget { Widget build(BuildContext context) { return Text( text, - style: UiTypography.body2m.textPrimary, + style: UiTypography.titleUppercase3m.textSecondary, ); } } -/// A read-only field widget for displaying non-editable information. -/// A read-only field widget for displaying non-editable information. class _ReadOnlyField extends StatelessWidget { const _ReadOnlyField({required this.value}); final String value; @@ -183,14 +246,12 @@ class _ReadOnlyField extends StatelessWidget { ), child: Text( value, - style: UiTypography.body2r.textPrimary, + style: UiTypography.body2r.textInactive, ), ); } } -/// An editable text field widget. -/// An editable text field widget. class _EditableField extends StatelessWidget { const _EditableField({ required this.controller, @@ -232,7 +293,7 @@ class _EditableField extends StatelessWidget { borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), borderSide: const BorderSide(color: UiColors.primary), ), - fillColor: UiColors.bgPopup, + fillColor: enabled ? UiColors.bgPopup : UiColors.bgSecondary, filled: true, ), ); 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 f949fa72..d9617e9b 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 @@ -9,6 +9,7 @@ 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'; /// The entry module for the Staff Profile Info feature. /// @@ -61,5 +62,12 @@ class StaffProfileInfoModule extends Module { ), child: (BuildContext context) => const LanguageSelectionPage(), ); + r.child( + StaffPaths.childRoute( + StaffPaths.onboardingPersonalInfo, + StaffPaths.preferredLocations, + ), + child: (BuildContext context) => const PreferredLocationsPage(), + ); } } 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 ef8602e7..a3853419 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 @@ -30,6 +30,8 @@ dependencies: 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/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 4428a780..a41c5e1f 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,371 +1,70 @@ import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_domain/krow_domain.dart'; -import 'package:intl/intl.dart'; -import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; import '../../domain/repositories/shifts_repository_interface.dart'; -class ShiftsRepositoryImpl - implements ShiftsRepositoryInterface { +/// Implementation of [ShiftsRepositoryInterface] that delegates to [dc.ShiftsConnectorRepository]. +/// +/// This implementation follows the "Buffer Layer" pattern by using a dedicated +/// connector repository from the data_connect package. +class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { + final dc.ShiftsConnectorRepository _connectorRepository; final dc.DataConnectService _service; - ShiftsRepositoryImpl() : _service = dc.DataConnectService.instance; + ShiftsRepositoryImpl({ + dc.ShiftsConnectorRepository? connectorRepository, + dc.DataConnectService? service, + }) : _connectorRepository = connectorRepository ?? + dc.DataConnectService.instance.getShiftsRepository(), + _service = service ?? dc.DataConnectService.instance; - // Cache: ShiftID -> ApplicationID (For Accept/Decline) - final Map _shiftToAppIdMap = {}; - // Cache: ApplicationID -> RoleID (For Accept/Decline w/ Update mutation) - final Map _appToRoleIdMap = {}; - - // This need to be an APPLICATION - // THERE SHOULD BE APPLICATIONSTATUS and SHIFTSTATUS enums in the domain layer to avoid this string mapping and potential bugs. @override Future> getMyShifts({ required DateTime start, required DateTime end, }) async { - return _fetchApplications(start: start, end: end); + final staffId = await _service.getStaffId(); + return _connectorRepository.getMyShifts( + staffId: staffId, + start: start, + end: end, + ); } @override Future> getPendingAssignments() async { - return []; + final staffId = await _service.getStaffId(); + return _connectorRepository.getPendingAssignments(staffId: staffId); } @override Future> getCancelledShifts() async { - return []; + final staffId = await _service.getStaffId(); + return _connectorRepository.getCancelledShifts(staffId: staffId); } @override Future> getHistoryShifts() async { final staffId = await _service.getStaffId(); - final fdc.QueryResult response = await _service.executeProtected(() => _service.connector - .listCompletedApplicationsByStaffId(staffId: staffId) - .execute()); - final List shifts = []; - - for (final app in response.data.applications) { - _shiftToAppIdMap[app.shift.id] = app.id; - _appToRoleIdMap[app.id] = app.shiftRole.id; - - 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: _mapStatus(dc.ApplicationStatus.CHECKED_OUT), - 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; - } - - Future> _fetchApplications({ - DateTime? start, - DateTime? end, - }) async { - final staffId = await _service.getStaffId(); - var query = _service.connector.getApplicationsByStaffId(staffId: staffId); - if (start != null && end != null) { - query = query.dayStart(_service.toTimestamp(start)).dayEnd(_service.toTimestamp(end)); - } - final fdc.QueryResult response = await _service.executeProtected(() => query.execute()); - - final apps = response.data.applications; - final List shifts = []; - - for (final app in apps) { - _shiftToAppIdMap[app.shift.id] = app.id; - _appToRoleIdMap[app.id] = app.shiftRole.id; - - 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); - - // Override status to reflect the application state (e.g., CHECKED_OUT, CONFIRMED) - final bool hasCheckIn = app.checkInTime != null; - final bool hasCheckOut = app.checkOutTime != null; - dc.ApplicationStatus? appStatus; - if (app.status is dc.Known) { - appStatus = (app.status as dc.Known).value; - } - final String mappedStatus = hasCheckOut - ? 'completed' - : hasCheckIn - ? 'checked_in' - : _mapStatus(appStatus ?? dc.ApplicationStatus.CONFIRMED); - 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: mappedStatus, - 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; - } - - String _mapStatus(dc.ApplicationStatus status) { - switch (status) { - case dc.ApplicationStatus.CONFIRMED: - return 'confirmed'; - case dc.ApplicationStatus.PENDING: - return 'pending'; - case dc.ApplicationStatus.CHECKED_OUT: - return 'completed'; - case dc.ApplicationStatus.REJECTED: - return 'cancelled'; - default: - return 'open'; - } + return _connectorRepository.getHistoryShifts(staffId: staffId); } @override Future> getAvailableShifts(String query, String type) async { - final String? vendorId = dc.StaffSessionStore.instance.session?.ownerId; - if (vendorId == null || vendorId.isEmpty) { - return []; - } - - final fdc.QueryResult result = await _service.executeProtected(() => _service.connector - .listShiftRolesByVendorId(vendorId: vendorId) - .execute()); - - final allShiftRoles = result.data.shiftRoles; - - // Fetch my applications to filter out already booked shifts - final List myShifts = await _fetchApplications(); - final Set myShiftIds = myShifts.map((s) => s.id).toSet(); - - final List mappedShifts = []; - for (final sr in allShiftRoles) { - // Skip if I have already applied/booked this shift - if (myShiftIds.contains(sr.shiftId)) continue; - - - final DateTime? shiftDate = _service.toDateTime(sr.shift.date); - final startDt = _service.toDateTime(sr.startTime); - final endDt = _service.toDateTime(sr.endTime); - final createdDt = _service.toDateTime(sr.createdAt); - - 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: startDt != null - ? DateFormat('HH:mm').format(startDt) - : '', - endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '', - createdDate: createdDt?.toIso8601String() ?? '', - status: sr.shift.status?.stringValue.toLowerCase() ?? 'open', - description: sr.shift.description, - durationDays: sr.shift.durationDays, - requiredSlots: sr.count, - filledSlots: sr.assigned ?? 0, - latitude: sr.shift.latitude, - longitude: sr.shift.longitude, - breakInfo: BreakAdapter.fromData( - isPaid: sr.isBreakPaid ?? false, - breakTime: sr.breakType?.stringValue, - ), - ), - ); - } - - if (query.isNotEmpty) { - return mappedShifts - .where( - (s) => - s.title.toLowerCase().contains(query.toLowerCase()) || - s.clientName.toLowerCase().contains(query.toLowerCase()), - ) - .toList(); - } - - return mappedShifts; + final staffId = await _service.getStaffId(); + return _connectorRepository.getAvailableShifts( + staffId: staffId, + query: query, + type: type, + ); } @override Future getShiftDetails(String shiftId, {String? roleId}) async { - return _getShiftDetails(shiftId, roleId: roleId); - } - - Future _getShiftDetails(String shiftId, {String? roleId}) async { - if (roleId != null && roleId.isNotEmpty) { - final roleResult = await _service.executeProtected(() => _service.connector - .getShiftRoleById(shiftId: shiftId, roleId: roleId) - .execute()); - final 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); - - final String staffId = await _service.getStaffId(); - bool hasApplied = false; - String status = 'open'; - final apps = await _service.executeProtected(() => - _service.connector.getApplicationsByStaffId(staffId: staffId).execute()); - final app = apps.data.applications - .where( - (a) => a.shiftId == shiftId && a.shiftRole.roleId == roleId, - ) - .firstOrNull; - if (app != null) { - hasApplied = true; - if (app.status is dc.Known) { - final dc.ApplicationStatus s = - (app.status as dc.Known).value; - status = _mapStatus(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 fdc.QueryResult result = - await _service.executeProtected(() => _service.connector.getShiftById(id: shiftId).execute()); - final s = result.data.shift; - if (s == null) return null; - - int? required; - int? filled; - Break? breakInfo; - try { - final rolesRes = await _service.executeProtected(() => - _service.connector.listShiftRolesByShiftId(shiftId: shiftId).execute()); - if (rolesRes.data.shiftRoles.isNotEmpty) { - required = 0; - filled = 0; - for (var r in rolesRes.data.shiftRoles) { - required = (required ?? 0) + r.count; - filled = (filled ?? 0) + (r.assigned ?? 0); - } - // Use the first role's break info as a representative - final firstRole = rolesRes.data.shiftRoles.first; - breakInfo = BreakAdapter.fromData( - isPaid: firstRole.isBreakPaid ?? false, - breakTime: firstRole.breakType?.stringValue, - ); - } - } catch (_) {} - - final startDt = _service.toDateTime(s.startTime); - final endDt = _service.toDateTime(s.endTime); - final 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, + final staffId = await _service.getStaffId(); + return _connectorRepository.getShiftDetails( + shiftId: shiftId, + staffId: staffId, + roleId: roleId, ); } @@ -376,182 +75,29 @@ class ShiftsRepositoryImpl String? roleId, }) async { final staffId = await _service.getStaffId(); - - String targetRoleId = roleId ?? ''; - if (targetRoleId.isEmpty) { - throw Exception('Missing role id.'); - } - - final roleResult = await _service.executeProtected(() => _service.connector - .getShiftRoleById(shiftId: shiftId, roleId: targetRoleId) - .execute()); - final role = roleResult.data.shiftRole; - if (role == null) { - throw Exception('Shift role not found'); - } - final shiftResult = - await _service.executeProtected(() => _service.connector.getShiftById(id: shiftId).execute()); - final shift = shiftResult.data.shift; - if (shift == null) { - throw Exception('Shift not found'); - } - final DateTime? shiftDate = _service.toDateTime(shift.date); - if (shiftDate != null) { - final DateTime dayStartUtc = DateTime.utc( - shiftDate.year, - shiftDate.month, - shiftDate.day, - ); - final DateTime dayEndUtc = DateTime.utc( - shiftDate.year, - shiftDate.month, - shiftDate.day, - 23, - 59, - 59, - 999, - 999, - ); - - final dayApplications = await _service.executeProtected(() => _service.connector - .vaidateDayStaffApplication(staffId: staffId) - .dayStart(_service.toTimestamp(dayStartUtc)) - .dayEnd(_service.toTimestamp(dayEndUtc)) - .execute()); - if (dayApplications.data.applications.isNotEmpty) { - throw Exception('The user already has a shift that day.'); - } - } - final existingApplicationResult = await _service.executeProtected(() => _service.connector - .getApplicationByStaffShiftAndRole( - staffId: staffId, - shiftId: shiftId, - roleId: targetRoleId, - ) - .execute()); - if (existingApplicationResult.data.applications.isNotEmpty) { - throw Exception('Application already exists.'); - } - final int assigned = role.assigned ?? 0; - if (assigned >= role.count) { - throw Exception('This shift is full.'); - } - - final int filled = shift.filled ?? 0; - - String? appId; - bool updatedRole = false; - bool updatedShift = false; - try { - final appResult = await _service.executeProtected(() => _service.connector - .createApplication( - shiftId: shiftId, - staffId: staffId, - roleId: targetRoleId, - status: dc.ApplicationStatus.CONFIRMED, - origin: dc.ApplicationOrigin.STAFF, - ) - // TODO: this should be PENDING so a vendor can accept it. - .execute()); - appId = appResult.data.application_insert.id; - - await _service.executeProtected(() => _service.connector - .updateShiftRole(shiftId: shiftId, roleId: targetRoleId) - .assigned(assigned + 1) - .execute()); - updatedRole = true; - - await _service.executeProtected( - () => _service.connector.updateShift(id: shiftId).filled(filled + 1).execute()); - updatedShift = true; - } catch (e) { - if (updatedShift) { - try { - await _service.connector.updateShift(id: shiftId).filled(filled).execute(); - } catch (_) {} - } - if (updatedRole) { - try { - await _service.connector - .updateShiftRole(shiftId: shiftId, roleId: targetRoleId) - .assigned(assigned) - .execute(); - } catch (_) {} - } - if (appId != null) { - try { - await _service.connector.deleteApplication(id: appId).execute(); - } catch (_) {} - } - rethrow; - } + return _connectorRepository.applyForShift( + shiftId: shiftId, + staffId: staffId, + isInstantBook: isInstantBook, + roleId: roleId, + ); } @override Future acceptShift(String shiftId) async { - await _updateApplicationStatus(shiftId, dc.ApplicationStatus.CONFIRMED); + final staffId = await _service.getStaffId(); + return _connectorRepository.acceptShift( + shiftId: shiftId, + staffId: staffId, + ); } @override Future declineShift(String shiftId) async { - await _updateApplicationStatus(shiftId, dc.ApplicationStatus.REJECTED); - } - - Future _updateApplicationStatus( - String shiftId, - dc.ApplicationStatus newStatus, - ) async { - String? appId = _shiftToAppIdMap[shiftId]; - String? roleId; - - if (appId == null) { - // Try to find it in pending - await getPendingAssignments(); - } - // Re-check map - appId = _shiftToAppIdMap[shiftId]; - if (appId != null) { - roleId = _appToRoleIdMap[appId]; - } else { - // Fallback fetch - final staffId = await _service.getStaffId(); - final apps = await _service.executeProtected(() => - _service.connector.getApplicationsByStaffId(staffId: staffId).execute()); - final app = apps.data.applications - .where((a) => a.shiftId == shiftId) - .firstOrNull; - if (app != null) { - appId = app.id; - roleId = app.shiftRole.id; - } - } - - if (appId == null || roleId == null) { - // If we are rejecting and can't find an application, create one as rejected (declining an available shift) - if (newStatus == dc.ApplicationStatus.REJECTED) { - final rolesResult = await _service.executeProtected(() => - _service.connector.listShiftRolesByShiftId(shiftId: shiftId).execute()); - if (rolesResult.data.shiftRoles.isNotEmpty) { - final role = rolesResult.data.shiftRoles.first; - final staffId = await _service.getStaffId(); - await _service.executeProtected(() => _service.connector - .createApplication( - shiftId: shiftId, - staffId: staffId, - roleId: role.id, - status: dc.ApplicationStatus.REJECTED, - origin: dc.ApplicationOrigin.STAFF, - ) - .execute()); - return; - } - } - throw Exception("Application not found for shift $shiftId"); - } - - await _service.executeProtected(() => _service.connector - .updateApplicationStatus(id: appId!) - .status(newStatus) - .execute()); + final staffId = await _service.getStaffId(); + return _connectorRepository.declineShift( + shiftId: shiftId, + staffId: staffId, + ); } }