From 6eafba311b3fa921484f141aaa417ca17f2a788a Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Wed, 25 Feb 2026 15:10:44 -0500 Subject: [PATCH] refactor: Implement custom DioClient with AuthInterceptor and strongly typed API service responses. --- apps/mobile/packages/core/lib/core.dart | 1 + .../packages/core/lib/src/core_module.dart | 4 +- .../src/services/api_service/api_service.dart | 14 +------ .../file_upload/file_upload_service.dart | 11 ++++- .../core_api_services/llm/llm_service.dart | 11 ++++- .../signed_url/signed_url_service.dart | 11 ++++- .../verification/verification_service.dart | 41 +++++++++++++++---- .../src/services/api_service/dio_client.dart | 27 ++++++++++++ .../inspectors/auth_interceptor.dart | 24 +++++++++++ .../device_file_upload_service.dart | 5 ++- apps/mobile/packages/core/pubspec.yaml | 1 + .../attire_repository_impl.dart | 41 ++++++++----------- 12 files changed, 137 insertions(+), 54 deletions(-) create mode 100644 apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart create mode 100644 apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart diff --git a/apps/mobile/packages/core/lib/core.dart b/apps/mobile/packages/core/lib/core.dart index f6ef5e80..e5dff061 100644 --- a/apps/mobile/packages/core/lib/core.dart +++ b/apps/mobile/packages/core/lib/core.dart @@ -11,6 +11,7 @@ export 'src/presentation/observers/core_bloc_observer.dart'; export 'src/config/app_config.dart'; export 'src/routing/routing.dart'; export 'src/services/api_service/api_service.dart'; +export 'src/services/api_service/dio_client.dart'; // Core API Services export 'src/services/api_service/core_api_services/core_api_endpoints.dart'; diff --git a/apps/mobile/packages/core/lib/src/core_module.dart b/apps/mobile/packages/core/lib/src/core_module.dart index 78e584b0..bd782a8a 100644 --- a/apps/mobile/packages/core/lib/src/core_module.dart +++ b/apps/mobile/packages/core/lib/src/core_module.dart @@ -13,7 +13,7 @@ class CoreModule extends Module { @override void exportedBinds(Injector i) { // 1. Register the base HTTP client - i.addSingleton(() => Dio()); + i.addSingleton(() => DioClient()); // 2. Register the base API service i.addSingleton(() => ApiService(i.get())); @@ -31,7 +31,7 @@ class CoreModule extends Module { i.addSingleton(() => LlmService(i.get())); // 4. Register Device dependency - i.addSingleton(ImagePicker.new); + i.addSingleton(() => ImagePicker()); // 5. Register Device Services i.addSingleton(() => CameraService(i.get())); diff --git a/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart index 5edff474..db1119c9 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart @@ -88,21 +88,9 @@ class ApiService implements BaseApiService { /// Extracts [ApiResponse] from a successful [Response]. ApiResponse _handleResponse(Response response) { - if (response.data is Map) { - final Map body = response.data as Map; - return ApiResponse( - code: - body['code']?.toString() ?? - response.statusCode?.toString() ?? - 'unknown', - message: body['message']?.toString() ?? 'Success', - data: body['data'], - errors: _parseErrors(body['errors']), - ); - } return ApiResponse( code: response.statusCode?.toString() ?? '200', - message: 'Success', + message: response.data['message']?.toString() ?? 'Success', data: response.data, ); } diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_service.dart index 75886852..09dc2854 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_service.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_service.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:krow_domain/krow_domain.dart'; import '../core_api_endpoints.dart'; +import 'file_upload_response.dart'; /// Service for uploading files to the Core API. class FileUploadService extends BaseCoreService { @@ -12,13 +13,13 @@ class FileUploadService extends BaseCoreService { /// [filePath] is the local path to the file. /// [visibility] can be [FileVisibility.public] or [FileVisibility.private]. /// [category] is an optional metadata field. - Future uploadFile({ + Future uploadFile({ required String filePath, required String fileName, FileVisibility visibility = FileVisibility.private, String? category, }) async { - return action(() async { + final ApiResponse res = await action(() async { final FormData formData = FormData.fromMap({ 'file': await MultipartFile.fromFile(filePath, filename: fileName), 'visibility': visibility.value, @@ -27,5 +28,11 @@ class FileUploadService extends BaseCoreService { return api.post(CoreApiEndpoints.uploadFile, data: formData); }); + + if (res.code.startsWith('2')) { + return FileUploadResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); } } diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_service.dart index 0681dd1b..5bf6208d 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_service.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_service.dart @@ -1,5 +1,6 @@ import 'package:krow_domain/krow_domain.dart'; import '../core_api_endpoints.dart'; +import 'llm_response.dart'; /// Service for invoking Large Language Models (LLM). class LlmService extends BaseCoreService { @@ -11,12 +12,12 @@ class LlmService extends BaseCoreService { /// [prompt] is the text instruction for the model. /// [responseJsonSchema] is an optional JSON schema to enforce structure. /// [fileUrls] are optional URLs of files (images/PDFs) to include in context. - Future invokeLlm({ + Future invokeLlm({ required String prompt, Map? responseJsonSchema, List? fileUrls, }) async { - return action(() async { + final ApiResponse res = await action(() async { return api.post( CoreApiEndpoints.invokeLlm, data: { @@ -27,5 +28,11 @@ class LlmService extends BaseCoreService { }, ); }); + + if (res.code.startsWith('2')) { + return LlmResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); } } diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_service.dart index 31ca5948..f25fea52 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_service.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_service.dart @@ -1,5 +1,6 @@ import 'package:krow_domain/krow_domain.dart'; import '../core_api_endpoints.dart'; +import 'signed_url_response.dart'; /// Service for creating signed URLs for Cloud Storage objects. class SignedUrlService extends BaseCoreService { @@ -10,11 +11,11 @@ class SignedUrlService extends BaseCoreService { /// /// [fileUri] should be in gs:// format. /// [expiresInSeconds] must be <= 900. - Future createSignedUrl({ + Future createSignedUrl({ required String fileUri, int expiresInSeconds = 300, }) async { - return action(() async { + final ApiResponse res = await action(() async { return api.post( CoreApiEndpoints.createSignedUrl, data: { @@ -23,5 +24,11 @@ class SignedUrlService extends BaseCoreService { }, ); }); + + if (res.code.startsWith('2')) { + return SignedUrlResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); } } diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart index 1446bddc..73390819 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart @@ -1,5 +1,6 @@ import 'package:krow_domain/krow_domain.dart'; import '../core_api_endpoints.dart'; +import 'verification_response.dart'; /// Service for handling async verification jobs. class VerificationService extends BaseCoreService { @@ -11,14 +12,14 @@ class VerificationService extends BaseCoreService { /// [type] can be 'attire', 'government_id', etc. /// [subjectType] is usually 'worker'. /// [fileUri] is the gs:// path of the uploaded file. - Future createVerification({ + Future createVerification({ required String type, required String subjectType, required String subjectId, required String fileUri, Map? rules, }) async { - return action(() async { + final ApiResponse res = await action(() async { return api.post( CoreApiEndpoints.verifications, data: { @@ -30,25 +31,37 @@ class VerificationService extends BaseCoreService { }, ); }); + + if (res.code.startsWith('2')) { + return VerificationResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); } /// Polls the status of a specific verification. - Future getStatus(String verificationId) async { - return action(() async { + Future getStatus(String verificationId) async { + final ApiResponse res = await action(() async { return api.get(CoreApiEndpoints.verificationStatus(verificationId)); }); + + if (res.code.startsWith('2')) { + return VerificationResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); } /// Submits a manual review decision. /// /// [decision] should be 'APPROVED' or 'REJECTED'. - Future reviewVerification({ + Future reviewVerification({ required String verificationId, required String decision, String? note, String? reasonCode, }) async { - return action(() async { + final ApiResponse res = await action(() async { return api.post( CoreApiEndpoints.verificationReview(verificationId), data: { @@ -58,12 +71,24 @@ class VerificationService extends BaseCoreService { }, ); }); + + if (res.code.startsWith('2')) { + return VerificationResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); } /// Retries a verification job that failed or needs re-processing. - Future retryVerification(String verificationId) async { - return action(() async { + Future retryVerification(String verificationId) async { + final ApiResponse res = await action(() async { return api.post(CoreApiEndpoints.verificationRetry(verificationId)); }); + + if (res.code.startsWith('2')) { + return VerificationResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); } } diff --git a/apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart b/apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart new file mode 100644 index 00000000..e035ae18 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart @@ -0,0 +1,27 @@ +import 'package:dio/dio.dart'; +import 'package:krow_core/src/services/api_service/inspectors/auth_interceptor.dart'; + +/// A custom Dio client for the Krow project that includes basic configuration +/// and an [AuthInterceptor]. +class DioClient extends DioMixin implements Dio { + DioClient([BaseOptions? baseOptions]) { + options = + baseOptions ?? + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + ); + + // Use the default adapter + httpClientAdapter = HttpClientAdapter(); + + // Add interceptors + interceptors.addAll([ + AuthInterceptor(), + LogInterceptor( + requestBody: true, + responseBody: true, + ), // Added for better debugging + ]); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart b/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart new file mode 100644 index 00000000..d6974e57 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart @@ -0,0 +1,24 @@ +import 'package:dio/dio.dart'; +import 'package:firebase_auth/firebase_auth.dart'; + +/// An interceptor that adds the Firebase Auth ID token to the Authorization header. +class AuthInterceptor extends Interceptor { + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + final User? user = FirebaseAuth.instance.currentUser; + if (user != null) { + try { + final String? token = await user.getIdToken(); + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + } catch (e) { + rethrow; + } + } + return handler.next(options); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/device/file_upload/device_file_upload_service.dart b/apps/mobile/packages/core/lib/src/services/device/file_upload/device_file_upload_service.dart index 55892fd3..4fea7e77 100644 --- a/apps/mobile/packages/core/lib/src/services/device/file_upload/device_file_upload_service.dart +++ b/apps/mobile/packages/core/lib/src/services/device/file_upload/device_file_upload_service.dart @@ -2,6 +2,7 @@ import 'package:krow_domain/krow_domain.dart'; import '../camera/camera_service.dart'; import '../gallery/gallery_service.dart'; import '../../api_service/core_api_services/file_upload/file_upload_service.dart'; +import '../../api_service/core_api_services/file_upload/file_upload_response.dart'; /// Orchestrator service that combines device picking and network uploading. /// @@ -20,7 +21,7 @@ class DeviceFileUploadService extends BaseDeviceService { final FileUploadService apiUploadService; /// Captures a photo from the camera and uploads it immediately. - Future uploadFromCamera({ + Future uploadFromCamera({ required String fileName, FileVisibility visibility = FileVisibility.private, String? category, @@ -39,7 +40,7 @@ class DeviceFileUploadService extends BaseDeviceService { } /// Picks an image from the gallery and uploads it immediately. - Future uploadFromGallery({ + Future uploadFromGallery({ required String fileName, FileVisibility visibility = FileVisibility.private, String? category, diff --git a/apps/mobile/packages/core/pubspec.yaml b/apps/mobile/packages/core/pubspec.yaml index 421c9a2b..08ec902f 100644 --- a/apps/mobile/packages/core/pubspec.yaml +++ b/apps/mobile/packages/core/pubspec.yaml @@ -25,3 +25,4 @@ dependencies: image_picker: ^1.1.2 path_provider: ^2.1.3 file_picker: ^8.1.7 + firebase_auth: ^6.1.4 diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart index 4b278417..9ad0acb2 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart @@ -37,23 +37,18 @@ class AttireRepositoryImpl implements AttireRepository { Future uploadPhoto(String itemId, String filePath) async { // 1. Upload file to Core API final FileUploadService uploadService = Modular.get(); - final ApiResponse uploadRes = await uploadService.uploadFile( + final FileUploadResponse uploadRes = await uploadService.uploadFile( filePath: filePath, fileName: filePath.split('/').last, ); - if (!uploadRes.code.startsWith('2')) { - throw Exception('Upload failed: ${uploadRes.message}'); - } - - final String fileUri = uploadRes.data?['fileUri'] as String; + final String fileUri = uploadRes.fileUri; // 2. Create signed URL for the uploaded file final SignedUrlService signedUrlService = Modular.get(); - final ApiResponse signedUrlRes = await signedUrlService.createSignedUrl( - fileUri: fileUri, - ); - final String photoUrl = signedUrlRes.data?['signedUrl'] as String; + final SignedUrlResponse signedUrlRes = await signedUrlService + .createSignedUrl(fileUri: fileUri); + final String photoUrl = signedUrlRes.signedUrl; // 3. Initiate verification job final VerificationService verificationService = @@ -68,14 +63,15 @@ class AttireRepositoryImpl implements AttireRepository { final String dressCode = '${targetItem.description ?? ''} ${targetItem.label}'.trim(); - final ApiResponse verifyRes = await verificationService.createVerification( - type: 'attire', - subjectType: 'worker', - subjectId: staff.id, - fileUri: fileUri, - rules: {'dressCode': dressCode}, - ); - final String verificationId = verifyRes.data?['verificationId'] as String; + final VerificationResponse verifyRes = await verificationService + .createVerification( + type: 'attire', + subjectType: 'worker', + subjectId: staff.id, + fileUri: fileUri, + rules: {'dressCode': dressCode}, + ); + final String verificationId = verifyRes.verificationId; // 4. Poll for status until it's finished or timeout (max 10 seconds) try { @@ -83,11 +79,10 @@ class AttireRepositoryImpl implements AttireRepository { bool isFinished = false; while (!isFinished && attempts < 5) { await Future.delayed(const Duration(seconds: 2)); - final ApiResponse statusRes = await verificationService.getStatus( - verificationId, - ); - final String? status = statusRes.data?['status'] as String?; - if (status != null && status != 'PENDING' && status != 'QUEUED') { + final VerificationResponse statusRes = await verificationService + .getStatus(verificationId); + final String status = statusRes.status; + if (status != 'PENDING' && status != 'QUEUED') { isFinished = true; } attempts++;