diff --git a/apps/mobile/apps/client/lib/src/widgets/session_listener.dart b/apps/mobile/apps/client/lib/src/widgets/session_listener.dart index 968d1a3f..810bbf85 100644 --- a/apps/mobile/apps/client/lib/src/widgets/session_listener.dart +++ b/apps/mobile/apps/client/lib/src/widgets/session_listener.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:core_localization/core_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; @@ -84,7 +85,7 @@ class _SessionListenerState extends State { if (!_isInitialState) { debugPrint('[SessionListener] Session error: ${state.errorMessage}'); _showSessionErrorDialog( - state.errorMessage ?? 'Session error occurred', + state.errorMessage ?? t.session.error_title, ); } else { _isInitialState = false; @@ -101,22 +102,21 @@ class _SessionListenerState extends State { /// Shows a dialog when the session expires. void _showSessionExpiredDialog() { + final Translations translations = t; showDialog( context: context, barrierDismissible: false, - builder: (BuildContext context) { + builder: (BuildContext dialogContext) { return AlertDialog( - title: const Text('Session Expired'), - content: const Text( - 'Your session has expired. Please log in again to continue.', - ), + title: Text(translations.session.expired_title), + content: Text(translations.session.expired_message), actions: [ TextButton( onPressed: () { - Modular.to.popSafe(); + Navigator.of(dialogContext).pop(); _proceedToLogin(); }, - child: const Text('Log In'), + child: Text(translations.session.log_in), ), ], ); @@ -126,27 +126,28 @@ class _SessionListenerState extends State { /// Shows a dialog when a session error occurs, with retry option. void _showSessionErrorDialog(String errorMessage) { + final Translations translations = t; showDialog( context: context, barrierDismissible: false, - builder: (BuildContext context) { + builder: (BuildContext dialogContext) { return AlertDialog( - title: const Text('Session Error'), + title: Text(translations.session.error_title), content: Text(errorMessage), actions: [ TextButton( onPressed: () { // User can retry by dismissing and continuing - Modular.to.popSafe(); + Navigator.of(dialogContext).pop(); }, - child: const Text('Continue'), + child: Text(translations.common.continue_text), ), TextButton( onPressed: () { - Modular.to.popSafe(); + Navigator.of(dialogContext).pop(); _proceedToLogin(); }, - child: const Text('Log Out'), + child: Text(translations.session.log_out), ), ], ); diff --git a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart index a07aa31f..fe5bac48 100644 --- a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart +++ b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:core_localization/core_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; @@ -97,7 +98,7 @@ class _SessionListenerState extends State { if (!_isInitialState) { debugPrint('[SessionListener] Session error: ${state.errorMessage}'); _showSessionErrorDialog( - state.errorMessage ?? 'Session error occurred', + state.errorMessage ?? t.session.error_title, ); } else { _isInitialState = false; @@ -114,22 +115,21 @@ class _SessionListenerState extends State { /// Shows a dialog when the session expires. void _showSessionExpiredDialog() { + final Translations translations = t; showDialog( context: context, barrierDismissible: false, - builder: (BuildContext context) { + builder: (BuildContext dialogContext) { return AlertDialog( - title: const Text('Session Expired'), - content: const Text( - 'Your session has expired. Please log in again to continue.', - ), + title: Text(translations.session.expired_title), + content: Text(translations.session.expired_message), actions: [ TextButton( onPressed: () { - Modular.to.popSafe(); + Navigator.of(dialogContext).pop(); _proceedToLogin(); }, - child: const Text('Log In'), + child: Text(translations.session.log_in), ), ], ); @@ -139,27 +139,28 @@ class _SessionListenerState extends State { /// Shows a dialog when a session error occurs, with retry option. void _showSessionErrorDialog(String errorMessage) { + final Translations translations = t; showDialog( context: context, barrierDismissible: false, - builder: (BuildContext context) { + builder: (BuildContext dialogContext) { return AlertDialog( - title: const Text('Session Error'), + title: Text(translations.session.error_title), content: Text(errorMessage), actions: [ TextButton( onPressed: () { // User can retry by dismissing and continuing - Modular.to.popSafe(); + Navigator.of(dialogContext).pop(); }, - child: const Text('Continue'), + child: Text(translations.common.continue_text), ), TextButton( onPressed: () { - Modular.to.popSafe(); + Navigator.of(dialogContext).pop(); _proceedToLogin(); }, - child: const Text('Log Out'), + child: Text(translations.session.log_out), ), ], ); diff --git a/apps/mobile/packages/core/lib/core.dart b/apps/mobile/packages/core/lib/core.dart index f9143b60..da88a048 100644 --- a/apps/mobile/packages/core/lib/core.dart +++ b/apps/mobile/packages/core/lib/core.dart @@ -42,6 +42,9 @@ export 'src/services/session/client_session_store.dart'; export 'src/services/session/staff_session_store.dart'; export 'src/services/session/v2_session_service.dart'; +// Auth +export 'src/services/auth/auth_token_provider.dart'; + // Device Services export 'src/services/device/camera/camera_service.dart'; export 'src/services/device/gallery/gallery_service.dart'; diff --git a/apps/mobile/packages/core/lib/src/core_module.dart b/apps/mobile/packages/core/lib/src/core_module.dart index 40145f27..529852c0 100644 --- a/apps/mobile/packages/core/lib/src/core_module.dart +++ b/apps/mobile/packages/core/lib/src/core_module.dart @@ -3,6 +3,9 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:image_picker/image_picker.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:krow_core/src/services/auth/auth_token_provider.dart'; +import 'package:krow_core/src/services/auth/firebase_auth_token_provider.dart'; + import '../core.dart'; /// A module that provides core services and shared dependencies. @@ -57,7 +60,10 @@ class CoreModule extends Module { ), ); - // 6. Register Geofence Device Services + // 6. Auth Token Provider + i.addLazySingleton(FirebaseAuthTokenProvider.new); + + // 7. Register Geofence Device Services i.addLazySingleton(() => const LocationService()); i.addLazySingleton(() => NotificationService()); i.addLazySingleton(() => StorageService()); diff --git a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart index 714172bb..aeb0f45f 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/client_endpoints.dart @@ -48,6 +48,26 @@ abstract final class ClientEndpoints { static const ApiEndpoint coverageCoreTeam = ApiEndpoint('/client/coverage/core-team'); + /// Coverage incidents. + static const ApiEndpoint coverageIncidents = + ApiEndpoint('/client/coverage/incidents'); + + /// Blocked staff. + static const ApiEndpoint coverageBlockedStaff = + ApiEndpoint('/client/coverage/blocked-staff'); + + /// Coverage swap requests. + static const ApiEndpoint coverageSwapRequests = + ApiEndpoint('/client/coverage/swap-requests'); + + /// Dispatch teams. + static const ApiEndpoint coverageDispatchTeams = + ApiEndpoint('/client/coverage/dispatch-teams'); + + /// Dispatch candidates. + static const ApiEndpoint coverageDispatchCandidates = + ApiEndpoint('/client/coverage/dispatch-candidates'); + /// Hubs list. static const ApiEndpoint hubs = ApiEndpoint('/client/hubs'); @@ -162,4 +182,28 @@ abstract final class ClientEndpoints { /// Cancel late worker assignment. static ApiEndpoint coverageCancelLateWorker(String assignmentId) => ApiEndpoint('/client/coverage/late-workers/$assignmentId/cancel'); + + /// Register or delete device push token (POST to register, DELETE to remove). + static const ApiEndpoint devicesPushTokens = + ApiEndpoint('/client/devices/push-tokens'); + + /// Create shift manager. + static const ApiEndpoint shiftManagerCreate = + ApiEndpoint('/client/shift-managers'); + + /// Resolve coverage swap request by ID. + static ApiEndpoint coverageSwapRequestResolve(String id) => + ApiEndpoint('/client/coverage/swap-requests/$id/resolve'); + + /// Cancel coverage swap request by ID. + static ApiEndpoint coverageSwapRequestCancel(String id) => + ApiEndpoint('/client/coverage/swap-requests/$id/cancel'); + + /// Create dispatch team membership. + static const ApiEndpoint coverageDispatchTeamMembershipsCreate = + ApiEndpoint('/client/coverage/dispatch-teams/memberships'); + + /// Delete dispatch team membership by ID. + static ApiEndpoint coverageDispatchTeamMembershipsDelete(String id) => + ApiEndpoint('/client/coverage/dispatch-teams/memberships/$id'); } diff --git a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/core_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/core_endpoints.dart index 8c18a244..7931ad99 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/core_endpoints.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/core_endpoints.dart @@ -2,39 +2,42 @@ import 'package:krow_domain/krow_domain.dart' show ApiEndpoint; /// Core infrastructure endpoints (upload, signed URLs, LLM, verifications, /// rapid orders). +/// +/// Paths are at the unified API root level (not under `/core/`). abstract final class CoreEndpoints { /// Upload a file. - static const ApiEndpoint uploadFile = - ApiEndpoint('/core/upload-file'); + static const ApiEndpoint uploadFile = ApiEndpoint('/upload-file'); /// Create a signed URL for a file. - static const ApiEndpoint createSignedUrl = - ApiEndpoint('/core/create-signed-url'); + static const ApiEndpoint createSignedUrl = ApiEndpoint('/create-signed-url'); /// Invoke a Large Language Model. - static const ApiEndpoint invokeLlm = ApiEndpoint('/core/invoke-llm'); + static const ApiEndpoint invokeLlm = ApiEndpoint('/invoke-llm'); /// Root for verification operations. - static const ApiEndpoint verifications = - ApiEndpoint('/core/verifications'); + static const ApiEndpoint verifications = ApiEndpoint('/verifications'); /// Get status of a verification job. static ApiEndpoint verificationStatus(String id) => - ApiEndpoint('/core/verifications/$id'); + ApiEndpoint('/verifications/$id'); /// Review a verification decision. static ApiEndpoint verificationReview(String id) => - ApiEndpoint('/core/verifications/$id/review'); + ApiEndpoint('/verifications/$id/review'); /// Retry a verification job. static ApiEndpoint verificationRetry(String id) => - ApiEndpoint('/core/verifications/$id/retry'); + ApiEndpoint('/verifications/$id/retry'); /// Transcribe audio to text for rapid orders. static const ApiEndpoint transcribeRapidOrder = - ApiEndpoint('/core/rapid-orders/transcribe'); + ApiEndpoint('/rapid-orders/transcribe'); /// Parse text to structured rapid order. static const ApiEndpoint parseRapidOrder = - ApiEndpoint('/core/rapid-orders/parse'); + ApiEndpoint('/rapid-orders/parse'); + + /// Combined transcribe + parse in a single call. + static const ApiEndpoint processRapidOrder = + ApiEndpoint('/rapid-orders/process'); } diff --git a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart index c98c780e..6955b964 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart @@ -105,6 +105,10 @@ abstract final class StaffEndpoints { /// Benefits. static const ApiEndpoint benefits = ApiEndpoint('/staff/profile/benefits'); + /// Benefits history. + static const ApiEndpoint benefitsHistory = + ApiEndpoint('/staff/profile/benefits/history'); + /// Time card. static const ApiEndpoint timeCard = ApiEndpoint('/staff/profile/time-card'); @@ -112,6 +116,10 @@ abstract final class StaffEndpoints { /// Privacy settings. static const ApiEndpoint privacy = ApiEndpoint('/staff/profile/privacy'); + /// Preferred locations. + static const ApiEndpoint locations = + ApiEndpoint('/staff/profile/locations'); + /// FAQs. static const ApiEndpoint faqs = ApiEndpoint('/staff/faqs'); @@ -177,4 +185,16 @@ abstract final class StaffEndpoints { /// Delete certificate by ID. static ApiEndpoint certificateDelete(String certificateId) => ApiEndpoint('/staff/profile/certificates/$certificateId'); + + /// Submit shift for approval. + static ApiEndpoint shiftSubmitForApproval(String shiftId) => + ApiEndpoint('/staff/shifts/$shiftId/submit-for-approval'); + + /// Location streams. + static const ApiEndpoint locationStreams = + ApiEndpoint('/staff/location-streams'); + + /// Register or delete device push token (POST to register, DELETE to remove). + static const ApiEndpoint devicesPushTokens = + ApiEndpoint('/staff/devices/push-tokens'); } diff --git a/apps/mobile/packages/core/lib/src/services/auth/auth_token_provider.dart b/apps/mobile/packages/core/lib/src/services/auth/auth_token_provider.dart new file mode 100644 index 00000000..b42d7620 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/auth/auth_token_provider.dart @@ -0,0 +1,11 @@ +/// Provides the current Firebase ID token for API authentication. +/// +/// Lives in core so feature packages can access auth tokens +/// without importing firebase_auth directly. +abstract interface class AuthTokenProvider { + /// Returns the current ID token, refreshing if expired. + /// + /// Pass [forceRefresh] to force a token refresh from Firebase. + /// Returns null if no user is signed in. + Future getIdToken({bool forceRefresh}); +} diff --git a/apps/mobile/packages/core/lib/src/services/auth/firebase_auth_token_provider.dart b/apps/mobile/packages/core/lib/src/services/auth/firebase_auth_token_provider.dart new file mode 100644 index 00000000..de9f162a --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/auth/firebase_auth_token_provider.dart @@ -0,0 +1,15 @@ +import 'package:firebase_auth/firebase_auth.dart'; + +import 'package:krow_core/src/services/auth/auth_token_provider.dart'; + +/// Firebase-backed implementation of [AuthTokenProvider]. +/// +/// Delegates to [FirebaseAuth] to get the current user's +/// ID token. Must run in the main isolate (Firebase SDK requirement). +class FirebaseAuthTokenProvider implements AuthTokenProvider { + @override + Future getIdToken({bool forceRefresh = false}) async { + final User? user = FirebaseAuth.instance.currentUser; + return user?.getIdToken(forceRefresh); + } +} 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 a7a83a54..766144cf 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 @@ -12,6 +12,13 @@ "english": "English", "spanish": "Español" }, + "session": { + "expired_title": "Session Expired", + "expired_message": "Your session has expired. Please log in again to continue.", + "error_title": "Session Error", + "log_in": "Log In", + "log_out": "Log Out" + }, "settings": { "language": "Language", "change_language": "Change Language" @@ -1337,7 +1344,14 @@ "applying_dialog": { "title": "Applying" }, - "eligibility_requirements": "Eligibility Requirements" + "eligibility_requirements": "Eligibility Requirements", + "missing_certifications": "You are missing required certifications or documents to claim this shift. Please upload them to continue.", + "go_to_certificates": "Go to Certificates", + "shift_booked": "Shift successfully booked!", + "shift_not_found": "Shift not found", + "shift_declined_success": "Shift declined", + "complete_account_title": "Complete Your Account", + "complete_account_description": "Complete your account to book this shift and start earning" }, "my_shift_card": { "submit_for_approval": "Submit for Approval", @@ -1457,7 +1471,8 @@ "shift": { "no_open_roles": "There are no open positions available for this shift.", "application_not_found": "Your application couldn't be found.", - "no_active_shift": "You don't have an active shift to clock out from." + "no_active_shift": "You don't have an active shift to clock out from.", + "not_found": "Shift not found. It may have been removed or is no longer available." }, "clock_in": { "location_verification_required": "Please wait for location verification before clocking in.", 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 22d14d50..31bc829d 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 @@ -12,6 +12,13 @@ "english": "English", "spanish": "Español" }, + "session": { + "expired_title": "Sesión Expirada", + "expired_message": "Tu sesión ha expirado. Por favor inicia sesión de nuevo para continuar.", + "error_title": "Error de Sesión", + "log_in": "Iniciar Sesión", + "log_out": "Cerrar Sesión" + }, "settings": { "language": "Idioma", "change_language": "Cambiar Idioma" @@ -1332,7 +1339,14 @@ "applying_dialog": { "title": "Solicitando" }, - "eligibility_requirements": "Requisitos de Elegibilidad" + "eligibility_requirements": "Requisitos de Elegibilidad", + "missing_certifications": "Te faltan certificaciones o documentos requeridos para reclamar este turno. Por favor, súbelos para continuar.", + "go_to_certificates": "Ir a Certificados", + "shift_booked": "¡Turno reservado con éxito!", + "shift_not_found": "Turno no encontrado", + "shift_declined_success": "Turno rechazado", + "complete_account_title": "Completa Tu Cuenta", + "complete_account_description": "Completa tu cuenta para reservar este turno y comenzar a ganar" }, "my_shift_card": { "submit_for_approval": "Enviar para Aprobación", @@ -1452,7 +1466,8 @@ "shift": { "no_open_roles": "No hay posiciones abiertas disponibles para este turno.", "application_not_found": "No se pudo encontrar tu solicitud.", - "no_active_shift": "No tienes un turno activo para registrar salida." + "no_active_shift": "No tienes un turno activo para registrar salida.", + "not_found": "Turno no encontrado. Puede haber sido eliminado o ya no está disponible." }, "clock_in": { "location_verification_required": "Por favor, espera la verificaci\u00f3n de ubicaci\u00f3n antes de registrar entrada.", diff --git a/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart b/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart index 69e4282d..5f6d5388 100644 --- a/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart +++ b/apps/mobile/packages/core_localization/lib/src/utils/error_translator.dart @@ -124,6 +124,8 @@ String _translateShiftError(String errorType) { return t.errors.shift.application_not_found; case 'no_active_shift': return t.errors.shift.no_active_shift; + case 'not_found': + return t.errors.shift.not_found; default: return t.errors.generic.unknown; } diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/assigned_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/assigned_shift.dart index a41371be..11b27bc1 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/assigned_shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/assigned_shift.dart @@ -18,6 +18,10 @@ class AssignedShift extends Equatable { required this.startTime, required this.endTime, required this.hourlyRateCents, + required this.hourlyRate, + required this.totalRateCents, + required this.totalRate, + required this.clientName, required this.orderType, required this.status, }); @@ -33,6 +37,10 @@ class AssignedShift extends Equatable { startTime: DateTime.parse(json['startTime'] as String), endTime: DateTime.parse(json['endTime'] as String), hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, + hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0, + totalRateCents: json['totalRateCents'] as int? ?? 0, + totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0, + clientName: json['clientName'] as String? ?? '', orderType: OrderType.fromJson(json['orderType'] as String?), status: AssignmentStatus.fromJson(json['status'] as String?), ); @@ -62,6 +70,18 @@ class AssignedShift extends Equatable { /// Pay rate in cents per hour. final int hourlyRateCents; + /// Pay rate in dollars per hour. + final double hourlyRate; + + /// Total pay for this shift in cents. + final int totalRateCents; + + /// Total pay for this shift in dollars. + final double totalRate; + + /// Name of the client / business for this shift. + final String clientName; + /// Order type. final OrderType orderType; @@ -79,6 +99,10 @@ class AssignedShift extends Equatable { 'startTime': startTime.toIso8601String(), 'endTime': endTime.toIso8601String(), 'hourlyRateCents': hourlyRateCents, + 'hourlyRate': hourlyRate, + 'totalRateCents': totalRateCents, + 'totalRate': totalRate, + 'clientName': clientName, 'orderType': orderType.toJson(), 'status': status.toJson(), }; @@ -94,6 +118,10 @@ class AssignedShift extends Equatable { startTime, endTime, hourlyRateCents, + hourlyRate, + totalRateCents, + totalRate, + clientName, orderType, status, ]; diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart index 3d3e47e2..54f29d7d 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart @@ -12,10 +12,18 @@ class CompletedShift extends Equatable { required this.shiftId, required this.title, required this.location, + required this.clientName, required this.date, + required this.startTime, + required this.endTime, required this.minutesWorked, + required this.hourlyRateCents, + required this.hourlyRate, + required this.totalRateCents, + required this.totalRate, required this.paymentStatus, required this.status, + this.timesheetStatus, }); /// Deserialises from the V2 API JSON response. @@ -25,10 +33,22 @@ class CompletedShift extends Equatable { shiftId: json['shiftId'] as String, title: json['title'] as String? ?? '', location: json['location'] as String? ?? '', + clientName: json['clientName'] as String? ?? '', date: DateTime.parse(json['date'] as String), + startTime: json['startTime'] != null + ? DateTime.parse(json['startTime'] as String) + : DateTime.now(), + endTime: json['endTime'] != null + ? DateTime.parse(json['endTime'] as String) + : DateTime.now(), minutesWorked: json['minutesWorked'] as int? ?? 0, + hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, + hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0, + totalRateCents: json['totalRateCents'] as int? ?? 0, + totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0, paymentStatus: PaymentStatus.fromJson(json['paymentStatus'] as String?), status: AssignmentStatus.completed, + timesheetStatus: json['timesheetStatus'] as String?, ); } @@ -44,18 +64,42 @@ class CompletedShift extends Equatable { /// Human-readable location label. final String location; + /// Name of the client / business for this shift. + final String clientName; + /// The date the shift was worked. final DateTime date; + /// Scheduled start time. + final DateTime startTime; + + /// Scheduled end time. + final DateTime endTime; + /// Total minutes worked (regular + overtime). final int minutesWorked; + /// Pay rate in cents per hour. + final int hourlyRateCents; + + /// Pay rate in dollars per hour. + final double hourlyRate; + + /// Total pay for this shift in cents. + final int totalRateCents; + + /// Total pay for this shift in dollars. + final double totalRate; + /// Payment processing status. final PaymentStatus paymentStatus; /// Assignment status (should always be `completed` for this class). final AssignmentStatus status; + /// Timesheet status (e.g. `SUBMITTED`, `APPROVED`, `PAID`, or null). + final String? timesheetStatus; + /// Serialises to JSON. Map toJson() { return { @@ -63,9 +107,17 @@ class CompletedShift extends Equatable { 'shiftId': shiftId, 'title': title, 'location': location, + 'clientName': clientName, 'date': date.toIso8601String(), + 'startTime': startTime.toIso8601String(), + 'endTime': endTime.toIso8601String(), 'minutesWorked': minutesWorked, + 'hourlyRateCents': hourlyRateCents, + 'hourlyRate': hourlyRate, + 'totalRateCents': totalRateCents, + 'totalRate': totalRate, 'paymentStatus': paymentStatus.toJson(), + 'timesheetStatus': timesheetStatus, }; } @@ -75,8 +127,17 @@ class CompletedShift extends Equatable { shiftId, title, location, + clientName, date, + startTime, + endTime, minutesWorked, + hourlyRateCents, + hourlyRate, + totalRateCents, + totalRate, paymentStatus, + timesheetStatus, + status, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/open_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/open_shift.dart index 8481b343..2da02a03 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/open_shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/open_shift.dart @@ -17,6 +17,7 @@ class OpenShift extends Equatable { required this.startTime, required this.endTime, required this.hourlyRateCents, + required this.hourlyRate, required this.orderType, required this.instantBook, required this.requiredWorkerCount, @@ -33,6 +34,7 @@ class OpenShift extends Equatable { startTime: DateTime.parse(json['startTime'] as String), endTime: DateTime.parse(json['endTime'] as String), hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, + hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0, orderType: OrderType.fromJson(json['orderType'] as String?), instantBook: json['instantBook'] as bool? ?? false, requiredWorkerCount: json['requiredWorkerCount'] as int? ?? 1, @@ -63,6 +65,9 @@ class OpenShift extends Equatable { /// Pay rate in cents per hour. final int hourlyRateCents; + /// Pay rate in dollars per hour. + final double hourlyRate; + /// Order type. final OrderType orderType; @@ -83,6 +88,7 @@ class OpenShift extends Equatable { 'startTime': startTime.toIso8601String(), 'endTime': endTime.toIso8601String(), 'hourlyRateCents': hourlyRateCents, + 'hourlyRate': hourlyRate, 'orderType': orderType.toJson(), 'instantBook': instantBook, 'requiredWorkerCount': requiredWorkerCount, @@ -99,6 +105,7 @@ class OpenShift extends Equatable { startTime, endTime, hourlyRateCents, + hourlyRate, orderType, instantBook, requiredWorkerCount, diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart index 03d21a7b..fd84d394 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart @@ -11,7 +11,7 @@ class Shift extends Equatable { /// Creates a [Shift]. const Shift({ required this.id, - required this.orderId, + this.orderId, required this.title, required this.status, required this.startsAt, @@ -25,13 +25,16 @@ class Shift extends Equatable { required this.requiredWorkers, required this.assignedWorkers, this.notes, + this.clockInMode, + this.allowClockInOverride, + this.nfcTagId, }); /// Deserialises from the V2 API JSON response. factory Shift.fromJson(Map json) { return Shift( id: json['id'] as String, - orderId: json['orderId'] as String, + orderId: json['orderId'] as String?, title: json['title'] as String? ?? '', status: ShiftStatus.fromJson(json['status'] as String?), startsAt: DateTime.parse(json['startsAt'] as String), @@ -45,14 +48,17 @@ class Shift extends Equatable { requiredWorkers: json['requiredWorkers'] as int? ?? 1, assignedWorkers: json['assignedWorkers'] as int? ?? 0, notes: json['notes'] as String?, + clockInMode: json['clockInMode'] as String?, + allowClockInOverride: json['allowClockInOverride'] as bool?, + nfcTagId: json['nfcTagId'] as String?, ); } /// The shift row id. final String id; - /// The parent order id. - final String orderId; + /// The parent order id (may be null for today-shifts endpoint). + final String? orderId; /// Display title. final String title; @@ -93,6 +99,15 @@ class Shift extends Equatable { /// Free-form notes for the shift. final String? notes; + /// Clock-in mode for this shift (`NFC_REQUIRED`, `GEO_REQUIRED`, `EITHER`). + final String? clockInMode; + + /// Whether the worker is allowed to override the clock-in method. + final bool? allowClockInOverride; + + /// NFC tag identifier for NFC-based clock-in. + final String? nfcTagId; + /// Serialises to JSON. Map toJson() { return { @@ -111,6 +126,9 @@ class Shift extends Equatable { 'requiredWorkers': requiredWorkers, 'assignedWorkers': assignedWorkers, 'notes': notes, + 'clockInMode': clockInMode, + 'allowClockInOverride': allowClockInOverride, + 'nfcTagId': nfcTagId, }; } @@ -140,5 +158,8 @@ class Shift extends Equatable { requiredWorkers, assignedWorkers, notes, + clockInMode, + allowClockInOverride, + nfcTagId, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart index 793dc07e..c4082982 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/shift_detail.dart @@ -3,6 +3,7 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/src/entities/enums/application_status.dart'; import 'package:krow_domain/src/entities/enums/assignment_status.dart'; import 'package:krow_domain/src/entities/enums/order_type.dart'; +import 'package:krow_domain/src/entities/shifts/shift.dart'; /// Full detail view of a shift for the staff member. /// @@ -18,17 +19,27 @@ class ShiftDetail extends Equatable { this.description, required this.location, this.address, + required this.clientName, + this.latitude, + this.longitude, required this.date, required this.startTime, required this.endTime, required this.roleId, required this.roleName, required this.hourlyRateCents, + required this.hourlyRate, + required this.totalRateCents, + required this.totalRate, required this.orderType, required this.requiredCount, required this.confirmedCount, this.assignmentStatus, this.applicationStatus, + this.clockInMode, + required this.allowClockInOverride, + this.geofenceRadiusMeters, + this.nfcTagId, }); /// Deserialises from the V2 API JSON response. @@ -39,12 +50,18 @@ class ShiftDetail extends Equatable { description: json['description'] as String?, location: json['location'] as String? ?? '', address: json['address'] as String?, + clientName: json['clientName'] as String? ?? '', + latitude: Shift.parseDouble(json['latitude']), + longitude: Shift.parseDouble(json['longitude']), date: DateTime.parse(json['date'] as String), startTime: DateTime.parse(json['startTime'] as String), endTime: DateTime.parse(json['endTime'] as String), roleId: json['roleId'] as String, roleName: json['roleName'] as String, hourlyRateCents: json['hourlyRateCents'] as int? ?? 0, + hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0, + totalRateCents: json['totalRateCents'] as int? ?? 0, + totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0, orderType: OrderType.fromJson(json['orderType'] as String?), requiredCount: json['requiredCount'] as int? ?? 1, confirmedCount: json['confirmedCount'] as int? ?? 0, @@ -54,6 +71,10 @@ class ShiftDetail extends Equatable { applicationStatus: json['applicationStatus'] != null ? ApplicationStatus.fromJson(json['applicationStatus'] as String?) : null, + clockInMode: json['clockInMode'] as String?, + allowClockInOverride: json['allowClockInOverride'] as bool? ?? false, + geofenceRadiusMeters: json['geofenceRadiusMeters'] as int?, + nfcTagId: json['nfcTagId'] as String?, ); } @@ -72,6 +93,15 @@ class ShiftDetail extends Equatable { /// Street address of the shift location. final String? address; + /// Name of the client / business for this shift. + final String clientName; + + /// Latitude for map display and geofence validation. + final double? latitude; + + /// Longitude for map display and geofence validation. + final double? longitude; + /// Date of the shift (same as startTime, kept for display grouping). final DateTime date; @@ -90,6 +120,15 @@ class ShiftDetail extends Equatable { /// Pay rate in cents per hour. final int hourlyRateCents; + /// Pay rate in dollars per hour. + final double hourlyRate; + + /// Total pay for this shift in cents. + final int totalRateCents; + + /// Total pay for this shift in dollars. + final double totalRate; + /// Order type. final OrderType orderType; @@ -105,6 +144,26 @@ class ShiftDetail extends Equatable { /// Current worker's application status, if applied. final ApplicationStatus? applicationStatus; + /// Clock-in mode for this shift (`NFC_REQUIRED`, `GEO_REQUIRED`, `EITHER`). + final String? clockInMode; + + /// Whether the worker is allowed to override the clock-in method. + final bool allowClockInOverride; + + /// Geofence radius in meters for clock-in validation. + final int? geofenceRadiusMeters; + + /// NFC tag identifier for NFC-based clock-in. + final String? nfcTagId; + + /// Duration of the shift in hours. + double get durationHours { + return endTime.difference(startTime).inMinutes / 60; + } + + /// Estimated total pay in dollars. + double get estimatedTotal => hourlyRate * durationHours; + /// Serialises to JSON. Map toJson() { return { @@ -113,17 +172,27 @@ class ShiftDetail extends Equatable { 'description': description, 'location': location, 'address': address, + 'clientName': clientName, + 'latitude': latitude, + 'longitude': longitude, 'date': date.toIso8601String(), 'startTime': startTime.toIso8601String(), 'endTime': endTime.toIso8601String(), 'roleId': roleId, 'roleName': roleName, 'hourlyRateCents': hourlyRateCents, + 'hourlyRate': hourlyRate, + 'totalRateCents': totalRateCents, + 'totalRate': totalRate, 'orderType': orderType.toJson(), 'requiredCount': requiredCount, 'confirmedCount': confirmedCount, 'assignmentStatus': assignmentStatus?.toJson(), 'applicationStatus': applicationStatus?.toJson(), + 'clockInMode': clockInMode, + 'allowClockInOverride': allowClockInOverride, + 'geofenceRadiusMeters': geofenceRadiusMeters, + 'nfcTagId': nfcTagId, }; } @@ -134,16 +203,26 @@ class ShiftDetail extends Equatable { description, location, address, + clientName, + latitude, + longitude, date, startTime, endTime, roleId, roleName, hourlyRateCents, + hourlyRate, + totalRateCents, + totalRate, orderType, requiredCount, confirmedCount, assignmentStatus, applicationStatus, + clockInMode, + allowClockInOverride, + geofenceRadiusMeters, + nfcTagId, ]; } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart index 3006a1ed..ff80e27a 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart @@ -1,6 +1,8 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_clock_in/src/domain/arguments/clock_in_arguments.dart'; +import 'package:staff_clock_in/src/domain/arguments/clock_out_arguments.dart'; import 'package:staff_clock_in/src/domain/repositories/clock_in_repository_interface.dart'; /// Implementation of [ClockInRepositoryInterface] using the V2 REST API. @@ -19,7 +21,8 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface { final ApiResponse response = await _apiService.get( StaffEndpoints.clockInShiftsToday, ); - final List items = response.data['items'] as List; + final List items = + response.data['items'] as List? ?? []; return items .map( (dynamic json) => @@ -37,36 +40,20 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface { } @override - Future clockIn({ - required String shiftId, - String? notes, - }) async { + Future clockIn(ClockInArguments arguments) async { await _apiService.post( StaffEndpoints.clockIn, - data: { - 'shiftId': shiftId, - 'sourceType': 'GEO', - if (notes != null && notes.isNotEmpty) 'notes': notes, - }, + data: arguments.toJson(), ); // Re-fetch the attendance status to get the canonical state after clock-in. return getAttendanceStatus(); } @override - Future clockOut({ - String? notes, - int? breakTimeMinutes, - String? shiftId, - }) async { + Future clockOut(ClockOutArguments arguments) async { await _apiService.post( StaffEndpoints.clockOut, - data: { - if (shiftId != null) 'shiftId': shiftId, - 'sourceType': 'GEO', - if (notes != null && notes.isNotEmpty) 'notes': notes, - if (breakTimeMinutes != null) 'breakMinutes': breakTimeMinutes, - }, + data: arguments.toJson(), ); // Re-fetch the attendance status to get the canonical state after clock-out. return getAttendanceStatus(); @@ -76,14 +63,19 @@ class ClockInRepositoryImpl implements ClockInRepositoryInterface { static Shift _mapTodayShiftJsonToShift(Map json) { return Shift( id: json['shiftId'] as String, - orderId: json['orderId'] as String? ?? '', + orderId: null, title: json['clientName'] as String? ?? json['roleName'] as String? ?? '', status: ShiftStatus.assigned, startsAt: DateTime.parse(json['startTime'] as String), endsAt: DateTime.parse(json['endTime'] as String), - locationName: json['location'] as String?, + locationName: json['locationAddress'] as String? ?? + json['location'] as String?, latitude: Shift.parseDouble(json['latitude']), longitude: Shift.parseDouble(json['longitude']), + geofenceRadiusMeters: json['geofenceRadiusMeters'] as int?, + clockInMode: json['clockInMode'] as String?, + allowClockInOverride: json['allowClockInOverride'] as bool?, + nfcTagId: json['nfcTagId'] as String?, requiredWorkers: 0, assignedWorkers: 0, ); diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart index d22ea458..aaf89787 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/services/background_geofence_service.dart @@ -1,7 +1,50 @@ // ignore_for_file: avoid_print +// Print statements are intentional — background isolates cannot use +// dart:developer or structured loggers from the DI container. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; +/// Lightweight HTTP client for background isolate API calls. +/// +/// Cannot use Dio or DI — uses [HttpClient] directly with auth tokens +/// from [StorageService] (SharedPreferences, works across isolates). +class BackgroundApiClient { + /// Creates a [BackgroundApiClient] with its own HTTP client and storage. + BackgroundApiClient() : _client = HttpClient(), _storage = StorageService(); + + final HttpClient _client; + final StorageService _storage; + + /// POSTs JSON to [path] under the V2 API base URL. + /// + /// Returns the HTTP status code, or null if no auth token is available. + Future post(String path, Map body) async { + final String? token = await _storage.getString( + BackgroundGeofenceService._keyAuthToken, + ); + if (token == null || token.isEmpty) { + print('[BackgroundApiClient] No auth token stored, skipping POST'); + return null; + } + + final Uri uri = Uri.parse('${AppConfig.v2ApiBaseUrl}$path'); + final HttpClientRequest request = await _client.postUrl(uri); + request.headers.set(HttpHeaders.contentTypeHeader, 'application/json'); + request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $token'); + request.write(jsonEncode(body)); + final HttpClientResponse response = await request.close(); + await response.drain(); + return response.statusCode; + } + + /// Closes the underlying [HttpClient]. + void dispose() => _client.close(force: false); +} + /// Top-level callback dispatcher for background geofence tasks. /// /// Must be a top-level function because workmanager executes it in a separate @@ -13,83 +56,134 @@ import 'package:krow_domain/krow_domain.dart'; /// is retained solely for this entry-point pattern. @pragma('vm:entry-point') void backgroundGeofenceDispatcher() { - const BackgroundTaskService().executeTask( - (String task, Map? inputData) async { - print('[BackgroundGeofence] Task triggered: $task'); - print('[BackgroundGeofence] Input data: $inputData'); - print( - '[BackgroundGeofence] Timestamp: ${DateTime.now().toIso8601String()}', - ); + const BackgroundTaskService().executeTask(( + String task, + Map? inputData, + ) async { + print('[BackgroundGeofence] Task triggered: $task'); + print('[BackgroundGeofence] Input data: $inputData'); + print( + '[BackgroundGeofence] Timestamp: ${DateTime.now().toIso8601String()}', + ); - final double? targetLat = inputData?['targetLat'] as double?; - final double? targetLng = inputData?['targetLng'] as double?; - final String? shiftId = inputData?['shiftId'] as String?; + final double? targetLat = inputData?['targetLat'] as double?; + final double? targetLng = inputData?['targetLng'] as double?; + final String? shiftId = inputData?['shiftId'] as String?; + final double geofenceRadius = + (inputData?['geofenceRadiusMeters'] as num?)?.toDouble() ?? + BackgroundGeofenceService.defaultGeofenceRadiusMeters; - print( - '[BackgroundGeofence] Target: lat=$targetLat, lng=$targetLng, ' - 'shiftId=$shiftId', - ); + print( + '[BackgroundGeofence] Target: lat=$targetLat, lng=$targetLng, ' + 'shiftId=$shiftId, geofenceRadius=${geofenceRadius.round()}m', + ); - if (targetLat == null || targetLng == null) { - print( - '[BackgroundGeofence] Missing target coordinates, skipping check', - ); - return true; - } - - try { - const LocationService locationService = LocationService(); - final DeviceLocation location = await locationService.getCurrentLocation(); - print( - '[BackgroundGeofence] Current position: ' - 'lat=${location.latitude}, lng=${location.longitude}', - ); - - final double distance = calculateDistance( - location.latitude, - location.longitude, - targetLat, - targetLng, - ); - print( - '[BackgroundGeofence] Distance from target: ${distance.round()}m', - ); - - if (distance > BackgroundGeofenceService.geofenceRadiusMeters) { - print( - '[BackgroundGeofence] Worker is outside geofence ' - '(${distance.round()}m > ' - '${BackgroundGeofenceService.geofenceRadiusMeters.round()}m), ' - 'showing notification', - ); - - final String title = inputData?['leftGeofenceTitle'] as String? ?? - "You've Left the Workplace"; - final String body = inputData?['leftGeofenceBody'] as String? ?? - 'You appear to be more than 500m from your shift location.'; - - final NotificationService notificationService = - NotificationService(); - await notificationService.showNotification( - id: BackgroundGeofenceService.leftGeofenceNotificationId, - title: title, - body: body, - ); - } else { - print( - '[BackgroundGeofence] Worker is within geofence ' - '(${distance.round()}m <= ' - '${BackgroundGeofenceService.geofenceRadiusMeters.round()}m)', - ); - } - } catch (e) { - print('[BackgroundGeofence] Error during background check: $e'); - } - - print('[BackgroundGeofence] Background check completed'); + if (targetLat == null || targetLng == null) { + print('[BackgroundGeofence] Missing target coordinates, skipping check'); return true; - }, - ); + } + + final BackgroundApiClient client = BackgroundApiClient(); + try { + const LocationService locationService = LocationService(); + final DeviceLocation location = await locationService + .getCurrentLocation(); + print( + '[BackgroundGeofence] Current position: ' + 'lat=${location.latitude}, lng=${location.longitude}', + ); + + final double distance = calculateDistance( + location.latitude, + location.longitude, + targetLat, + targetLng, + ); + print('[BackgroundGeofence] Distance from target: ${distance.round()}m'); + + // POST location stream to the V2 API before geofence check. + unawaited( + _postLocationStream( + client: client, + shiftId: shiftId, + location: location, + ), + ); + + if (distance > geofenceRadius) { + print( + '[BackgroundGeofence] Worker is outside geofence ' + '(${distance.round()}m > ' + '${geofenceRadius.round()}m), ' + 'showing notification', + ); + + // Fallback for when localized strings are not available in the + // background isolate. The primary path passes localized strings + // via inputData from the UI layer. + final String title = + inputData?['leftGeofenceTitle'] as String? ?? + 'You have left the work area'; + final String body = + inputData?['leftGeofenceBody'] as String? ?? + 'You appear to have moved outside your shift location.'; + + final NotificationService notificationService = NotificationService(); + await notificationService.showNotification( + id: BackgroundGeofenceService.leftGeofenceNotificationId, + title: title, + body: body, + ); + } else { + print( + '[BackgroundGeofence] Worker is within geofence ' + '(${distance.round()}m <= ' + '${geofenceRadius.round()}m)', + ); + } + } catch (e) { + print('[BackgroundGeofence] Error during background check: $e'); + } finally { + client.dispose(); + } + + print('[BackgroundGeofence] Background check completed'); + return true; + }); +} + +/// Posts a location data point to the V2 location-streams endpoint. +/// +/// Uses [BackgroundApiClient] for isolate-safe HTTP access. +/// Failures are silently caught — location streaming is best-effort. +Future _postLocationStream({ + required BackgroundApiClient client, + required String? shiftId, + required DeviceLocation location, +}) async { + if (shiftId == null) return; + + try { + final int? status = await client.post( + StaffEndpoints.locationStreams.path, + { + 'shiftId': shiftId, + 'sourceType': 'GEO', + 'points': >[ + { + 'capturedAt': location.timestamp.toUtc().toIso8601String(), + 'latitude': location.latitude, + 'longitude': location.longitude, + 'accuracyMeters': location.accuracy, + }, + ], + 'metadata': {'source': 'background-workmanager'}, + }, + ); + print('[BackgroundGeofence] Location stream POST status: $status'); + } catch (e) { + print('[BackgroundGeofence] Location stream POST failed: $e'); + } } /// Service that manages periodic background geofence checks while clocked in. @@ -98,13 +192,12 @@ void backgroundGeofenceDispatcher() { /// delivery is handled by [ClockInNotificationService]. The background isolate /// logic lives in the top-level [backgroundGeofenceDispatcher] function above. class BackgroundGeofenceService { - /// Creates a [BackgroundGeofenceService] instance. BackgroundGeofenceService({ required BackgroundTaskService backgroundTaskService, required StorageService storageService, - }) : _backgroundTaskService = backgroundTaskService, - _storageService = storageService; + }) : _backgroundTaskService = backgroundTaskService, + _storageService = storageService; /// The core background task service for scheduling periodic work. final BackgroundTaskService _backgroundTaskService; @@ -124,6 +217,9 @@ class BackgroundGeofenceService { /// Storage key for the active tracking flag. static const String _keyTrackingActive = 'geofence_tracking_active'; + /// Storage key for the Firebase auth token used in background isolate. + static const String _keyAuthToken = 'geofence_auth_token'; + /// Unique task name for the periodic background check. static const String taskUniqueName = 'geofence_background_check'; @@ -136,8 +232,12 @@ class BackgroundGeofenceService { /// it directly (background isolate has no DI access). static const int leftGeofenceNotificationId = 2; - /// Geofence radius in meters. - static const double geofenceRadiusMeters = 500; + /// Default geofence radius in meters, used as fallback when no per-shift + /// radius is provided. + static const double defaultGeofenceRadiusMeters = 500; + + /// Storage key for the per-shift geofence radius. + static const String _keyGeofenceRadius = 'geofence_radius_meters'; /// Starts periodic 15-minute background geofence checks. /// @@ -150,12 +250,17 @@ class BackgroundGeofenceService { required String shiftId, required String leftGeofenceTitle, required String leftGeofenceBody, + double geofenceRadiusMeters = defaultGeofenceRadiusMeters, + String? authToken, }) async { await Future.wait(>[ _storageService.setDouble(_keyTargetLat, targetLat), _storageService.setDouble(_keyTargetLng, targetLng), _storageService.setString(_keyShiftId, shiftId), + _storageService.setDouble(_keyGeofenceRadius, geofenceRadiusMeters), _storageService.setBool(_keyTrackingActive, true), + if (authToken != null) + _storageService.setString(_keyAuthToken, authToken), ]); await _backgroundTaskService.registerPeriodicTask( @@ -166,6 +271,7 @@ class BackgroundGeofenceService { 'targetLat': targetLat, 'targetLng': targetLng, 'shiftId': shiftId, + 'geofenceRadiusMeters': geofenceRadiusMeters, 'leftGeofenceTitle': leftGeofenceTitle, 'leftGeofenceBody': leftGeofenceBody, }, @@ -182,10 +288,20 @@ class BackgroundGeofenceService { _storageService.remove(_keyTargetLat), _storageService.remove(_keyTargetLng), _storageService.remove(_keyShiftId), + _storageService.remove(_keyGeofenceRadius), + _storageService.remove(_keyAuthToken), _storageService.setBool(_keyTrackingActive, false), ]); } + /// Stores a fresh auth token for background isolate API calls. + /// + /// Called by the foreground [GeofenceBloc] both initially and + /// periodically (~45 min) to keep the token fresh across long shifts. + Future storeAuthToken(String token) async { + await _storageService.setString(_keyAuthToken, token); + } + /// Whether background tracking is currently active. Future get isTrackingActive async { final bool? active = await _storageService.getBool(_keyTrackingActive); diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_in_arguments.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_in_arguments.dart index e2b3724f..da737591 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_in_arguments.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_in_arguments.dart @@ -7,13 +7,99 @@ class ClockInArguments extends UseCaseArgument { const ClockInArguments({ required this.shiftId, this.notes, + this.deviceId, + this.latitude, + this.longitude, + this.accuracyMeters, + this.capturedAt, + this.overrideReason, + this.nfcTagId, + this.proofNonce, + this.proofTimestamp, + this.attestationProvider, + this.attestationToken, }); + /// The ID of the shift to clock in to. final String shiftId; /// Optional notes provided by the user during clock-in. final String? notes; + /// Device identifier for audit trail. + final String? deviceId; + + /// Latitude of the device at clock-in time. + final double? latitude; + + /// Longitude of the device at clock-in time. + final double? longitude; + + /// Horizontal accuracy of the GPS fix in meters. + final double? accuracyMeters; + + /// Timestamp when the location was captured on-device. + final DateTime? capturedAt; + + /// Justification when the worker overrides a geofence check. + final String? overrideReason; + + /// NFC tag identifier when clocking in via NFC tap. + final String? nfcTagId; + + /// Server-generated nonce for proof-of-presence validation. + final String? proofNonce; + + /// Device-local timestamp when the proof was captured. + final DateTime? proofTimestamp; + + /// Name of the attestation provider (e.g. `'apple'`, `'android'`). + final String? attestationProvider; + + /// Signed attestation token from the device integrity API. + final String? attestationToken; + + /// Serializes the arguments to a JSON map for the V2 API request body. + /// + /// Only includes non-null fields. The `sourceType` is inferred from + /// whether [nfcTagId] is present. + Map toJson() { + return { + 'shiftId': shiftId, + 'sourceType': nfcTagId != null ? 'NFC' : 'GEO', + if (notes != null && notes!.isNotEmpty) 'notes': notes, + if (deviceId != null) 'deviceId': deviceId, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + if (accuracyMeters != null) 'accuracyMeters': accuracyMeters, + if (capturedAt != null) + 'capturedAt': capturedAt!.toUtc().toIso8601String(), + if (overrideReason != null && overrideReason!.isNotEmpty) + 'overrideReason': overrideReason, + if (nfcTagId != null) 'nfcTagId': nfcTagId, + if (proofNonce != null) 'proofNonce': proofNonce, + if (proofTimestamp != null) + 'proofTimestamp': proofTimestamp!.toUtc().toIso8601String(), + if (attestationProvider != null) + 'attestationProvider': attestationProvider, + if (attestationToken != null) 'attestationToken': attestationToken, + }; + } + @override - List get props => [shiftId, notes]; + List get props => [ + shiftId, + notes, + deviceId, + latitude, + longitude, + accuracyMeters, + capturedAt, + overrideReason, + nfcTagId, + proofNonce, + proofTimestamp, + attestationProvider, + attestationToken, + ]; } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_out_arguments.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_out_arguments.dart index 58902f0e..3127d211 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_out_arguments.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/arguments/clock_out_arguments.dart @@ -7,6 +7,17 @@ class ClockOutArguments extends UseCaseArgument { this.notes, this.breakTimeMinutes, this.shiftId, + this.deviceId, + this.latitude, + this.longitude, + this.accuracyMeters, + this.capturedAt, + this.overrideReason, + this.nfcTagId, + this.proofNonce, + this.proofTimestamp, + this.attestationProvider, + this.attestationToken, }); /// Optional notes provided by the user during clock-out. @@ -18,6 +29,82 @@ class ClockOutArguments extends UseCaseArgument { /// The shift id used by the V2 API to resolve the assignment. final String? shiftId; + /// Device identifier for audit trail. + final String? deviceId; + + /// Latitude of the device at clock-out time. + final double? latitude; + + /// Longitude of the device at clock-out time. + final double? longitude; + + /// Horizontal accuracy of the GPS fix in meters. + final double? accuracyMeters; + + /// Timestamp when the location was captured on-device. + final DateTime? capturedAt; + + /// Justification when the worker overrides a geofence check. + final String? overrideReason; + + /// NFC tag identifier when clocking out via NFC tap. + final String? nfcTagId; + + /// Server-generated nonce for proof-of-presence validation. + final String? proofNonce; + + /// Device-local timestamp when the proof was captured. + final DateTime? proofTimestamp; + + /// Name of the attestation provider (e.g. `'apple'`, `'android'`). + final String? attestationProvider; + + /// Signed attestation token from the device integrity API. + final String? attestationToken; + + /// Serializes the arguments to a JSON map for the V2 API request body. + /// + /// Only includes non-null fields. The `sourceType` is inferred from + /// whether [nfcTagId] is present. + Map toJson() { + return { + if (shiftId != null) 'shiftId': shiftId, + 'sourceType': nfcTagId != null ? 'NFC' : 'GEO', + if (notes != null && notes!.isNotEmpty) 'notes': notes, + if (breakTimeMinutes != null) 'breakMinutes': breakTimeMinutes, + if (deviceId != null) 'deviceId': deviceId, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + if (accuracyMeters != null) 'accuracyMeters': accuracyMeters, + if (capturedAt != null) + 'capturedAt': capturedAt!.toUtc().toIso8601String(), + if (overrideReason != null && overrideReason!.isNotEmpty) + 'overrideReason': overrideReason, + if (nfcTagId != null) 'nfcTagId': nfcTagId, + if (proofNonce != null) 'proofNonce': proofNonce, + if (proofTimestamp != null) + 'proofTimestamp': proofTimestamp!.toUtc().toIso8601String(), + if (attestationProvider != null) + 'attestationProvider': attestationProvider, + if (attestationToken != null) 'attestationToken': attestationToken, + }; + } + @override - List get props => [notes, breakTimeMinutes, shiftId]; + List get props => [ + notes, + breakTimeMinutes, + shiftId, + deviceId, + latitude, + longitude, + accuracyMeters, + capturedAt, + overrideReason, + nfcTagId, + proofNonce, + proofTimestamp, + attestationProvider, + attestationToken, + ]; } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/repositories/clock_in_repository_interface.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/repositories/clock_in_repository_interface.dart index 9f93682b..2bd59a91 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/repositories/clock_in_repository_interface.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/repositories/clock_in_repository_interface.dart @@ -1,8 +1,11 @@ import 'package:krow_domain/krow_domain.dart'; -/// Repository interface for Clock In/Out functionality -abstract class ClockInRepositoryInterface { - +import '../arguments/clock_in_arguments.dart'; +import '../arguments/clock_out_arguments.dart'; + +/// Repository interface for Clock In/Out functionality. +abstract interface class ClockInRepositoryInterface { + /// Retrieves the shifts assigned to the user for the current day. /// Returns empty list if no shift is assigned for today. Future> getTodaysShifts(); @@ -11,17 +14,12 @@ abstract class ClockInRepositoryInterface { /// This helps in restoring the UI state if the app was killed. Future getAttendanceStatus(); - /// Checks the user in for the specified [shiftId]. + /// Checks the user in using the fields from [arguments]. /// Returns the updated [AttendanceStatus]. - Future clockIn({required String shiftId, String? notes}); + Future clockIn(ClockInArguments arguments); - /// Checks the user out for the currently active shift. + /// Checks the user out using the fields from [arguments]. /// - /// The V2 API resolves the assignment from [shiftId]. Optionally accepts - /// [breakTimeMinutes] if tracked. - Future clockOut({ - String? notes, - int? breakTimeMinutes, - String? shiftId, - }); + /// The V2 API resolves the assignment from the shift ID. + Future clockOut(ClockOutArguments arguments); } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_in_usecase.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_in_usecase.dart index b99b27f5..8938e627 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_in_usecase.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_in_usecase.dart @@ -11,9 +11,6 @@ class ClockInUseCase implements UseCase { @override Future call(ClockInArguments arguments) { - return _repository.clockIn( - shiftId: arguments.shiftId, - notes: arguments.notes, - ); + return _repository.clockIn(arguments); } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_out_usecase.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_out_usecase.dart index 22503897..df022d9b 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_out_usecase.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/usecases/clock_out_usecase.dart @@ -11,10 +11,6 @@ class ClockOutUseCase implements UseCase { @override Future call(ClockOutArguments arguments) { - return _repository.clockOut( - notes: arguments.notes, - breakTimeMinutes: arguments.breakTimeMinutes, - shiftId: arguments.shiftId, - ); + return _repository.clockOut(arguments); } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/domain/utils/time_window_utils.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/utils/time_window_utils.dart new file mode 100644 index 00000000..7222ab59 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/domain/utils/time_window_utils.dart @@ -0,0 +1,75 @@ +import 'package:krow_domain/krow_domain.dart'; + +import '../validators/clock_in_validation_context.dart'; +import '../validators/validators/time_window_validator.dart'; + +/// Holds the computed time-window check-in/check-out availability flags. +class TimeWindowFlags { + /// Creates a [TimeWindowFlags] with default allowed values. + const TimeWindowFlags({ + this.isCheckInAllowed = true, + this.isCheckOutAllowed = true, + this.checkInAvailabilityTime, + this.checkOutAvailabilityTime, + }); + + /// Whether the time window currently allows check-in. + final bool isCheckInAllowed; + + /// Whether the time window currently allows check-out. + final bool isCheckOutAllowed; + + /// Formatted time when check-in becomes available, or `null`. + final String? checkInAvailabilityTime; + + /// Formatted time when check-out becomes available, or `null`. + final String? checkOutAvailabilityTime; +} + +/// Computes time-window check-in/check-out flags for the given [shift]. +/// +/// Returns a [TimeWindowFlags] indicating whether the current time falls +/// within the allowed clock-in and clock-out windows. Uses +/// [TimeWindowValidator] for the underlying validation logic. +TimeWindowFlags computeTimeWindowFlags(Shift? shift) { + if (shift == null) { + return const TimeWindowFlags(); + } + + const TimeWindowValidator validator = TimeWindowValidator(); + final DateTime shiftStart = shift.startsAt; + final DateTime shiftEnd = shift.endsAt; + + // Check-in window. + bool isCheckInAllowed = true; + String? checkInAvailabilityTime; + final ClockInValidationContext checkInCtx = ClockInValidationContext( + isCheckingIn: true, + shiftStartTime: shiftStart, + ); + isCheckInAllowed = validator.validate(checkInCtx).isValid; + if (!isCheckInAllowed) { + checkInAvailabilityTime = + TimeWindowValidator.getAvailabilityTime(shiftStart); + } + + // Check-out window. + bool isCheckOutAllowed = true; + String? checkOutAvailabilityTime; + final ClockInValidationContext checkOutCtx = ClockInValidationContext( + isCheckingIn: false, + shiftEndTime: shiftEnd, + ); + isCheckOutAllowed = validator.validate(checkOutCtx).isValid; + if (!isCheckOutAllowed) { + checkOutAvailabilityTime = + TimeWindowValidator.getAvailabilityTime(shiftEnd); + } + + return TimeWindowFlags( + isCheckInAllowed: isCheckInAllowed, + isCheckOutAllowed: isCheckOutAllowed, + checkInAvailabilityTime: checkInAvailabilityTime, + checkOutAvailabilityTime: checkOutAvailabilityTime, + ); +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart index eee69dcb..4661fae4 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart @@ -4,21 +4,22 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../../domain/arguments/clock_in_arguments.dart'; -import '../../../domain/arguments/clock_out_arguments.dart'; -import '../../../domain/usecases/clock_in_usecase.dart'; -import '../../../domain/usecases/clock_out_usecase.dart'; -import '../../../domain/usecases/get_attendance_status_usecase.dart'; -import '../../../domain/usecases/get_todays_shift_usecase.dart'; -import '../../../domain/validators/clock_in_validation_context.dart'; -import '../../../domain/validators/clock_in_validation_result.dart'; -import '../../../domain/validators/validators/composite_clock_in_validator.dart'; -import '../../../domain/validators/validators/time_window_validator.dart'; -import '../geofence/geofence_bloc.dart'; -import '../geofence/geofence_event.dart'; -import '../geofence/geofence_state.dart'; -import 'clock_in_event.dart'; -import 'clock_in_state.dart'; +import 'package:staff_clock_in/src/data/services/background_geofence_service.dart'; +import 'package:staff_clock_in/src/domain/arguments/clock_in_arguments.dart'; +import 'package:staff_clock_in/src/domain/arguments/clock_out_arguments.dart'; +import 'package:staff_clock_in/src/domain/usecases/clock_in_usecase.dart'; +import 'package:staff_clock_in/src/domain/usecases/clock_out_usecase.dart'; +import 'package:staff_clock_in/src/domain/usecases/get_attendance_status_usecase.dart'; +import 'package:staff_clock_in/src/domain/usecases/get_todays_shift_usecase.dart'; +import 'package:staff_clock_in/src/domain/validators/clock_in_validation_context.dart'; +import 'package:staff_clock_in/src/domain/validators/clock_in_validation_result.dart'; +import 'package:staff_clock_in/src/domain/utils/time_window_utils.dart'; +import 'package:staff_clock_in/src/domain/validators/validators/composite_clock_in_validator.dart'; +import 'package:staff_clock_in/src/presentation/bloc/geofence/geofence_bloc.dart'; +import 'package:staff_clock_in/src/presentation/bloc/geofence/geofence_event.dart'; +import 'package:staff_clock_in/src/presentation/bloc/geofence/geofence_state.dart'; +import 'package:staff_clock_in/src/presentation/bloc/clock_in/clock_in_event.dart'; +import 'package:staff_clock_in/src/presentation/bloc/clock_in/clock_in_state.dart'; /// BLoC responsible for clock-in/clock-out operations and shift management. /// @@ -92,7 +93,7 @@ class ClockInBloc extends Bloc selectedShift ??= shifts.last; } - final _TimeWindowFlags timeFlags = _computeTimeWindowFlags( + final TimeWindowFlags timeFlags = computeTimeWindowFlags( selectedShift, ); @@ -122,7 +123,7 @@ class ClockInBloc extends Bloc ShiftSelected event, Emitter emit, ) { - final _TimeWindowFlags timeFlags = _computeTimeWindowFlags(event.shift); + final TimeWindowFlags timeFlags = computeTimeWindowFlags(event.shift); emit(state.copyWith( selectedShift: event.shift, isCheckInAllowed: timeFlags.isCheckInAllowed, @@ -201,8 +202,20 @@ class ClockInBloc extends Bloc await handleError( emit: emit.call, action: () async { + final DeviceLocation? location = geofenceState.currentLocation; + final AttendanceStatus newStatus = await _clockIn( - ClockInArguments(shiftId: event.shiftId, notes: event.notes), + ClockInArguments( + shiftId: event.shiftId, + notes: event.notes, + latitude: location?.latitude, + longitude: location?.longitude, + accuracyMeters: location?.accuracy, + capturedAt: location?.timestamp, + overrideReason: geofenceState.isGeofenceOverridden + ? geofenceState.overrideNotes + : null, + ), ); emit(state.copyWith( status: ClockInStatus.success, @@ -224,20 +237,39 @@ class ClockInBloc extends Bloc /// Handles a clock-out request. /// + /// Emits a failure state and returns early when no active shift ID is + /// available — this prevents the API call from being made without a valid + /// shift reference. /// On success, dispatches [BackgroundTrackingStopped] to [_geofenceBloc]. Future _onCheckOut( CheckOutRequested event, Emitter emit, ) async { + final String? activeShiftId = state.attendance.activeShiftId; + if (activeShiftId == null) { + emit(state.copyWith( + status: ClockInStatus.failure, + errorMessage: 'errors.shift.no_active_shift', + )); + return; + } + emit(state.copyWith(status: ClockInStatus.actionInProgress)); await handleError( emit: emit.call, action: () async { + final GeofenceState currentGeofence = _geofenceBloc.state; + final DeviceLocation? location = currentGeofence.currentLocation; + final AttendanceStatus newStatus = await _clockOut( ClockOutArguments( notes: event.notes, - breakTimeMinutes: event.breakTimeMinutes ?? 0, - shiftId: state.attendance.activeShiftId, + breakTimeMinutes: event.breakTimeMinutes, + shiftId: activeShiftId, + latitude: location?.latitude, + longitude: location?.longitude, + accuracyMeters: location?.accuracy, + capturedAt: location?.timestamp, ), ); emit(state.copyWith( @@ -269,7 +301,7 @@ class ClockInBloc extends Bloc Emitter emit, ) { if (state.status != ClockInStatus.success) return; - final _TimeWindowFlags timeFlags = _computeTimeWindowFlags( + final TimeWindowFlags timeFlags = computeTimeWindowFlags( state.selectedShift, ); emit(state.copyWith( @@ -299,52 +331,6 @@ class ClockInBloc extends Bloc return super.close(); } - /// Computes time-window check-in/check-out flags for the given [shift]. - /// - /// Uses [TimeWindowValidator] so this business logic stays out of widgets. - static _TimeWindowFlags _computeTimeWindowFlags(Shift? shift) { - if (shift == null) { - return const _TimeWindowFlags(); - } - - const TimeWindowValidator validator = TimeWindowValidator(); - final DateTime shiftStart = shift.startsAt; - final DateTime shiftEnd = shift.endsAt; - - // Check-in window. - bool isCheckInAllowed = true; - String? checkInAvailabilityTime; - final ClockInValidationContext checkInCtx = ClockInValidationContext( - isCheckingIn: true, - shiftStartTime: shiftStart, - ); - isCheckInAllowed = validator.validate(checkInCtx).isValid; - if (!isCheckInAllowed) { - checkInAvailabilityTime = - TimeWindowValidator.getAvailabilityTime(shiftStart); - } - - // Check-out window. - bool isCheckOutAllowed = true; - String? checkOutAvailabilityTime; - final ClockInValidationContext checkOutCtx = ClockInValidationContext( - isCheckingIn: false, - shiftEndTime: shiftEnd, - ); - isCheckOutAllowed = validator.validate(checkOutCtx).isValid; - if (!isCheckOutAllowed) { - checkOutAvailabilityTime = - TimeWindowValidator.getAvailabilityTime(shiftEnd); - } - - return _TimeWindowFlags( - isCheckInAllowed: isCheckInAllowed, - isCheckOutAllowed: isCheckOutAllowed, - checkInAvailabilityTime: checkInAvailabilityTime, - checkOutAvailabilityTime: checkOutAvailabilityTime, - ); - } - /// Dispatches [BackgroundTrackingStarted] to [_geofenceBloc] if the /// geofence has target coordinates. void _dispatchBackgroundTrackingStarted({ @@ -361,6 +347,9 @@ class ClockInBloc extends Bloc shiftId: activeShiftId, targetLat: geofenceState.targetLat!, targetLng: geofenceState.targetLng!, + geofenceRadiusMeters: + state.selectedShift?.geofenceRadiusMeters?.toDouble() ?? + BackgroundGeofenceService.defaultGeofenceRadiusMeters, greetingTitle: event.clockInGreetingTitle, greetingBody: event.clockInGreetingBody, leftGeofenceTitle: event.leftGeofenceTitle, @@ -370,26 +359,3 @@ class ClockInBloc extends Bloc } } } - -/// Internal value holder for time-window computation results. -class _TimeWindowFlags { - /// Creates a [_TimeWindowFlags] with default allowed values. - const _TimeWindowFlags({ - this.isCheckInAllowed = true, - this.isCheckOutAllowed = true, - this.checkInAvailabilityTime, - this.checkOutAvailabilityTime, - }); - - /// Whether the time window currently allows check-in. - final bool isCheckInAllowed; - - /// Whether the time window currently allows check-out. - final bool isCheckOutAllowed; - - /// Formatted time when check-in becomes available, or `null`. - final String? checkInAvailabilityTime; - - /// Formatted time when check-out becomes available, or `null`. - final String? checkOutAvailabilityTime; -} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart index d9c6a260..1616b7bf 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_bloc.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:developer' as developer; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; @@ -25,9 +26,11 @@ class GeofenceBloc extends Bloc required GeofenceServiceInterface geofenceService, required BackgroundGeofenceService backgroundGeofenceService, required ClockInNotificationService notificationService, + required AuthTokenProvider authTokenProvider, }) : _geofenceService = geofenceService, _backgroundGeofenceService = backgroundGeofenceService, _notificationService = notificationService, + _authTokenProvider = authTokenProvider, super(const GeofenceState.initial()) { on(_onStarted); on(_onResultUpdated); @@ -52,6 +55,17 @@ class GeofenceBloc extends Bloc /// The notification service for clock-in related notifications. final ClockInNotificationService _notificationService; + /// Provides fresh Firebase ID tokens for background isolate storage. + final AuthTokenProvider _authTokenProvider; + + /// Periodic timer that refreshes the auth token in SharedPreferences + /// so the background isolate always has a valid token for API calls. + Timer? _tokenRefreshTimer; + + /// How often to refresh the auth token for background use. + /// Set to 45 minutes — well before Firebase's 1-hour expiry. + static const Duration _tokenRefreshInterval = Duration(minutes: 45); + /// Active subscription to the foreground geofence location stream. StreamSubscription? _geofenceSubscription; @@ -239,6 +253,17 @@ class GeofenceBloc extends Bloc shiftId: event.shiftId, leftGeofenceTitle: event.leftGeofenceTitle, leftGeofenceBody: event.leftGeofenceBody, + geofenceRadiusMeters: event.geofenceRadiusMeters, + ); + + // Get and store initial auth token for background location streaming. + await _refreshAndStoreToken(); + + // Start periodic token refresh to keep it valid across long shifts. + _tokenRefreshTimer?.cancel(); + _tokenRefreshTimer = Timer.periodic( + _tokenRefreshInterval, + (_) => _refreshAndStoreToken(), ); // Show greeting notification using localized strings from the UI. @@ -261,6 +286,9 @@ class GeofenceBloc extends Bloc BackgroundTrackingStopped event, Emitter emit, ) async { + _tokenRefreshTimer?.cancel(); + _tokenRefreshTimer = null; + await handleError( emit: emit.call, action: () async { @@ -298,6 +326,8 @@ class GeofenceBloc extends Bloc GeofenceStopped event, Emitter emit, ) async { + _tokenRefreshTimer?.cancel(); + _tokenRefreshTimer = null; await _geofenceSubscription?.cancel(); _geofenceSubscription = null; await _serviceStatusSubscription?.cancel(); @@ -305,8 +335,26 @@ class GeofenceBloc extends Bloc emit(const GeofenceState.initial()); } + /// Fetches a fresh Firebase ID token and stores it in SharedPreferences + /// for the background isolate to use. + Future _refreshAndStoreToken() async { + try { + final String? token = await _authTokenProvider.getIdToken( + forceRefresh: true, + ); + if (token != null) { + await _backgroundGeofenceService.storeAuthToken(token); + } + } catch (e) { + // Best-effort — if token refresh fails, the background isolate will + // skip the POST (it checks for null/empty token). + developer.log('Token refresh failed: $e', name: 'GeofenceBloc', error: e); + } + } + @override Future close() { + _tokenRefreshTimer?.cancel(); _geofenceSubscription?.cancel(); _serviceStatusSubscription?.cancel(); return super.close(); diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart index 980d5c5d..bd3e2437 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/geofence/geofence_event.dart @@ -73,6 +73,7 @@ class BackgroundTrackingStarted extends GeofenceEvent { required this.greetingBody, required this.leftGeofenceTitle, required this.leftGeofenceBody, + this.geofenceRadiusMeters = 500, }); /// The shift ID being tracked. @@ -84,6 +85,9 @@ class BackgroundTrackingStarted extends GeofenceEvent { /// Target longitude of the shift location. final double targetLng; + /// Geofence radius in meters for this shift. Defaults to 500m. + final double geofenceRadiusMeters; + /// Localized greeting notification title passed from the UI layer. final String greetingTitle; @@ -103,6 +107,7 @@ class BackgroundTrackingStarted extends GeofenceEvent { shiftId, targetLat, targetLng, + geofenceRadiusMeters, greetingTitle, greetingBody, leftGeofenceTitle, diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/attendance_card.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/attendance_card.dart index fc187fdb..a4e0eef0 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/attendance_card.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/attendance_card.dart @@ -56,7 +56,7 @@ class AttendanceCard extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 2), + const SizedBox(height: UiConstants.space1), FittedBox( fit: BoxFit.scaleDown, child: Text( @@ -65,13 +65,13 @@ class AttendanceCard extends StatelessWidget { ), ), if (scheduledTime != null) ...[ - const SizedBox(height: 2), + const SizedBox(height: UiConstants.space1), Text( "Scheduled: $scheduledTime", style: UiTypography.footnote2r.textInactive, ), ], - const SizedBox(height: 2), + const SizedBox(height: UiConstants.space1), Text( subtitle, style: UiTypography.footnote1r.copyWith(color: UiColors.primary), diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart index 211769d1..bc2e2d2f 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart @@ -281,7 +281,7 @@ class _CommuteTrackerState extends State { size: 12, color: UiColors.textInactive, ), - const SizedBox(width: 2), + const SizedBox(width: UiConstants.space1), Text( i18n.starts_in(min: _getMinutesUntilShift().toString()), style: UiTypography.titleUppercase4m.textSecondary, diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/date_selector.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/date_selector.dart index c91be1a4..38df9665 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/date_selector.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/date_selector.dart @@ -55,7 +55,7 @@ class DateSelector extends StatelessWidget { : UiColors.foreground, ), ), - const SizedBox(height: 2), + const SizedBox(height: UiConstants.space1), Text( DateFormat('E').format(date), style: UiTypography.footnote2r.copyWith( diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart index 7aac190d..04f1fde2 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart @@ -228,7 +228,7 @@ class _LunchBreakDialogState extends State { ), ), ), - const SizedBox(width: 10), + const SizedBox(width: UiConstants.space2), Expanded( child: DropdownButtonFormField( isExpanded: true, diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart index 671642ae..8bcf794c 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart @@ -79,6 +79,7 @@ class StaffClockInModule extends Module { geofenceService: i.get(), backgroundGeofenceService: i.get(), notificationService: i.get(), + authTokenProvider: i.get(), ), ); i.add( diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart index 77aecffc..b775bfac 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart @@ -32,14 +32,11 @@ class _TimeCardPageState extends State { @override Widget build(BuildContext context) { final Translations t = Translations.of(context); - return BlocProvider.value( - value: _bloc, - child: Scaffold( - appBar: UiAppBar( - title: t.staff_time_card.title, - showBackButton: true, - ), - body: BlocConsumer( + return Scaffold( + appBar: UiAppBar(title: t.staff_time_card.title, showBackButton: true), + body: BlocProvider.value( + value: _bloc, + child: BlocConsumer( listener: (BuildContext context, TimeCardState state) { if (state is TimeCardError) { UiSnackbar.show( diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/shift_history_list.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/shift_history_list.dart index 4d9ffd0b..39856787 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/shift_history_list.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/shift_history_list.dart @@ -17,9 +17,7 @@ class ShiftHistoryList extends StatelessWidget { children: [ Text( t.staff_time_card.shift_history, - style: UiTypography.title2b.copyWith( - color: UiColors.textPrimary, - ), + style: UiTypography.title2b, ), const SizedBox(height: UiConstants.space3), if (timesheets.isEmpty) diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/timesheet_card.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/timesheet_card.dart index 9248f9db..9ddcf955 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/timesheet_card.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/timesheet_card.dart @@ -6,7 +6,6 @@ import 'package:krow_domain/krow_domain.dart'; /// A card widget displaying details of a single shift/timecard. class TimesheetCard extends StatelessWidget { - const TimesheetCard({super.key, required this.timesheet}); final TimeCardEntry timesheet; @@ -25,9 +24,10 @@ class TimesheetCard extends StatelessWidget { decoration: BoxDecoration( color: UiColors.bgPopup, borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border), + border: Border.all(color: UiColors.border, width: 0.5), ), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -60,20 +60,22 @@ class TimesheetCard extends StatelessWidget { if (timesheet.clockInAt != null && timesheet.clockOutAt != null) _IconText( icon: UiIcons.clock, - text: '${DateFormat('h:mm a').format(timesheet.clockInAt!)} - ${DateFormat('h:mm a').format(timesheet.clockOutAt!)}', + text: + '${DateFormat('h:mm a').format(timesheet.clockInAt!)} - ${DateFormat('h:mm a').format(timesheet.clockOutAt!)}', ), if (timesheet.location != null) _IconText(icon: UiIcons.mapPin, text: timesheet.location!), ], ), - const SizedBox(height: UiConstants.space3), + const SizedBox(height: UiConstants.space5), Container( - padding: const EdgeInsets.only(top: UiConstants.space3), + padding: const EdgeInsets.only(top: UiConstants.space4), decoration: const BoxDecoration( - border: Border(top: BorderSide(color: UiColors.border)), + border: Border(top: BorderSide(color: UiColors.border, width: 0.5)), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( '${totalHours.toStringAsFixed(1)} ${t.staff_time_card.hours} @ \$${hourlyRate.toStringAsFixed(2)}${t.staff_time_card.per_hr}', @@ -81,7 +83,7 @@ class TimesheetCard extends StatelessWidget { ), Text( '\$${totalPay.toStringAsFixed(2)}', - style: UiTypography.title2b.primary, + style: UiTypography.title1b, ), ], ), @@ -93,7 +95,6 @@ class TimesheetCard extends StatelessWidget { } class _IconText extends StatelessWidget { - const _IconText({required this.icon, required this.text}); final IconData icon; final String text; @@ -105,10 +106,7 @@ class _IconText extends StatelessWidget { children: [ Icon(icon, size: 14, color: UiColors.iconSecondary), const SizedBox(width: UiConstants.space1), - Text( - text, - style: UiTypography.body2r.textSecondary, - ), + Text(text, style: UiTypography.body2r.textSecondary), ], ); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart index 8835d825..6f474dfd 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 @@ -147,6 +147,16 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { ); } + @override + Future submitForApproval(String shiftId, {String? note}) async { + await _apiService.post( + StaffEndpoints.shiftSubmitForApproval(shiftId), + data: { + if (note != null) 'note': note, + }, + ); + } + @override Future getProfileCompletion() async { final ApiResponse response = diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart index abd6a9ea..d6583347 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/repositories/shifts_repository_interface.dart @@ -47,4 +47,9 @@ abstract interface class ShiftsRepositoryInterface { /// Returns whether the staff profile is complete. Future getProfileCompletion(); + + /// Submits a completed shift for timesheet approval. + /// + /// Only allowed for shifts in CHECKED_OUT or COMPLETED status. + Future submitForApproval(String shiftId, {String? note}); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/submit_for_approval_usecase.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/submit_for_approval_usecase.dart new file mode 100644 index 00000000..fbbf921e --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/usecases/submit_for_approval_usecase.dart @@ -0,0 +1,18 @@ +import 'package:staff_shifts/src/domain/repositories/shifts_repository_interface.dart'; + +/// Submits a completed shift for timesheet approval. +/// +/// Delegates to [ShiftsRepositoryInterface.submitForApproval] which calls +/// `POST /staff/shifts/:shiftId/submit-for-approval`. +class SubmitForApprovalUseCase { + /// Creates a [SubmitForApprovalUseCase]. + SubmitForApprovalUseCase(this.repository); + + /// The shifts repository. + final ShiftsRepositoryInterface repository; + + /// Executes the use case. + Future call(String shiftId, {String? note}) async { + return repository.submitForApproval(shiftId, note: note); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/domain/utils/shift_date_utils.dart b/apps/mobile/packages/features/staff/shifts/lib/src/domain/utils/shift_date_utils.dart new file mode 100644 index 00000000..a656c7ea --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/domain/utils/shift_date_utils.dart @@ -0,0 +1,37 @@ +import 'package:krow_domain/krow_domain.dart'; + +/// Computes a Friday-based week calendar for the given [weekOffset]. +/// +/// Returns a list of 7 [DateTime] values starting from the Friday of the +/// week identified by [weekOffset] (0 = current week, negative = past, +/// positive = future). Each date is midnight-normalised. +List getCalendarDaysForOffset(int weekOffset) { + final DateTime now = DateTime.now(); + final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday; + final int daysSinceFriday = (reactDayIndex + 2) % 7; + final DateTime start = now + .subtract(Duration(days: daysSinceFriday)) + .add(Duration(days: weekOffset * 7)); + final DateTime startDate = DateTime(start.year, start.month, start.day); + return List.generate( + 7, + (int index) => startDate.add(Duration(days: index)), + ); +} + +/// Filters out [OpenShift] entries whose date is strictly before today. +/// +/// Comparison is done at midnight granularity so shifts scheduled for +/// today are always included. +List filterPastOpenShifts(List shifts) { + final DateTime now = DateTime.now(); + final DateTime today = DateTime(now.year, now.month, now.day); + return shifts.where((OpenShift shift) { + final DateTime dateOnly = DateTime( + shift.date.year, + shift.date.month, + shift.date.day, + ); + return !dateOnly.isBefore(today); + }).toList(); +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart index 3067440c..8dc53c57 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shift_details/shift_details_bloc.dart @@ -53,7 +53,7 @@ class ShiftDetailsBloc extends Bloc isProfileComplete: isProfileComplete, )); } else { - emit(const ShiftDetailsError('Shift not found')); + emit(const ShiftDetailsError('errors.shift.not_found')); } }, onError: (String errorKey) => ShiftDetailsError(errorKey), @@ -74,7 +74,7 @@ class ShiftDetailsBloc extends Bloc ); emit( ShiftActionSuccess( - 'Shift successfully booked!', + 'shift_booked', shiftDate: event.date, ), ); @@ -91,7 +91,7 @@ class ShiftDetailsBloc extends Bloc emit: emit.call, action: () async { await declineShift(event.shiftId); - emit(const ShiftActionSuccess('Shift declined')); + emit(const ShiftActionSuccess('shift_declined_success')); }, onError: (String errorKey) => ShiftDetailsError(errorKey), ); diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart index 0d05ffa6..a63992b3 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart @@ -14,6 +14,8 @@ import 'package:staff_shifts/src/domain/usecases/get_history_shifts_usecase.dart import 'package:staff_shifts/src/domain/usecases/get_my_shifts_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_pending_assignments_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/submit_for_approval_usecase.dart'; +import 'package:staff_shifts/src/domain/utils/shift_date_utils.dart'; part 'shifts_event.dart'; part 'shifts_state.dart'; @@ -31,6 +33,7 @@ class ShiftsBloc extends Bloc required this.getProfileCompletion, required this.acceptShift, required this.declineShift, + required this.submitForApproval, }) : super(const ShiftsState()) { on(_onLoadShifts); on(_onLoadHistoryShifts); @@ -41,6 +44,7 @@ class ShiftsBloc extends Bloc on(_onCheckProfileCompletion); on(_onAcceptShift); on(_onDeclineShift); + on(_onSubmitForApproval); } /// Use case for assigned shifts. @@ -67,6 +71,9 @@ class ShiftsBloc extends Bloc /// Use case for declining a shift. final DeclineShiftUseCase declineShift; + /// Use case for submitting a shift for timesheet approval. + final SubmitForApprovalUseCase submitForApproval; + Future _onLoadShifts( LoadShiftsEvent event, Emitter emit, @@ -78,7 +85,7 @@ class ShiftsBloc extends Bloc await handleError( emit: emit.call, action: () async { - final List days = _getCalendarDaysForOffset(0); + final List days = getCalendarDaysForOffset(0); // Load assigned, pending, and cancelled shifts in parallel. final List results = await Future.wait(>[ @@ -110,6 +117,7 @@ class ShiftsBloc extends Bloc historyLoaded: false, myShiftsLoaded: true, searchQuery: '', + clearErrorMessage: true, ), ); }, @@ -136,6 +144,7 @@ class ShiftsBloc extends Bloc historyShifts: historyResult, historyLoading: false, historyLoaded: true, + clearErrorMessage: true, ), ); }, @@ -167,9 +176,10 @@ class ShiftsBloc extends Bloc ); emit( state.copyWith( - availableShifts: _filterPastOpenShifts(availableResult), + availableShifts: filterPastOpenShifts(availableResult), availableLoading: false, availableLoaded: true, + clearErrorMessage: true, ), ); }, @@ -219,9 +229,10 @@ class ShiftsBloc extends Bloc emit( state.copyWith( status: ShiftsStatus.loaded, - availableShifts: _filterPastOpenShifts(availableResult), + availableShifts: filterPastOpenShifts(availableResult), availableLoading: false, availableLoaded: true, + clearErrorMessage: true, ), ); }, @@ -239,6 +250,7 @@ class ShiftsBloc extends Bloc LoadShiftsForRangeEvent event, Emitter emit, ) async { + emit(state.copyWith(myShifts: const [], myShiftsLoaded: false)); await handleError( emit: emit.call, action: () async { @@ -251,6 +263,7 @@ class ShiftsBloc extends Bloc status: ShiftsStatus.loaded, myShifts: myShiftsResult, myShiftsLoaded: true, + clearErrorMessage: true, ), ); }, @@ -281,7 +294,7 @@ class ShiftsBloc extends Bloc emit( state.copyWith( - availableShifts: _filterPastOpenShifts(result), + availableShifts: filterPastOpenShifts(result), searchQuery: search, ), ); @@ -342,30 +355,37 @@ class ShiftsBloc extends Bloc ); } - /// Gets calendar days for the given week offset (Friday-based week). - List _getCalendarDaysForOffset(int weekOffset) { - final DateTime now = DateTime.now(); - final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday; - final int daysSinceFriday = (reactDayIndex + 2) % 7; - final DateTime start = now - .subtract(Duration(days: daysSinceFriday)) - .add(Duration(days: weekOffset * 7)); - final DateTime startDate = DateTime(start.year, start.month, start.day); - return List.generate( - 7, (int index) => startDate.add(Duration(days: index))); + Future _onSubmitForApproval( + SubmitForApprovalEvent event, + Emitter emit, + ) async { + // Guard: another submission is already in progress. + if (state.submittingShiftId != null) return; + // Guard: this shift was already submitted. + if (state.submittedShiftIds.contains(event.shiftId)) return; + + emit(state.copyWith(submittingShiftId: event.shiftId)); + await handleError( + emit: emit.call, + action: () async { + await submitForApproval(event.shiftId, note: event.note); + emit( + state.copyWith( + clearSubmittingShiftId: true, + clearErrorMessage: true, + submittedShiftIds: { + ...state.submittedShiftIds, + event.shiftId, + }, + ), + ); + }, + onError: (String errorKey) => state.copyWith( + clearSubmittingShiftId: true, + status: ShiftsStatus.error, + errorMessage: errorKey, + ), + ); } - /// Filters out open shifts whose date is in the past. - List _filterPastOpenShifts(List shifts) { - final DateTime now = DateTime.now(); - final DateTime today = DateTime(now.year, now.month, now.day); - return shifts.where((OpenShift shift) { - final DateTime dateOnly = DateTime( - shift.date.year, - shift.date.month, - shift.date.day, - ); - return !dateOnly.isBefore(today); - }).toList(); - } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart index ac14d74e..83a1d948 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart @@ -93,3 +93,18 @@ class CheckProfileCompletionEvent extends ShiftsEvent { @override List get props => []; } + +/// Submits a completed shift for timesheet approval. +class SubmitForApprovalEvent extends ShiftsEvent { + /// Creates a [SubmitForApprovalEvent]. + const SubmitForApprovalEvent({required this.shiftId, this.note}); + + /// The shift row id to submit. + final String shiftId; + + /// Optional note to include with the submission. + final String? note; + + @override + List get props => [shiftId, note]; +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart index 3b7a1de9..3906afc3 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart @@ -21,6 +21,8 @@ class ShiftsState extends Equatable { this.searchQuery = '', this.profileComplete, this.errorMessage, + this.submittingShiftId, + this.submittedShiftIds = const {}, }); /// Current lifecycle status. @@ -65,6 +67,12 @@ class ShiftsState extends Equatable { /// Error message key for display. final String? errorMessage; + /// The shift ID currently being submitted for approval (null when idle). + final String? submittingShiftId; + + /// Set of shift IDs that have been successfully submitted for approval. + final Set submittedShiftIds; + /// Creates a copy with the given fields replaced. ShiftsState copyWith({ ShiftsStatus? status, @@ -81,6 +89,10 @@ class ShiftsState extends Equatable { String? searchQuery, bool? profileComplete, String? errorMessage, + bool clearErrorMessage = false, + String? submittingShiftId, + bool clearSubmittingShiftId = false, + Set? submittedShiftIds, }) { return ShiftsState( status: status ?? this.status, @@ -96,7 +108,13 @@ class ShiftsState extends Equatable { myShiftsLoaded: myShiftsLoaded ?? this.myShiftsLoaded, searchQuery: searchQuery ?? this.searchQuery, profileComplete: profileComplete ?? this.profileComplete, - errorMessage: errorMessage ?? this.errorMessage, + errorMessage: clearErrorMessage + ? null + : (errorMessage ?? this.errorMessage), + submittingShiftId: clearSubmittingShiftId + ? null + : (submittingShiftId ?? this.submittingShiftId), + submittedShiftIds: submittedShiftIds ?? this.submittedShiftIds, ); } @@ -116,5 +134,7 @@ class ShiftsState extends Equatable { searchQuery, profileComplete, errorMessage, + submittingShiftId, + submittedShiftIds, ]; } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart index 5eb65bc6..cb238376 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart @@ -47,12 +47,6 @@ class _ShiftDetailsPageState extends State { return DateFormat('EEEE, MMMM d, y').format(dt); } - double _calculateDuration(ShiftDetail detail) { - final int minutes = detail.endTime.difference(detail.startTime).inMinutes; - final double hours = minutes / 60; - return hours < 0 ? hours + 24 : hours; - } - @override Widget build(BuildContext context) { return BlocProvider( @@ -67,7 +61,7 @@ class _ShiftDetailsPageState extends State { _isApplying = false; UiSnackbar.show( context, - message: state.message, + message: _translateSuccessKey(context, state.message), type: UiSnackbarType.success, ); Modular.to.toShifts( @@ -98,14 +92,8 @@ class _ShiftDetailsPageState extends State { } final ShiftDetail detail = state.detail; - final dynamic i18n = - Translations.of(context).staff_shifts.shift_details; final bool isProfileComplete = state.isProfileComplete; - final double duration = _calculateDuration(detail); - final double hourlyRate = detail.hourlyRateCents / 100; - final double estimatedTotal = hourlyRate * duration; - return Scaffold( appBar: UiAppBar( centerTitle: false, @@ -122,45 +110,46 @@ class _ShiftDetailsPageState extends State { Padding( padding: const EdgeInsets.all(UiConstants.space6), child: UiNoticeBanner( - title: 'Complete Your Account', - description: - 'Complete your account to book this shift and start earning', + title: context.t.staff_shifts.shift_details + .complete_account_title, + description: context.t.staff_shifts.shift_details + .complete_account_description, icon: UiIcons.sparkles, ), ), ShiftDetailsHeader(detail: detail), const Divider(height: 1, thickness: 0.5), ShiftStatsRow( - estimatedTotal: estimatedTotal, - hourlyRate: hourlyRate, - duration: duration, - totalLabel: i18n.est_total, - hourlyRateLabel: i18n.hourly_rate, - hoursLabel: i18n.hours, + estimatedTotal: detail.estimatedTotal, + hourlyRate: detail.hourlyRate, + duration: detail.durationHours, + totalLabel: context.t.staff_shifts.shift_details.est_total, + hourlyRateLabel: context.t.staff_shifts.shift_details.hourly_rate, + hoursLabel: context.t.staff_shifts.shift_details.hours, ), const Divider(height: 1, thickness: 0.5), ShiftDateTimeSection( date: detail.date, startTime: detail.startTime, endTime: detail.endTime, - shiftDateLabel: i18n.shift_date, - clockInLabel: i18n.start_time, - clockOutLabel: i18n.end_time, + shiftDateLabel: context.t.staff_shifts.shift_details.shift_date, + clockInLabel: context.t.staff_shifts.shift_details.start_time, + clockOutLabel: context.t.staff_shifts.shift_details.end_time, ), const Divider(height: 1, thickness: 0.5), ShiftLocationSection( location: detail.location, address: detail.address ?? '', - locationLabel: i18n.location, - tbdLabel: i18n.tbd, - getDirectionLabel: i18n.get_direction, + locationLabel: context.t.staff_shifts.shift_details.location, + tbdLabel: context.t.staff_shifts.shift_details.tbd, + getDirectionLabel: context.t.staff_shifts.shift_details.get_direction, ), const Divider(height: 1, thickness: 0.5), if (detail.description != null && detail.description!.isNotEmpty) ShiftDescriptionSection( description: detail.description!, - descriptionLabel: i18n.job_description, + descriptionLabel: context.t.staff_shifts.shift_details.job_description, ), ], ), @@ -190,13 +179,11 @@ class _ShiftDetailsPageState extends State { } void _bookShift(BuildContext context, ShiftDetail detail) { - final dynamic i18n = - Translations.of(context).staff_shifts.shift_details.book_dialog; showDialog( context: context, builder: (BuildContext ctx) => AlertDialog( - title: Text(i18n.title as String), - content: Text(i18n.message as String), + title: Text(context.t.staff_shifts.shift_details.book_dialog.title), + content: Text(context.t.staff_shifts.shift_details.book_dialog.message), actions: [ TextButton( onPressed: () => Modular.to.popSafe(), @@ -228,14 +215,12 @@ class _ShiftDetailsPageState extends State { if (_actionDialogOpen) return; _actionDialogOpen = true; _isApplying = true; - final dynamic i18n = - Translations.of(context).staff_shifts.shift_details.applying_dialog; showDialog( context: context, useRootNavigator: true, barrierDismissible: false, builder: (BuildContext ctx) => AlertDialog( - title: Text(i18n.title as String), + title: Text(context.t.staff_shifts.shift_details.applying_dialog.title), content: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -250,7 +235,7 @@ class _ShiftDetailsPageState extends State { style: UiTypography.body2b.textPrimary, textAlign: TextAlign.center, ), - const SizedBox(height: 6), + const SizedBox(height: UiConstants.space1), Text( '${_formatDate(detail.date)} \u2022 ${_formatTime(detail.startTime)} - ${_formatTime(detail.endTime)}', style: UiTypography.body3r.textSecondary, @@ -270,6 +255,18 @@ class _ShiftDetailsPageState extends State { _actionDialogOpen = false; } + /// Translates a success message key to a localized string. + String _translateSuccessKey(BuildContext context, String key) { + switch (key) { + case 'shift_booked': + return context.t.staff_shifts.shift_details.shift_booked; + case 'shift_declined_success': + return context.t.staff_shifts.shift_details.shift_declined_success; + default: + return key; + } + } + void _showEligibilityErrorDialog(BuildContext context) { showDialog( context: context, @@ -288,16 +285,16 @@ class _ShiftDetailsPageState extends State { ], ), content: Text( - 'You are missing required certifications or documents to claim this shift. Please upload them to continue.', + context.t.staff_shifts.shift_details.missing_certifications, style: UiTypography.body2r.textSecondary, ), actions: [ UiButton.secondary( - text: 'Cancel', + text: Translations.of(context).common.cancel, onPressed: () => Navigator.of(ctx).pop(), ), UiButton.primary( - text: 'Go to Certificates', + text: context.t.staff_shifts.shift_details.go_to_certificates, onPressed: () { Modular.to.popSafe(); Modular.to.toCertificates(); diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart index e61c9558..ba32bb84 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart @@ -12,10 +12,15 @@ import 'package:staff_shifts/src/presentation/widgets/tabs/my_shifts_tab.dart'; import 'package:staff_shifts/src/presentation/widgets/tabs/find_shifts_tab.dart'; import 'package:staff_shifts/src/presentation/widgets/tabs/history_shifts_tab.dart'; +/// Tabbed page for browsing staff shifts (My Shifts, Find Work, History). +/// +/// Manages tab state locally and delegates data loading to [ShiftsBloc]. class ShiftsPage extends StatefulWidget { - final ShiftTabType? initialTab; - final DateTime? selectedDate; - final bool refreshAvailable; + /// Creates a [ShiftsPage]. + /// + /// [initialTab] selects the active tab on first render. + /// [selectedDate] pre-selects a calendar date in the My Shifts tab. + /// [refreshAvailable] triggers a forced reload of available shifts. const ShiftsPage({ super.key, this.initialTab, @@ -23,6 +28,15 @@ class ShiftsPage extends StatefulWidget { this.refreshAvailable = false, }); + /// The tab to display on initial render. Defaults to [ShiftTabType.find]. + final ShiftTabType? initialTab; + + /// Optional date to pre-select in the My Shifts calendar. + final DateTime? selectedDate; + + /// When true, forces a refresh of available shifts on load. + final bool refreshAvailable; + @override State createState() => _ShiftsPageState(); } @@ -251,6 +265,7 @@ class _ShiftsPageState extends State { pendingAssignments: pendingAssignments, cancelledShifts: cancelledShifts, initialDate: _selectedDate, + submittedShiftIds: state.submittedShiftIds, ); case ShiftTabType.find: if (availableLoading) { @@ -264,7 +279,10 @@ class _ShiftsPageState extends State { if (historyLoading) { return const ShiftsPageSkeleton(); } - return HistoryShiftsTab(historyShifts: historyShifts); + return HistoryShiftsTab( + historyShifts: historyShifts, + submittedShiftIds: state.submittedShiftIds, + ); } } @@ -333,7 +351,7 @@ class _ShiftsPageState extends State { ), ), if (showCount) ...[ - const SizedBox(width: 4), + const SizedBox(width: UiConstants.space1), Container( padding: const EdgeInsets.symmetric( horizontal: UiConstants.space1, diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_assignment_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_assignment_card.dart index 5482707f..1b1cc12e 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_assignment_card.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_assignment_card.dart @@ -1,3 +1,4 @@ +import 'package:core_localization/core_localization.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -107,7 +108,7 @@ class ShiftAssignmentCard extends StatelessWidget { children: [ const Icon(UiIcons.calendar, size: 12, color: UiColors.iconSecondary), - const SizedBox(width: 4), + const SizedBox(width: UiConstants.space1), Text( _formatDate(assignment.startTime), style: UiTypography.footnote1r.textSecondary, @@ -115,19 +116,19 @@ class ShiftAssignmentCard extends StatelessWidget { const SizedBox(width: UiConstants.space3), const Icon(UiIcons.clock, size: 12, color: UiColors.iconSecondary), - const SizedBox(width: 4), + const SizedBox(width: UiConstants.space1), Text( '${_formatTime(assignment.startTime)} - ${_formatTime(assignment.endTime)}', style: UiTypography.footnote1r.textSecondary, ), ], ), - const SizedBox(height: 4), + const SizedBox(height: UiConstants.space1), Row( children: [ const Icon(UiIcons.mapPin, size: 12, color: UiColors.iconSecondary), - const SizedBox(width: 4), + const SizedBox(width: UiConstants.space1), Expanded( child: Text( assignment.location, @@ -160,7 +161,10 @@ class ShiftAssignmentCard extends StatelessWidget { style: TextButton.styleFrom( foregroundColor: UiColors.destructive, ), - child: Text('Decline', style: UiTypography.body2m.textError), + child: Text( + context.t.staff_shifts.shift_details.decline, + style: UiTypography.body2m.textError, + ), ), ), const SizedBox(width: UiConstants.space2), @@ -178,14 +182,17 @@ class ShiftAssignmentCard extends StatelessWidget { ), child: isConfirming ? const SizedBox( - height: 16, - width: 16, + height: UiConstants.space4, + width: UiConstants.space4, child: CircularProgressIndicator( strokeWidth: 2, color: UiColors.white, ), ) - : Text('Accept', style: UiTypography.body2m.white), + : Text( + context.t.staff_shifts.shift_details.accept_shift, + style: UiTypography.body2m.white, + ), ), ), ], diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/history_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/history_shifts_tab.dart index 0ea3b6a6..ebd40aa9 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/history_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/history_shifts_tab.dart @@ -1,21 +1,30 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart' show ReadContext; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart'; import 'package:staff_shifts/src/presentation/widgets/shift_card.dart'; /// Tab displaying completed shift history. class HistoryShiftsTab extends StatelessWidget { /// Creates a [HistoryShiftsTab]. - const HistoryShiftsTab({super.key, required this.historyShifts}); + const HistoryShiftsTab({ + super.key, + required this.historyShifts, + this.submittedShiftIds = const {}, + }); /// Completed shifts. final List historyShifts; + /// Set of shift IDs that have been successfully submitted for approval. + final Set submittedShiftIds; + @override Widget build(BuildContext context) { if (historyShifts.isEmpty) { @@ -32,14 +41,31 @@ class HistoryShiftsTab extends StatelessWidget { children: [ const SizedBox(height: UiConstants.space5), ...historyShifts.map( - (CompletedShift shift) => Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space3), - child: ShiftCard( - data: ShiftCardData.fromCompleted(shift), - onTap: () => - Modular.to.toShiftDetailsById(shift.shiftId), - ), - ), + (CompletedShift shift) { + final bool isSubmitted = + submittedShiftIds.contains(shift.shiftId); + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: ShiftCard( + data: ShiftCardData.fromCompleted(shift), + onTap: () => + Modular.to.toShiftDetailsById(shift.shiftId), + showApprovalAction: !isSubmitted, + isSubmitted: isSubmitted, + onSubmitForApproval: () { + ReadContext(context).read().add( + SubmitForApprovalEvent(shiftId: shift.shiftId), + ); + UiSnackbar.show( + context, + message: context.t.staff_shifts + .my_shift_card.timesheet_submitted, + type: UiSnackbarType.success, + ); + }, + ), + ); + }, ), const SizedBox(height: UiConstants.space32), ], diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart index c4c52421..f18a26ff 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart @@ -7,6 +7,7 @@ import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_shifts/src/domain/utils/shift_date_utils.dart'; import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart'; import 'package:staff_shifts/src/presentation/widgets/shift_card.dart'; @@ -20,6 +21,7 @@ class MyShiftsTab extends StatefulWidget { required this.pendingAssignments, required this.cancelledShifts, this.initialDate, + this.submittedShiftIds = const {}, }); /// Assigned shifts for the current week. @@ -34,6 +36,9 @@ class MyShiftsTab extends StatefulWidget { /// Initial date to select in the calendar. final DateTime? initialDate; + /// Set of shift IDs that have been successfully submitted for approval. + final Set submittedShiftIds; + @override State createState() => _MyShiftsTabState(); } @@ -42,9 +47,6 @@ class _MyShiftsTabState extends State { DateTime _selectedDate = DateTime.now(); int _weekOffset = 0; - /// Tracks which completed-shift cards have been submitted locally. - final Set _submittedShiftIds = {}; - @override void initState() { super.initState(); @@ -90,20 +92,7 @@ class _MyShiftsTabState extends State { }); } - List _getCalendarDays() { - final DateTime now = DateTime.now(); - final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday; - final int daysSinceFriday = (reactDayIndex + 2) % 7; - final DateTime start = now - .subtract(Duration(days: daysSinceFriday)) - .add(Duration(days: _weekOffset * 7)); - final DateTime startDate = - DateTime(start.year, start.month, start.day); - return List.generate( - 7, - (int index) => startDate.add(Duration(days: index)), - ); - } + List _getCalendarDays() => getCalendarDaysForOffset(_weekOffset); void _loadShiftsForCurrentWeek() { final List calendarDays = _getCalendarDays(); @@ -402,7 +391,7 @@ class _MyShiftsTabState extends State { final bool isCompleted = shift.status == AssignmentStatus.completed; final bool isSubmitted = - _submittedShiftIds.contains(shift.shiftId); + widget.submittedShiftIds.contains(shift.shiftId); return Padding( padding: const EdgeInsets.only( @@ -415,9 +404,11 @@ class _MyShiftsTabState extends State { showApprovalAction: isCompleted, isSubmitted: isSubmitted, onSubmitForApproval: () { - setState(() { - _submittedShiftIds.add(shift.shiftId); - }); + ReadContext(context).read().add( + SubmitForApprovalEvent( + shiftId: shift.shiftId, + ), + ); UiSnackbar.show( context, message: context.t.staff_shifts diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart b/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart index e35cf7cb..98a51de7 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart @@ -14,6 +14,7 @@ import 'package:staff_shifts/src/domain/usecases/get_profile_completion_usecase. import 'package:staff_shifts/src/domain/usecases/get_shift_details_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/accept_shift_usecase.dart'; import 'package:staff_shifts/src/domain/usecases/decline_shift_usecase.dart'; +import 'package:staff_shifts/src/domain/usecases/submit_for_approval_usecase.dart'; import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; import 'package:staff_shifts/src/presentation/blocs/shift_details/shift_details_bloc.dart'; import 'package:staff_shifts/src/presentation/utils/shift_tab_type.dart'; @@ -45,6 +46,9 @@ class StaffShiftsModule extends Module { i.addLazySingleton(ApplyForShiftUseCase.new); i.addLazySingleton(GetShiftDetailUseCase.new); i.addLazySingleton(GetProfileCompletionUseCase.new); + i.addLazySingleton( + () => SubmitForApprovalUseCase(i.get()), + ); // BLoC i.add( @@ -57,6 +61,7 @@ class StaffShiftsModule extends Module { getProfileCompletion: i.get(), acceptShift: i.get(), declineShift: i.get(), + submitForApproval: i.get(), ), ); i.add(